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

# Notifications (webhooks)

> Get told the moment something happens on a deal: created, submitted, answered, offer in, statements analysed, renewal-ready.

Webhooks tell your systems the moment something happens in your workspace: a deal was created, a lender replied, an offer came in, a bank statement finished analysing, an advance is ready to renew. Each notification is delivered to a web address you choose, signed so you can be sure it came from us. They are the trigger for anything automated, from a Slack ping to a full underwriting agent, and they replace asking the API "has anything changed?" over and over.

## What you get told about

| Event | Fires when | What it carries |
| - | - | - |
| `deal.created` | A deal is created, from the app, the API, an intake form or the CRM | The deal and its business id |
| `deal.stage_changed` | A deal moves to another pipeline stage, by hand or automatically | The old and new stage and what triggered the move |
| `deal.funded` | A deal is marked funded (the advance already exists when this fires) | The deal and the stage it entered |
| `deal.dead` | A deal is marked dead | The deal and the reason |
| `submission.sent` | A submission goes out to a lender, by email, through the lender's API, or recorded by hand | The submission and the lender |
| `submission.responded` | A lender answers: approved, declined or needs more info | The submission, the lender and the answer |
| `offer.received` | An offer is logged, by a person, through the API or pulled from a lender's email | The offer with its terms |
| `offer.primary_changed` | A different offer becomes the primary offer | The new primary offer and the previous one's id |
| `document.uploaded` | A document lands on a deal, from the app, the merchant portal or an upload | The document |
| `document.processed` | Analysis finishes for one bank statement | That statement's figures |
| `statement.analyzed` | Analysis finishes for every statement on a deal | Revenue, average balance, NSFs, negative days, positions |
| `advance.renewal_ready` | An update to an advance (a new `paid_amount`, a status or a threshold change) takes it past its renewal threshold. An advance paying down on its schedule crosses it without an update, so poll `GET /v1/advances?renewal_ready=true` for those | The advance and the percentage paid in |
| `advance.status_changed` | An advance changes status: on track, missed payments, defaulted, paid off | The advance with old and new status |
| `business.created` | A business is created | The business |
| `person.created` | A contact or owner is created | The person |

Each notification carries the ids you need and a compact snapshot of the record: names, amounts, statuses and dates. It never carries application data, social security numbers, dates of birth or CRM ids. Treat it as the nudge, not the source of truth: look the record up when you need the full, current picture.

## Subscribing

In the app, go to **Settings → Developers → Webhooks → Add endpoint**, paste your HTTPS address and tick the events you want (or none, for all of them, including any added later). You get a signing secret once; keep it. The same page shows every delivery with your endpoint's response, lets you send a test event, rotate the secret, and replay any delivery, which is how you catch up after an outage. A developer can do the same through the API with a credential that has `webhooks:manage`.

## When your endpoint is down

* We keep trying: 1 minute, 5 minutes, 30 minutes, 2 hours and then 6 hours after each failed attempt, before giving up on that delivery.
* If every delivery to an endpoint has failed for three days, the subscription is switched off and the workspace admins get an email. Fix the endpoint, then switch it back on from Settings.
* Nothing is lost. Deliveries that gave up can be replayed from Settings at any time.

## What to build with it

* **Rep alerts.** `offer.received` and `submission.responded` into Slack, Teams or a text, with the lender name and terms.
* **Stuck-deal follow-up.** `deal.stage_changed` starts or stops a follow-up cadence in your CRM or dialer.
* **Underwriting automation.** `statement.analyzed` re-runs lender matching and submits to the lenders that fit.
* **Renewal pipeline.** `advance.renewal_ready` opens a task or a conversation with the merchant.
* **Reporting.** Mirror every event into your warehouse for dashboards that update as the day goes on.

## For developers

**Envelope.** Every delivery is a `POST` with the same JSON body: `id` (`evt_…`, stable across retries), `type`, `created_at`, `location_id` and `data`. `data` carries routing ids plus a 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` | As `submission.sent` plus `status` (`approved`, `declined` or `needs_info`) |
| `offer.received` | `offer_id`, `deal_id`, `lender_id`, `lender_name`, `offer`; `source: "email_classifier"` when 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` (`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` |

Snapshots are a fixed subset of the REST fields; the `offer` snapshot carries `status_id` rather than the status slug. Fetch the resource (`GET /v1/offers/{id}` and so on) for the full record.

**Subscribe by API.** `POST /v1/webhooks` with `url`, `events` (empty array for all) and an optional `description`; the response includes `secret` once. `url` must be absolute HTTPS (`http://localhost` and `http://127.0.0.1` are accepted for local development; private and loopback hosts are otherwise rejected). `GET /v1/webhooks` lists subscriptions with their health (`status`, `failing_since`, `last_success_at`, `last_failure_at`) without secrets; `DELETE /v1/webhooks/{id}` removes one; `POST /v1/webhooks/{id}/test` delivers a `webhook.test` event synchronously (even to a disabled subscription), signed like a real event and never retried, and returns the single attempt: `delivery_id`, `event_type`, `status` (`succeeded` or `dead`), `response_status`, the first 2 KB of `response_body`, `error` and `duration_ms`.

**Headers on every delivery.** `Content-Type: application/json`, `User-Agent: NextLevelMCA-Webhooks/1.0`, `X-NLMCA-Event` (the type), `X-NLMCA-Delivery` (the delivery id; retries carry the same one) and `X-NLMCA-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>`.

**Verify the signature.** HMAC-SHA256 with your secret over `` `${t}.${rawBody}` ``, the timestamp, a dot and the exact request bytes. Hash the raw body before parsing (re-serialised JSON breaks the HMAC), 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=`; accept the delivery when any matches.

```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);
  });
}
```

In Express, read the body with `express.raw({ type: 'application/json' })` so the bytes reach the verifier untouched, answer `200` as soon as the signature checks out and the event is stored, and do the real work afterwards.

**Delivery rules.** Deliveries time out after **10 seconds**; any `2xx` is a success, and a non-`2xx` status, a redirect (not followed), a connection error or a timeout is a failure that follows the retry schedule above, then `dead`. Deliveries are at-least-once and not guaranteed in order, so dedupe on the event `id` (keep handled ids for a day), use `created_at` rather than arrival order, refetch the resource before acting where it matters, and return a non-`2xx` only when you actually want a retry.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.