> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nextlevelmca.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

> Envelopes, errors, request ids, pagination, idempotency, rate limits, timestamps, ids and versioning.

Everything on `/v1` follows the rules on this page. They are applied by one shared request pipeline, so they hold for every endpoint.

## Requests

* Base URL `https://api.nextlevelmca.com/v1`, HTTPS only.
* JSON in, JSON out. Send `Content-Type: application/json` with any request body.
* Authenticate with `Authorization: Bearer <key or token>`. See [Authentication](/getting-started/authentication).

## Response envelope

Every successful response is an object with `data` and `meta`.

```json Single object theme={null}
{
  "data": { "id": "0f6b0d2e-1c3a-4b8e-9d2f-7a1e5c4b3d21", "legal_name": "ACME Plumbing LLC" },
  "meta": {}
}
```

```json List theme={null}
{
  "data": [
    { "id": "3c9d1f70-8e5a-4d26-b1f4-2a7e6c5d4b39" },
    { "id": "8a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d" }
  ],
  "meta": { "next_cursor": "eyJvIjoyNX0", "has_more": true }
}
```

`meta` is empty for single objects, carries pagination for lists, and sometimes adds context (for example `meta.deal` on lender matches, `meta.primary_offer_id` on offers, `meta.warning` when a send step was skipped). Even `DELETE /v1/webhooks/{id}` returns an object (`{ "deleted": true, "id": … }`) so the envelope never changes shape. Creates return `201`, everything else `200`.

## Errors

Every error is an `error` object with the same five fields, never a bare message.

```json theme={null}
{
  "error": {
    "type": "validation_error",
    "message": "requested_amount must be a positive number",
    "code": "invalid_request",
    "param": "requested_amount",
    "request_id": "req_Kq3xT9vLwZ1a"
  }
}
```

<ResponseField name="type" type="string" required>
  One of the seven types below. Use it to decide how to react.
</ResponseField>

<ResponseField name="message" type="string" required>
  Human-readable and safe to show to an operator or an AI agent.
</ResponseField>

<ResponseField name="code" type="string">
  Stable and machine-readable. Branch on this, not on `message`.
</ResponseField>

<ResponseField name="param" type="string">
  The request field, header or scope the error refers to, when there is one.
</ResponseField>

<ResponseField name="request_id" type="string" required>
  The same value as the `X-Request-Id` response header. Quote it when contacting support.
</ResponseField>

