> ## 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.

# Webhooks

> Subscribe to events, verify signatures and handle retries.

Webhooks push events from your location to an HTTPS endpoint you control: a deal was created, a lender replied, a statement finished analysing. They are the trigger layer for anything automated. Poll the API only for what webhooks do not tell you.

## Create a subscription

In the app (Settings → Developers → Webhooks → Add endpoint), or with the API using a credential that has `webhooks:manage`:

```bash theme={null}
curl -X POST "$NLMCA_API/webhooks" \
  -H "Authorization: Bearer $NLMCA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/nlmca",
    "events": ["deal.created", "offer.received", "statement.analyzed"],
    "description": "Underwriting agent"
  }'
```

```json Response 201 theme={null}
{
  "data": {
    "id": "7e1a9c3b-2d4f-4e6a-8b0c-9d1e2f3a4b5c",
    "url": "https://example.com/hooks/nlmca",
    "events": ["deal.created", "offer.received", "statement.analyzed"],
    "description": "Underwriting agent",
    "status": "active",
    "disabled_reason": null,
    "failing_since": null,
    "last_success_at": null,
    "last_failure_at": null,
    "created_at": "2026-09-04T14:10:00.000Z",
    "updated_at": "2026-09-04T14:10:00.000Z",
    "secret": "whsec_2mS9Xq7LkT0vR4nB8pW1cD6fH3jY5aZ9eG2uI7oK1sM"
  },
  "meta": {}
}
```

<Warning>
  `secret` is returned once, on creation. Store it now; you need it to verify signatures. You can rotate it later from Settings → Developers, which invalidates the old one.
</Warning>

`url` must be an absolute HTTPS endpoint (`http://localhost` and `http://127.0.0.1` are accepted for local development; hosts that resolve to private or loopback addresses are rejected). An empty `events` array subscribes to every event, including types added later. `GET /v1/webhooks` lists subscriptions with their health (`status`, `failing_since`, `last_success_at`, `last_failure_at`) and without secrets, `DELETE /v1/webhooks/{id}` removes one and returns `{ deleted: true, id }`, and `POST /v1/webhooks/{id}/test` sends a `webhook.test` event so you can check your endpoint end to end.

## Event types

| Event                    | Fires when                                                                                                       |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `deal.created`           | A deal was created (app, public API, intake form or CRM).                                                        |
| `deal.stage_changed`     | A deal moved to a different pipeline stage (system event, board move or API).                                    |
| `deal.funded`            | A deal was marked funded and entered the funded phase. The advance exists by the time this fires.                |
| `deal.dead`              | A deal was marked dead, with the reason.                                                                         |
| `submission.sent`        | A submission was sent to a lender (by email or recorded manually).                                               |
| `submission.responded`   | A lender responded to a submission: `approved`, `declined` or `needs_info`.                                      |
| `offer.received`         | An offer was logged on a deal (manually, through the API or extracted from a lender email).                      |
| `offer.primary_changed`  | A different offer became the primary offer on a deal.                                                            |
| `document.uploaded`      | A document was added to a deal (app, merchant portal or a finalised API upload).                                 |
| `document.processed`     | Bank statement analysis finished for one document.                                                               |
| `statement.analyzed`     | Bank statement analysis finished for a deal (every statement in the batch); statement analysis is ready to read. |
| `advance.renewal_ready`  | A funded advance crossed its renewal threshold (percent paid in).                                                |
| `advance.status_changed` | A funded advance changed status (`on-track`, `missed-payments`, `defaulted`, `paid-off`).                        |
| `business.created`       | A business was created.                                                                                          |
| `person.created`         | A person (contact or owner) was created.                                                                         |

`webhook.test` is sent only by the test endpoint and the test button in the app.

## Payload

