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

# Webhooks

> Subscribe to investor, mandate, order and payment lifecycle events instead of polling — signing, retries, and the full event catalog.

Investor registration, mandate registration, order dispatch and payment
settlement are all asynchronous — see
[Process flows](/api-documentation/process-flows). Polling
`GET /api/.../:id` always works, but a webhook subscription is the faster and
cheaper way to learn about a state change the moment it happens.

<Note>
  Webhooks are delivered **at least once** and **may arrive out of order** for
  the same resource (a retried delivery can land after a later event). Key your
  handler off `X-MF-Delivery` for de-duplication and off the resource's own
  `status` field for ordering — never assume the Nth webhook you receive is the
  Nth thing that happened.
</Note>

## Subscribe

```http theme={null}
POST /api/webhooks/v1/subscriptions
Authorization: Bearer <token>
Content-Type: application/json
```

```json theme={null}
{
  "url": "https://yourapp.example.com/hooks/mf-atlas",
  "events": ["investor.*", "order.*", "payment.*", "mandate.*"]
}
```

Response `201`:

```json theme={null}
{
  "success": true,
  "data": {
    "id": "whs_3f9a2b1c",
    "url": "https://yourapp.example.com/hooks/mf-atlas",
    "events": ["investor.*", "order.*", "payment.*", "mandate.*"],
    "secret": "whsec_9f2c1a...e7b4"
  }
}
```

| Field    | Required | Notes                                                                                                                                                                                   |
| -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`    | yes      | Must be `https://` and resolve to a public address — not `localhost`, and not a private/loopback/link-local range. Checked at subscribe time and re-checked just before every delivery. |
| `events` | yes      | One or more event names or `<resource>.*` wildcards (see the catalog below).                                                                                                            |

<Warning>
  `secret` is returned **once**, at creation. Store it — it's what you use to
  verify `X-MF-Signature` on every delivery, and it cannot be retrieved later.
  If you lose it, delete the subscription and create a new one.
</Warning>

## Event catalog

| Event                 | Fires when                                                                                                   | `data` shape                               |
| --------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
| `investor.registered` | UCC registration succeeds                                                                                    | `{ investor_id, status, provider_remark }` |
| `investor.rejected`   | UCC registration (or a step in it) is rejected                                                               | `{ investor_id, status, provider_remark }` |
| `mandate.registered`  | A mandate (eNACH bank approval, or physical scan acceptance) becomes active                                  | `{ mandate_id, status }`                   |
| `mandate.rejected`    | A mandate is declined                                                                                        | `{ mandate_id, status }`                   |
| `mandate.cancelled`   | A mandate is cancelled                                                                                       | `{ mandate_id, status }`                   |
| `order.accepted`      | The exchange accepts a dispatched order                                                                      | `{ order_id, status, provider_remark }`    |
| `order.rejected`      | The exchange rejects an order, at dispatch or later via status-sync                                          | `{ order_id, status, provider_remark }`    |
| `order.failed`        | Transport failure reaching the exchange (safe to investigate/retry)                                          | `{ order_id, status, provider_remark }`    |
| `order.allotted`      | Units are allotted (`nav`, `units`, `allotment_date` are on the order itself — `GET` it for the full record) | `{ order_id, status }`                     |
| `payment.captured`    | Funds settle and NAV is applied                                                                              | `{ payment_id, status }`                   |
| `payment.failed`      | Payment fails                                                                                                | `{ payment_id, status }`                   |
| `payment.refunded`    | Payment is refunded                                                                                          | `{ payment_id, status }`                   |

Every payload is wrapped in the same envelope as any other response:

```json theme={null}
{ "success": true, "data": { "order_id": "ord_1a2b3c4d", "status": "ALLOTTED", "provider_remark": "" } }
```

<Note>
  The payload is intentionally thin — it tells you *what changed*, not the full
  resource. Treat it as a trigger to `GET /api/.../:id` for the authoritative,
  PII-masked record, not as the source of truth to persist directly.
</Note>

## Delivery

