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

# Webhooks overview

> Receive signed HTTP callbacks for every operation create, update, and error so your systems stay in sync with the request-money API in near real time.

You can register a partner webhook URL so the request-money API delivers a signed HTTP callback every time one of your operations is **created**, **updated**, or **fails**. Webhooks are persisted, retried on failure, and signed with HMAC-SHA256 so you can verify authenticity before reacting.

## When events fire

| Event               | Trigger                                                                                                                                                                                                                    |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `operation_created` | A successful **`POST /v1/request-money`** that persists a remittance for your partner.                                                                                                                                     |
| `operation_updated` | A successful internal status update (**`PATCH /v1/internal/remittances/status`**) for one of your remittances.                                                                                                             |
| `operation_error`   | Any error raised while handling **`POST /v1/request-money`** or the internal status update for your partner — validation failures, OFAC screening blocks, downstream identity-validator outages, missing remittances, etc. |

`operation_error` events are emitted in addition to whatever HTTP error your client receives synchronously, so background systems that only consume webhooks can still react to failed flows.

## Configuring the endpoint

<Steps>
  <Step title="Pick an HTTPS URL">
    The endpoint must speak **HTTPS** and accept **`POST`** with a JSON body. Plain **`http://`** values are rejected by the partner portal.
  </Step>

  <Step title="Open the partner portal">
    Sign in and go to **Settings → Webhooks**.
  </Step>

  <Step title="Save the URL and a signing secret">
    Paste your endpoint into **Webhook URL**. Generate a high-entropy secret (at least 8 characters) and paste it into **Webhook secret**. The portal stores the secret encrypted; rotating it later is a matter of overwriting the value or clearing it.
  </Step>

  <Step title="Confirm by triggering an event">
    Run a **Create request-money** call from the playground or your own backend. You should see an **`operation_created`** delivery hit your endpoint within seconds.
  </Step>
</Steps>

<Warning>
  If you leave **Webhook URL** empty, no events are queued for your partner. Existing pending events still attempt delivery against the URL that was active when they were enqueued.
</Warning>

## Payload envelope

Every event uses the same envelope. The `data` object's shape depends on `event` — see [Events catalog](/request-api/webhooks/events).

```json theme={null}
{
  "event": "operation_created",
  "data": {
    "partnerId": "65f8...",
    "referenceId": "Ab3xY9mK",
    "id": "Ab3xY9mK",
    "userReferenceId": "user-8842",
    "amount": 150.5,
    "currency": "GTQ",
    "status": "pending",
    "statusDetails": "The operation is pending because the sender has not yet started the process.",
    "waLink": "https://wa.me/...",
    "landingLink": "https://landing.example.com/...",
    "requestData": { "...": "..." },
    "createdAt": "2026-05-06T10:00:00.000Z",
    "updatedAt": "2026-05-06T10:00:00.000Z"
  },
  "timestamp": "2026-05-06T10:00:00.000Z"
}
```

| Field       | Type   | Description                                                                                  |
| ----------- | ------ | -------------------------------------------------------------------------------------------- |
| `event`     | string | One of `operation_created`, `operation_updated`, `operation_error`.                          |
| `data`      | object | Event-specific payload. See [Events catalog](/request-api/webhooks/events).                  |
| `timestamp` | string | ISO 8601 UTC instant the event was enqueued. Frozen at enqueue time, not updated on retries. |

## Headers

Every delivery sets these headers:

| Header                  | Purpose                                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `Content-Type`          | Always `application/json`.                                                                              |
| `X-Webhook-Event`       | Mirror of `payload.event` so you can route on a header before parsing JSON.                             |
| `X-Webhook-Delivery-Id` | Stable UUID per event. Identical across retries — use it for idempotency.                               |
| `X-Webhook-Signature`   | HMAC-SHA256 signature in the form `t=<unix>,v1=<hex>`. Present whenever a webhook secret is configured. |

## Verifying the signature

The `X-Webhook-Signature` header is computed as:

```
v1 = hex(HMAC_SHA256(secret, "{t}.{rawBody}"))
```

Where `t` is the Unix timestamp (seconds) the dispatcher signed the request, `secret` is the value you configured in **Settings → Webhooks**, and `rawBody` is the **exact** bytes of the JSON payload as received (do not pretty-print or re-serialize before hashing).

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  function verifyWebhook(rawBody, headerValue, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
      headerValue.split(",").map((kv) => kv.split("=", 2)),
    );
    const t = Number(parts.t);
    const v1 = parts.v1;
    if (!Number.isFinite(t) || !v1) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`)
      .digest("hex");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_webhook(raw_body: bytes, header_value: str, secret: str, tolerance_seconds: int = 300) -> bool:
      parts = dict(kv.split("=", 1) for kv in header_value.split(","))
      try:
          t = int(parts["t"])
      except (KeyError, ValueError):
          return False
      if abs(int(time.time()) - t) > tolerance_seconds:
          return False
      signed = f"{t}.{raw_body.decode('utf-8')}".encode("utf-8")
      expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```

  ```bash cURL (verification snippet) theme={null}
  # t and v1 from X-Webhook-Signature: t=<unix>,v1=<hex>
  expected=$(printf '%s' "${t}.${raw_body}" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
  [ "$expected" = "$v1" ] && echo ok || echo mismatch
  ```
</CodeGroup>

<Tip>
  Tolerate a clock skew of about five minutes when comparing `t` with your local time. Outside that window, treat the request as a replay.
</Tip>

## Delivery, retries, and dead-lettering

* The API persists every event in an outbox **before** the source request returns to the client, so you do not miss callbacks if your endpoint is briefly unavailable.
* A worker drains the outbox every **30 seconds** and POSTs each due event with a **10-second** timeout.
* Any non-2xx response, network failure, or timeout schedules a retry with exponential backoff: **1 minute**, **5 minutes**, **30 minutes**, **2 hours**, **12 hours**.
* After **5 failed attempts** the event moves to a **dead-letter** state and is no longer retried automatically. Operators can replay it from the database; partners receive no further deliveries for that event.
* Successful deliveries (any 2xx) finalize the event and remove it from the active queue.

## Idempotency

Use `X-Webhook-Delivery-Id` to deduplicate. The same `deliveryId` can be retried up to five times — your handler must be safe to invoke more than once for the same id (for example, by storing processed ids and short-circuiting).

## Configuration freezing

When an event is enqueued, the dispatcher captures the **partner's `webhookUrl` and `webhookSecret` at that exact moment**. Changing the configuration afterward only affects events enqueued **after** the change; in-flight retries continue to target the URL and signature key that were active when the source operation occurred. This keeps replays consistent and avoids signing old payloads with new secrets.

## Troubleshooting

| Symptom                                                                       | Likely cause                                                                             |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| No events arrive                                                              | `webhookUrl` is empty in **Settings → Webhooks**, or the URL is **not** HTTPS.           |
| Receive `401`/`403` from your endpoint then no more retries after a few hours | The dispatcher exhausted the 5 retries; the event is in `dead_letter`.                   |
| Signature does not match                                                      | Body was re-serialized before hashing. Always verify against the **raw** bytes received. |
| Duplicate processing on your side                                             | `X-Webhook-Delivery-Id` was not used for deduplication; retries reuse the same id.       |
