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

# Webhooks

> Get notified when a transaction changes status, and verify the signature.

When you attach a webhook to a transaction, chmod calls your endpoint as the transaction changes status — most importantly when the decision is ready.

```json lines theme={null}
{
  "config": {
    "webhook": {
      "url": "https://api.example.com/hooks/chmod"
    }
  }
}
```

The URL is set per transaction and must be HTTPS. Omit `webhook` and no notification is sent — you would then have to [poll](/api-reference/transactions/get-transaction).

## The request

chmod sends a `POST` with two headers that let you prove the call came from us:

| Header | Contents |
| - | - |
| `X-Timestamp` | When the request was signed |
| `X-Signature` | Signature over `{timestamp}:{transactionId}`, using chmod's private key |

The body identifies the transaction and its new status.

<Warning>
  Treat the webhook as a **notification, not as data**. It tells you a transaction has moved; it is not the source of truth for the decision. Fetch the transaction with [`GET /kyc/transaction/{id}`](/api-reference/transactions/get-transaction) to read the actual result, and act on that.

  This keeps you safe even if someone replays or forges a call: the verdict you act on always comes from an authenticated request you made yourself.
</Warning>

## Verifying the signature

chmod signs with a private key and publishes the matching public key as a JWKS document:

```text theme={null}
GET https://{your-api-host}/.well-known/jwks.json
```

To verify a call:

<Steps>
  <Step title="Read both headers">
    Reject the request outright if `X-Timestamp` or `X-Signature` is missing.
  </Step>

  <Step title="Rebuild the signed string">
    Concatenate the timestamp, a colon, and the transaction id from the body: `{timestamp}:{transactionId}`. No spaces, no trailing newline.
  </Step>

  <Step title="Verify against the public key">
    Fetch the JWKS (cache it — do not fetch per request) and verify `X-Signature` over that string. The `alg` in the JWKS entry tells you which algorithm to use.
  </Step>

  <Step title="Check the timestamp is recent">
    Reject anything older than a few minutes. A valid signature stays valid forever, so without this check a captured request can be replayed indefinitely.
  </Step>
</Steps>

```typescript lines theme={null}
import crypto from "node:crypto";
import { createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL(`${CHMOD_API_URL}/.well-known/jwks.json`));
const MAX_AGE_MS = 5 * 60 * 1000;

app.post("/hooks/chmod", express.json(), async (req, res) => {
  const timestamp = req.header("X-Timestamp");
  const signature = req.header("X-Signature");

  if (!timestamp || !signature) return res.sendStatus(401);

  // Reject stale calls: a valid signature never expires on its own.
  if (Math.abs(Date.now() - Date.parse(timestamp)) > MAX_AGE_MS) {
    return res.sendStatus(401);
  }

  const transactionId = req.body.transaction_id;
  const signedString  = `${timestamp}:${transactionId}`;

  const key = await JWKS({ alg: "RS256" }, null);
  const ok  = crypto.verify(
    "sha256",
    Buffer.from(signedString),
    key,
    Buffer.from(signature, "base64"),
  );

  if (!ok) return res.sendStatus(401);

  // Acknowledge immediately, then do the work out of band.
  res.sendStatus(200);
  await queue.enqueue({ transactionId });
});
```

<Warning>
  A webhook endpoint that does not verify the signature is an open endpoint: anyone who learns the URL can tell you a transaction was approved. Verify before you act.
</Warning>

## Responding

Return `2xx` as soon as you have accepted the call. Do the real work — fetching the transaction, updating your records, notifying the user — out of band.

A slow handler is a fragile handler: if your endpoint blocks on a database write and times out, the delivery counts as failed even though you received it.

## Delivery and idempotency

Assume **at-least-once** delivery. A retry after a network hiccup means the same status can reach you twice, so make your handler idempotent — key on `transaction_id` and the status, and make a repeat a no-op

## Not receiving calls?

| Symptom | Cause |
| - | - |
| Nothing ever arrives | `webhook` was omitted from the transaction config |
| Nothing arrives, config looks right | The URL is not publicly reachable, or not HTTPS |
| Calls arrive but fail verification | The signed string is not exactly `{timestamp}:{transactionId}` |
| Calls arrive twice | Expected — delivery is at-least-once. Make the handler idempotent |
| Calls stop after a while | Your endpoint returned non-`2xx` or timed out repeatedly |

When a webhook is genuinely lost, [polling](/api-reference/transactions/get-transaction) is the fallback — which is why a reconciliation job over transactions that never reached a terminal status is worth having in production.


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