> For the complete documentation index, see [llms.txt](https://docs.moderncollections.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.moderncollections.io/webhooks/receiving.md).

# Receiving webhooks

Registering an endpoint, the envelope, acknowledgment, retries, and partner fan-out.

We POST to a URL you register whenever something happens on a placement. Webhooks are the right way to integrate; polling is the fallback.

(We also *receive* webhooks from upstream voice, email, payment and accounting providers — those are internal. The one inbound path that is yours is `POST /v1/intake/structured`, which you HMAC-sign; see [signing requests you send us](/webhooks/verifying-signatures.md#signing-requests-you-send-us).)

## Registering a URL

| Where         | How                                            | Scope                             |
| ------------- | ---------------------------------------------- | --------------------------------- |
| Per creditor  | `webhook_url` via `PATCH /v1/settings`         | All placements, unless overridden |
| Per placement | `callback_url` on `POST /v1/placements`        | That placement only               |
| Per partner   | `outbound_webhook_url` via `PATCH /v1/partner` | Every creditor linked to you      |

Changing or clearing `webhook_url` requires a **dashboard session** — `ca_*` and `pa_*` bearers get `403`. A partner session also needs `manage` Authority on the link.

URLs must be **HTTPS** and must not resolve to a private, loopback, link-local, reserved or multicast IP — `localhost`, `127.0.0.1`, `::1`, anything in `169.254.0.0/16`. Credentials embedded in the URL (`https://user:pass@host/…`) are rejected. The same validation runs on all three registration paths. URLs are resolved once and the connection is pinned to that IP to defeat DNS rebinding. **A URL failing any check is silently dropped** — no delivery, no retry.

## The envelope

```http
POST https://yourapp.example/webhooks/moderncollections HTTP/1.1
Content-Type: application/json
X-Signature: 7f9c2ba4e88f827d616045507605853ed73b8093a3f1f3a8b9c9c8d6c0b7c1a2
X-Webhook-Timestamp: 1715520000
X-MC-Signing-Key-Id: creditor:9f1c0f7e-2b4a-4d0e-9a76-1c2b3d4e5f60
X-Delivery-Id: 8a3fbb2e-51f0-4715-9c1c-6b1a2d64f8f3

{
  "event": "placement.status_changed",
  "event_id": "3b2a1c9d-8e77-4f10-9c2e-7a1b2c3d4e5f",
  "timestamp": "2026-05-12T14:32:00+00:00",
  "creditor_id": "9f1c0f7e-2b4a-4d0e-9a76-1c2b3d4e5f60",
  "placement_id": "550e8400-e29b-41d4-a716-446655440000",
  "previous_status": "pending_payment",
  "new_status": "paid",
  "recovered_amount": 7500.0,
  "fee_amount": 375.0,
  "facts": {
    "debtor_company": "Acme Logistics LLC",
    "invoice_number": "INV-1042",
    "dashboard_url": "https://app.moderncollections.io/placements/550e8400-…",
    "creditor_name": "Northwind Supply Co."
  }
}
```

| Header                | Meaning                                                                                                                             |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `X-Signature`         | HMAC-SHA256 hex digest of `"{timestamp}.{body}"`. Raw hex — no `sha256=` prefix. **Always verify.**                                 |
| `X-Webhook-Timestamp` | Unix epoch seconds when we sent it. Reject anything more than 5 minutes from your clock.                                            |
| `X-MC-Signing-Key-Id` | Which secret signed it — `creditor:{uuid}` or `partner:{uuid}`. Select your key from this, so you verify *before* parsing the body. |
| `X-Delivery-Id`       | UUID for this delivery, **constant across retries**. Dedupe retries of one delivery on it.                                          |

The body is a flat JSON object. Every event opens with `event`, `event_id`, `timestamp` and `creditor_id`, then event-specific fields **at the top level** — not nested under `data`. `creditor_id` is present on every event, including those with no `placement_id`, and cannot be overridden by a payload field of the same name.

`event_id` is a second identifier, distinct from `X-Delivery-Id`: it names the *logical event*, not the delivery row, so it is the same value on the creditor's own copy and the partner fan-out copy of one event (see [Partner fan-out](#partner-fan-out)) — `X-Delivery-Id` differs between them. Like `creditor_id`, it cannot be overridden by a payload field of the same name.

Two keys may follow: `partner_id` (only on the copy delivered to a partner's fan-out URL) and `facts` (on placement-scoped events, so you can write your own creditor-facing mail without a second lookup; `dashboard_url` is `null` when no dashboard base URL is configured).

The event name is in the body as `event`. There is no `X-Event` header.

## Acknowledging

* Return **2xx** within our 10-second timeout.
* **4xx other than 408 and 429 are permanent** — straight to dead-letter, no retries. You have told us the request is broken.
* 5xx, 408, 429, connection errors and timeouts are transient and retried.
* Don't run business logic inline. Acknowledge fast, enqueue, process async. Targeting \~1 second leaves room on both sides.

## Retries

Five attempts total — the first inline with the event, four scheduled after each failure:

```
Attempt 1: immediately
Attempt 2: +1 minute
Attempt 3: +5 minutes
Attempt 4: +30 minutes
Attempt 5: +2 hours     → failure here dead-letters immediately
```

The body bytes are preserved unchanged across attempts — re-serialising would change byte ordering and break HMAC verification. `X-Webhook-Timestamp` is refreshed on each retry and `X-Signature` recomputed over that fresh timestamp and the original bytes, so a standard 5-minute freshness check won't reject a retry. The timestamp reflects when the retry was sent, not when the event occurred.

Delivery is **at-least-once** — dedupe on `X-Delivery-Id`, stored for at least 24 hours (the full retry window):

```python
async def receive_webhook(request: Request):
    # ... signature verification first ...
    delivery_id = request.headers["X-Delivery-Id"]
    if await redis.set(f"mc:webhook:{delivery_id}", "1", nx=True, ex=86400) is None:
        return {"received": True, "duplicate": True}
    await enqueue_for_processing(await request.json())
    return {"received": True}
```

Events are delivered in roughly the order they occurred but are **not strictly ordered** — one failing and retrying while the next succeeds reverses them. Reject transitions you have already applied.

### Delivery history, redelivery, and test sends

Self-service, scoped to deliveries addressed to your own `webhook_url` (a placement's `callback_url` deliveries aren't included):

|                                                                |                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/settings/webhook-deliveries`                          | Every attempt to your `webhook_url`, newest first. Filter with `status` (`pending` \| `delivered` \| `dead_letter`), `event`, `limit` (1–100, default 50), `offset`. The signed body is never returned — this tells you whether a delivery landed, not what it said.                                                                                                                                                               |
| `POST /v1/settings/webhook-deliveries/{delivery_id}/redeliver` | Replay one delivery now, including a dead-lettered one. One attempt; the response is its outcome. Re-signed with a fresh timestamp, so a delivery replayed days later still lands inside your verifier's 5-minute freshness window. `X-Delivery-Id` does not change — it's the same logical delivery. `404 {"reason": "delivery_not_found"}` for an unknown id; `409 {"reason": "delivery_already_pending"}` if it's still queued. |
| `POST /v1/settings/webhook-deliveries/test`                    | Send a signed `webhook.test` to your `webhook_url` before real traffic reaches it. `409 {"reason": "webhook_url_unset"}` if you haven't set one; `409 {"reason": "webhook_url_blocked"}` if the destination check refused it.                                                                                                                                                                                                      |

Platform partners have the equivalent surface under `/v1/partner/webhook-deliveries`, scoped across every creditor they hold an active link to.

## Partner fan-out

When a creditor has an active partner link and that partner has `outbound_webhook_url` set, each event is delivered **twice**:

| Copy     | Target                                                                                       | Envelope           | Signed with               |
| -------- | -------------------------------------------------------------------------------------------- | ------------------ | ------------------------- |
| Creditor | The creditor's `webhook_url` or the placement's `callback_url`. Skipped when neither is set. | No `partner_id`.   | The creditor's secret.    |
| Partner  | The partner's `outbound_webhook_url`. Sent even when the creditor has no URL of their own.   | Adds `partner_id`. | The **partner's** secret. |

They are independent deliveries — own `X-Delivery-Id`, own signing key, own retry schedule, own dead-letter outcome — but share one `event_id`, minted once for the underlying event before the fan-out split. If you operate both endpoints, dedupe across them on `event_id` rather than `X-Delivery-Id`. If the two URLs are identical the partner copy is suppressed, so one delivery arrives rather than two. A creditor has at most one active link, so there is never more than one partner copy.

## The event catalogue is authoritative

`GET /v1/settings/webhook-events` (creditor key) and `GET /v1/partner/webhook-events` (partner principal) return the platform's own event table with descriptions, `payload_fields` and sample bodies. **Query one at integration time** rather than hard-coding a list from any documentation page.

[Events](/webhooks/events.md) documents the payloads you are most likely to handle. Build your handler to **ignore unrecognized `event` values** rather than erroring — new types are announced in the [change log](https://changelog.moderncollections.io).

## See also

* [Signature verification](/webhooks/verifying-signatures.md)
* [Events](/webhooks/events.md)
* [Errors and limits](/getting-started/errors.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.moderncollections.io/webhooks/receiving.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