Each delivery is an HTTP `POST` to your `url` with:

| Header           | Meaning                                                                            |
| ---------------- | ---------------------------------------------------------------------------------- |
| `X-MF-Event`     | The event name, e.g. `order.allotted`.                                             |
| `X-MF-Delivery`  | A unique ID for this delivery attempt — use it to de-duplicate retried deliveries. |
| `X-MF-Signature` | `t=<unix timestamp>,v1=<hex HMAC-SHA256>` — see verification below.                |

Your endpoint must respond within **10 seconds**. Any `2xx` status marks the
delivery successful; anything else (including a timeout) is treated as a
failure and scheduled for retry.

### Verifying the signature

Compute `HMAC_SHA256("<timestamp>.<raw request body>", secret)` and compare
it, constant-time, against the `v1` value. Always use the **raw** request
body — not a re-serialized/parsed-and-re-stringified version, which can
differ byte-for-byte.

```js theme={null}
// Node.js
const crypto = require("crypto");

function verify(secret, header, rawBody) {
  const [tPart, vPart] = header.split(",");
  const timestamp = tPart.split("=")[1];
  const signature = vPart.split("=")[1];
  const signed = `${timestamp}.${rawBody}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

```python theme={null}
# Python
import hashlib, hmac

def verify(secret, header, raw_body):
    t_part, v_part = header.split(",")
    timestamp = t_part.split("=")[1]
    signature = v_part.split("=")[1]
    signed = f"{timestamp}.{raw_body}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

<Warning>
  Reject any delivery whose `t` timestamp is too far in the past (e.g. more
  than a few minutes) even if the signature is valid — this protects against a
  captured request being replayed later.
</Warning>

## Retries and parking

A failed delivery (non-`2xx`, timeout, or connection error) is retried with
backoff: **1 minute, 5 minutes, 25 minutes, 2 hours, 12 hours**. After 5 failed
attempts the delivery is marked `PARKED` — it will not be retried
automatically.

```mermaid theme={null}
stateDiagram-v2
    [*] --> PENDING
    PENDING --> DELIVERED: 2xx within 10s
    PENDING --> PENDING: non-2xx / timeout, backoff and retry
    PENDING --> PARKED: 5 failed attempts
    PARKED --> PENDING: POST /deliveries/:id/retry
```

If a `PARKED` delivery matters (e.g. you fixed an outage on your endpoint),
replay it manually — see below. There's no automatic un-parking.

## Manage subscriptions and deliveries

```http theme={null}
GET    /api/webhooks/v1/subscriptions
DELETE /api/webhooks/v1/subscriptions/:id

GET    /api/webhooks/v1/deliveries?status=&event=
POST   /api/webhooks/v1/deliveries/:id/retry
```

`GET /deliveries` returns each attempt's `status`
(`PENDING` | `DELIVERED` | `FAILED` | `PARKED`), `attempts`, and
`last_status_code` — use it to build a delivery-health view, or to find and
replay anything stuck `PARKED`.

```json theme={null}
{
  "success": true,
  "data": [
    {
      "id": "dlv_7e2a1f",
      "event": "order.allotted",
      "status": "PARKED",
      "attempts": 5,
      "last_status_code": 503,
      "created_at": "2026-09-10T04:12:00Z"
    }
  ]
}
```

`POST /deliveries/:id/retry` re-queues one delivery immediately, outside the
normal backoff schedule.

## If you'd rather poll

Every event above has a corresponding field you can poll instead:
`GET /api/investors/v1/:id` (`status`), `GET /api/mandates/v1/:id`
(`status`), `GET /api/orders/v1/:id` (`status`, `payment_status`). Webhooks
and polling are not mutually exclusive — most integrations use webhooks as
the primary signal and a low-frequency poll (or
[`POST /api/investors/v1/sync`](/api-documentation/create-investor#syncing-status-from-nse))
as a reconciliation fallback for missed deliveries.

## Next step

See [Process flows](/api-documentation/process-flows) for how each event maps
onto the underlying exchange lifecycle.