```json theme={null}
{
  "id": "evt_6f2c1a9e-3b4d-4c5e-8f7a-1b2c3d4e5f60",
  "type": "offer.received",
  "created_at": "2026-09-05T09:12:44.000Z",
  "location_id": "A1b2C3d4E5f6G7h8I9j0",
  "data": {
    "offer_id": "d3a4b5c6-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
    "deal_id": "3c9d1f70-8e5a-4d26-b1f4-2a7e6c5d4b39",
    "lender_id": "6a2d9e11-4f0b-4c7d-8e3a-9b1c2d3e4f50",
    "lender_name": "Northwind Capital",
    "offer": {
      "id": "d3a4b5c6-7e8f-4a9b-8c0d-1e2f3a4b5c6d",
      "deal_id": "3c9d1f70-8e5a-4d26-b1f4-2a7e6c5d4b39",
      "lender_id": "6a2d9e11-4f0b-4c7d-8e3a-9b1c2d3e4f50",
      "submission_id": "b1e2d3c4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
      "amount": 70000,
      "factor_rate": 1.32,
      "term": 9,
      "term_unit": "months",
      "payment_amount": 488.89,
      "payment_frequency": "daily",
      "payback_amount": 92400,
      "position": 1,
      "status_id": "c7d8e9f0-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
      "is_primary": false,
      "is_selected": false,
      "source": "api",
      "received_at": "2026-09-05T09:12:44.000Z",
      "expires_at": null,
      "created_at": "2026-09-05T09:12:44.000Z",
      "updated_at": "2026-09-05T09:12:44.000Z"
    }
  }
}
```

Every event has the same envelope: `id` (`evt_…`, stable across retries), `type`, `created_at`, `location_id` and `data`. `data` carries the ids you need for routing plus a compact snapshot of the object concerned:

| Event                      | `data` keys                                                                                                                                                                               |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deal.created`             | `deal_id`, `business_id`, `deal`                                                                                                                                                          |
| `deal.stage_changed`       | `deal_id`, `from_stage`, `to_stage` (each `{ id, label, phase, event }`), `trigger`, `event`, `reason`, `changed_at`                                                                      |
| `deal.funded`, `deal.dead` | `deal_id`, `stage`, `reason`, `changed_at`, `deal`                                                                                                                                        |
| `submission.sent`          | `submission_id`, `deal_id`, `lender_id`, `lender_name`, `submission`                                                                                                                      |
| `submission.responded`     | The same as `submission.sent` plus `status` (`approved`, `declined` or `needs_info`)                                                                                                      |
| `offer.received`           | `offer_id`, `deal_id`, `lender_id`, `lender_name`, `offer`; `source: "email_classifier"` is added when the offer was extracted from a lender email                                        |
| `offer.primary_changed`    | `offer_id`, `deal_id`, `lender_id`, `previous_primary_offer_id`, `offer`                                                                                                                  |
| `document.uploaded`        | `document_id`, `deal_id`, `document`                                                                                                                                                      |
| `document.processed`       | `document_id`, `deal_id`, `parsed`, `statement` (per-statement figures, or `null` when parsing failed)                                                                                    |
| `statement.analyzed`       | `deal_id`, `document_ids`, `statements_parsed`, `summary` (`true_revenue`, `average_balance`, `negative_days`, `nsf_count`, `mca_count`, `mca_withhold_percent`, `has_recovery_activity`) |
| `advance.renewal_ready`    | `advance_id`, `deal_id`, `lender_id`, `advance`, `percent_paid`, `renewal_threshold`                                                                                                      |
| `advance.status_changed`   | `advance_id`, `deal_id`, `lender_id`, `advance`, `previous_status`, `status`                                                                                                              |
| `business.created`         | `business_id`, `business`                                                                                                                                                                 |
| `person.created`           | `person_id`, `business_id`, `person`                                                                                                                                                      |
| `webhook.test`             | `subscription_id`, `message`, `sent_at`                                                                                                                                                   |

The snapshots (`deal`, `submission`, `offer`, `document`, `advance`, `business`, `person`) are a fixed subset of the REST fields: ids, names, amounts, statuses and timestamps. They never include `form_data`, SSNs, dates of birth, tokens or CRM ids, and the `offer` snapshot carries `status_id` rather than the status slug. Treat the payload as a notification, not the source of truth: fetch the resource (`GET /v1/offers/{id}` and so on) when you need the full, current record.

Headers on every delivery:

| Header              | Value                                                                                                                                    |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                                                                       |
| `User-Agent`        | `NextLevelMCA-Webhooks/1.0`                                                                                                              |
| `X-NLMCA-Event`     | The event type, for example `offer.received`.                                                                                            |
| `X-NLMCA-Delivery`  | Id of the delivery to this subscription. Retries carry the same delivery id and the same event `id`, so either works for de-duplication. |
| `X-NLMCA-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. See below.                                                                                      |