| HTTP | `type`                 | Typical `code` values                                                                                                                                                                                                                                                      |
| ---- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `validation_error`     | `invalid_request` (a body or query field failed validation; `param` names it), `invalid_field`, `invalid_cursor`, `invalid_idempotency_key`, `invalid_phone`, `invalid_document`, `stage_rule` (a stage move the board would refuse; the message is the one the app shows) |
| 401  | `authentication_error` | `unauthorized` (missing, malformed, expired or revoked credential)                                                                                                                                                                                                         |
| 403  | `permission_error`     | `missing_scope` (`param` is the scope you need), `forbidden` (the credential's role lacks the permission)                                                                                                                                                                  |
| 404  | `not_found`            | `deal_not_found`, `business_not_found` and so on (`<resource>_not_found`). Records in another location are a 404, not a 403.                                                                                                                                               |
| 409  | `conflict`             | `duplicate_business`, `duplicate_person` (both with `existing_id`), `duplicate_lender`, `idempotency_key_reused`, `idempotency_in_progress`, `no_email_sender`, `cannot_withdraw`, `upload_not_found`, `no_primary_offer`, `already_funded`, `already_dead`                |
| 429  | `rate_limit_error`     | `rate_limited`                                                                                                                                                                                                                                                             |
| 500  | `api_error`            | `internal_error`. Retry with the same `Idempotency-Key`, or contact support with the request id.                                                                                                                                                                           |

Errors can carry extra machine-readable fields next to the standard ones; `existing_id` on `duplicate_business` and `duplicate_person` (the latter also has `matched_by`: `email` or `phone`) is the common case.

## Request ids

Every response, success or error, carries `X-Request-Id: req_…`. Error bodies repeat it as `error.request_id`. Log it next to your own request logs; support uses it to find the server-side trace.

## Pagination

List endpoints page with a cursor.

| Parameter | Meaning                                              |
| --------- | ---------------------------------------------------- |
| `limit`   | Page size, 1 to 100. Default 25.                     |
| `cursor`  | The `meta.next_cursor` value from the previous page. |

```bash theme={null}
curl "$NLMCA_API/deals?limit=50" -H "Authorization: Bearer $NLMCA_KEY"
# "meta": { "next_cursor": "eyJvIjo1MH0", "has_more": true }

curl "$NLMCA_API/deals?limit=50&cursor=eyJvIjo1MH0" -H "Authorization: Bearer $NLMCA_KEY"
# "meta": { "next_cursor": null, "has_more": false }
```

* `meta.has_more` is `true` while there are more rows; `meta.next_cursor` is `null` on the last page.
* Keep filters identical between pages. Cursors are opaque; do not build or edit them. A cursor the server cannot read returns `400` with `code: "invalid_cursor"`.
* Lists are ordered newest first (`created_at desc, id desc`) unless the endpoint says otherwise.
* Some lists also report `meta.total` (lender matches, submissions, deal activity).

In v1 the cursor encodes an offset, so a row inserted while you page can shift the window by one. If you need exactly-once processing across pages, dedupe on `id`.

## Idempotency

Send an `Idempotency-Key` header on any `POST` to make it safe to retry. Use it on everything that sends email or text messages: submissions, document requests, portal links, webhook tests.

```bash theme={null}
curl -X POST "$NLMCA_API/deals/$DEAL/submissions" \
  -H "Authorization: Bearer $NLMCA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f1c2e4a-9b7d-4c3e-a1f5-2d6b8c9e0f13" \
  -d '{ "lender_ids": ["6a2d9e11-4f0b-4c7d-8e3a-9b1c2d3e4f50"] }'
```

This is exactly what the server does:

* Keys are scoped to the credential, so two keys in the same location never collide. Maximum 255 characters; longer keys are a `400` with `code: "invalid_idempotency_key"`. UUIDs are a good choice.
* The first successful response (status and body) is stored for **24 hours**. A retry with the same key, method, path and body returns the stored response with the header `Idempotent-Replayed: true` and the original status code. The action does not run again.
* The same key with a different body or path is a `409` with `code: "idempotency_key_reused"`.
* While the first request is still running, a concurrent retry gets `409` with `code: "idempotency_in_progress"`. Wait a moment and retry.
* Only successful responses are stored. If the first attempt fails with a 4xx or 5xx, retrying with the same key runs the request again.
* Only `POST` reads the header. `PATCH`, `PUT` and `DELETE` are repeatable by nature.

## Rate limits

Each credential (API key or OAuth client) is limited, per location, to:

* **300 requests per minute**, and
* a burst of **50 requests per second**.

Both are fixed windows. Every response carries the minute-window headers:

| Header                  | Meaning                                                    |
| ----------------------- | ---------------------------------------------------------- |
| `X-RateLimit-Limit`     | Requests allowed per minute (300).                         |
| `X-RateLimit-Remaining` | Requests left in the current minute.                       |
| `X-RateLimit-Reset`     | Unix timestamp, in seconds, when the minute window resets. |

Exceeding either limit returns `429` with `type: "rate_limit_error"`, `code: "rate_limited"` and a `Retry-After` header in seconds: the time to the minute reset when the per-minute limit tripped, or `1` when the burst did. Sleep for `Retry-After`, then retry. Do not retry in a tight loop.

Limits are not per endpoint. Webhooks are the cheap way to learn about changes; poll only for what webhooks do not cover.

## Timestamps, ids and values

* Timestamps are ISO 8601 in UTC with millisecond precision, for example `2026-09-04T14:02:11.000Z`.
* Ids are UUIDs, as strings. CRM ids (such as `ghl_contact_id`) appear as read-only fields where a record is synced.
* Money is a JSON number in US dollars; percentages are numbers from 0 to 100; rates such as `factor_rate` are decimals (`1.32`).
* Phone numbers are returned as stored. `GET /v1/people?phone=` accepts any format and normalises both sides to E.164 digits before matching.

## Versioning

The major version is in the path: `/v1`. Within v1, changes are **additive**: new endpoints, new optional parameters, new response fields and new webhook event types can appear without notice, so ignore fields you do not recognise. Breaking changes (removing or renaming fields, changing types or semantics) only ship in a new major version, with the old one kept running through a published sunset date. Changes are listed in the [changelog](/changelog/changelog).


## Related topics

- [Quickstart](/getting-started/quickstart.md)
- [Changelog](/changelog/changelog.md)
