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

# Receive webhooks

> Register a webhook endpoint and verify signed deliveries

This walkthrough registers a webhook endpoint for your organization, lists and deletes endpoints, and shows how to verify a signed delivery on your server. Every call here runs from your server with your `org_` API key. A partner admin session token also works (see [Admin session tokens](https://docs.fourdos.dev/authentication.md#admin-session-tokens)).

> [!WARNING]
>
> 4D does not send webhook deliveries yet. You can register, list and delete endpoints today, and they are kept, but no event is posted to them. Until delivery is switched on, use [Read recent events](https://docs.fourdos.dev/walkthroughs/read-recent-events.md) to pull events. The delivery format below is the one 4D signs with, so you can build and test your receiver against it now.

```bash theme={null}
export API_BASE_URL="https://api.develop.fourdos.dev"
export API_KEY="org_…"
```

## 1. Register an endpoint

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/4d",
    "events": ["identity.user.created", "xp.redeemed"]
  }'
```

| Field | Status | Notes |
| - | - | - |
| `url` | Required | Where deliveries go. Must be `https`. |
| `events` | Required | 1 to 50 event type names, such as `identity.user.created` or `xp.redeemed` |

The event names are stored as you send them. They are not checked against a catalog, so a misspelled name is accepted and never matches anything. To see the event types your organization produces, and their exact spelling, call [the refetch call](#4-refetch-recent-events) without a filter.

`201 Created`:

```json theme={null}
{
  "id": "…",
  "url": "https://hooks.example.com/4d",
  "events": ["identity.user.created", "xp.redeemed"],
  "status": "active",
  "created_at": "2026-11-11T09:00:00.000Z",
  "secret": "whs_…"
}
```

The `secret` is in this response only. Store it in your secret manager now: no later call returns it, and 4D keeps only an encrypted copy that it uses to sign deliveries.

A body that fails validation, including a `url` that is not `https`, answers `422` with code `VALIDATION_ERROR` and the field problems under `details`.

## 2. List and delete endpoints

`GET /v1/webhooks` lists your organization's endpoints, oldest first. The list never includes the secret.

```bash theme={null}
curl "$API_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
[
  {
    "id": "…",
    "url": "https://hooks.example.com/4d",
    "events": ["identity.user.created", "xp.redeemed"],
    "status": "active",
    "created_at": "2026-11-11T09:00:00.000Z"
  }
]
```

`DELETE /v1/webhooks/{id}` removes one:

```bash theme={null}
curl -X DELETE "$API_BASE_URL/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
{ "deleted": true }
```

An id that is not one of your organization's endpoints answers `404` with code `NOT_FOUND`.

There is no call to change an endpoint, read its secret again or rotate the secret. To change the URL, the events or the secret, register a new endpoint, move your receiver to the new secret, then delete the old endpoint.

## 3. Verify a delivery

A delivery is a `POST` to your `url` with a JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-4D-Signature` | `t=<unix seconds>,v1=<hex signature>` |
| `X-4D-Event-Id` | The event's id. The same event keeps the same id on every attempt. |
| `X-4D-Event-Type` | The event type, for example `xp.redeemed` |
| `X-4D-Delivery-Id` | A new id for each attempt |
| `X-4D-Webhook-Id` | The id of your endpoint |

The signature is HMAC-SHA256, keyed with your endpoint's `secret`, over the timestamp, a period and the exact raw request body: `` `${t}.${rawBody}` ``, hex-encoded. Each attempt is signed at the moment it is sent, so `t` is always recent.

To verify, on every request:

1. Read the raw body as bytes, before any JSON parsing. A re-serialised body does not match.
2. Parse `t` and every `v1` (and `v0`, if present) out of `X-4D-Signature`. A `v0` entry carries a signature made with a previous secret; accept the request if either matches.
3. Reject a `t` more than 300 seconds away from your clock.
4. Compute the HMAC and compare it in constant time.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const TOLERANCE_SECONDS = 300;

function verify4dSignature(rawBody, header, secret) {
  const parts = String(header).split(",").map((part) => part.split("=", 2));
  const t = Number(parts.find(([name]) => name === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return parts
    .filter(([name]) => name === "v1" || name === "v0")
    .some(([, hex]) => {
      const given = Buffer.from(hex ?? "", "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks).toString("utf8");
    if (!verify4dSignature(rawBody, req.headers["x-4d-signature"], process.env.WEBHOOK_SECRET)) {
      res.writeHead(401).end();
      return;
    }
    // Skip an X-4D-Event-Id you have already handled, queue the work, answer fast.
    res.writeHead(204).end();
  });
}).listen(3000);
```

Deduplicate on `X-4D-Event-Id`, not on `X-4D-Delivery-Id`. A retried delivery carries the same event id and a new delivery id, so an event can reach you more than once.

## Retries and what counts as success

Any `2xx` response is a success. Anything else, including a network error, is a failure, and 4D tries again. An event gets seven attempts in all: the first, then retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours, about 40 hours end to end. After the seventh failure the event is not sent to that endpoint again.

There is no call to redeliver a failed event. Use the refetch call below to recover anything your endpoint missed.

Answer quickly and do the work afterwards: return `2xx` once the signature checks out and the event is safely queued on your side.

## 4. Refetch recent events

To pull events instead of waiting for them, or to catch up after downtime, use [Read recent events](https://docs.fourdos.dev/walkthroughs/read-recent-events.md). It returns your organization's events from the last 30 days.


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