## Verify the signature

The signature is HMAC-SHA256 with your subscription secret over `` `${t}.${rawBody}` ``: the timestamp from the header, a dot, and the exact request body bytes. Verify before you parse, compare in constant time, and reject anything whose timestamp is more than five minutes off your clock. During a secret rotation the header can carry more than one `v1=` value; accept the delivery when any of them matches.

<CodeGroup>
  ```javascript verify.js theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';

  const TOLERANCE_SECONDS = 5 * 60;

  export function verifyNlmcaSignature(rawBody, signatureHeader, secret) {
    let t = null;
    const signatures = [];
    for (const part of String(signatureHeader ?? '').split(',')) {
      const idx = part.indexOf('=');
      if (idx <= 0) continue;
      const key = part.slice(0, idx).trim();
      const value = part.slice(idx + 1).trim();
      if (key === 't' && /^\d+$/.test(value)) t = Number(value);
      else if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
    }
    if (t === null || signatures.length === 0) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;

    const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'), 'hex');
    return signatures.some((sig) => {
      const candidate = Buffer.from(sig, 'hex');
      return candidate.length === expected.length && timingSafeEqual(candidate, expected);
    });
  }
  ```

  ```javascript server.js (Express) theme={null}
  import express from 'express';
  import { verifyNlmcaSignature } from './verify.js';

  const app = express();

  app.post(
    '/hooks/nlmca',
    express.raw({ type: 'application/json' }), // keep the raw bytes
    (req, res) => {
      const raw = req.body.toString('utf8');
      const ok = verifyNlmcaSignature(raw, req.get('X-NLMCA-Signature'), process.env.NLMCA_WEBHOOK_SECRET);
      if (!ok) return res.status(400).send('bad signature');

      const event = JSON.parse(raw);
      res.status(200).end(); // acknowledge first, then do the work
      handleEvent(event).catch(console.error);
    },
  );

  async function handleEvent(event) {
    // dedupe on event.id, then act on event.type
  }

  app.listen(3000);
  ```
</CodeGroup>

The usual mistake is parsing the JSON and re-serialising it before hashing: any change in whitespace or key order breaks the HMAC. Always hash the bytes as received.

## Delivery and retries

* Deliveries are `POST` requests with a **10 second** timeout. Any `2xx` status is a success; anything else is a failure: a non-`2xx` status, a redirect (redirects are not followed), a connection error or a timeout.
* Failed deliveries are retried **1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours** after the previous attempt (six attempts in all), then marked dead.
* If a subscription keeps failing for **3 days**, it is set to `disabled` (with `disabled_reason`) and the location's admins receive an email. Fix the endpoint, then re-enable it from Settings → Developers → Webhooks.
* Deliveries are at-least-once: they are not guaranteed to arrive in order, and an event can be delivered more than once.
* Test events (`POST /v1/webhooks/{id}/test`) are delivered synchronously, signed like real events and never retried. The response reports the single attempt: `delivery_id`, `event_type`, `status` (`succeeded` or `dead`), `response_status`, the first 2 KB of `response_body`, `error` and `duration_ms`.

## Replay

Settings → Developers → Webhooks shows every delivery per endpoint with the event type, the attempts and your endpoint's response. Any delivery, including dead ones, can be replayed from there, which is how you catch up after an outage or test a fix against real payloads.

## Consume idempotently

* Dedupe on the event `id`. Keep the ids you have handled for a day and ignore repeats.
* Return `2xx` as soon as the signature checks out and the event is persisted; do the real work asynchronously. Slow handlers hit the 10 second timeout and cause retries.
* Do not rely on order. Use `created_at` and, where it matters, refetch the resource before acting on it.
* Return a non-`2xx` status only when you actually want a retry.


## Related topics

- [Create a webhook subscription](/api-reference/webhooks/create-a-webhook-subscription.md)
- [Get a webhook subscription](/api-reference/webhooks/get-a-webhook-subscription.md)
- [List webhook subscriptions](/api-reference/webhooks/list-webhook-subscriptions.md)
