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

# Webhooks

> The delivery contract — headers, envelope, signature verification, replay protection, and the account events

Endl pushes signed events to your endpoint as things happen. This guide covers the
delivery contract that applies to every event — headers, envelope, signature
verification and replay protection — then the account events specifically.

## How delivery works

You register an endpoint and receive a signing secret. Endl then `POST`s each event
to that endpoint as a compact JSON body, signed so you can prove it came from us.

<Columns cols={3}>
  <Card title="Signed" icon="signature">
    Every PUSH delivery carries an HMAC-SHA512 signature over the raw body. Verify
    it before you act on anything.
  </Card>

  <Card title="At least once" icon="repeat">
    Retries and manual re-sends can deliver the same event more than once.
    Deduplicate on the event id.
  </Card>

  <Card title="After the fact" icon="clock">
    Events are emitted once the underlying operation has committed, so an event
    never describes something that did not happen.
  </Card>
</Columns>

<Warning>
  **Always verify before acting.** An unverified request is just a request from the
  internet. Verify the signature first, then dedupe on the event id, then act.
</Warning>

## Setting up a webhook

Register a subscription for the events you care about. Endl then either pushes each
matching event to your endpoint, or holds it for you to poll.

### Choose a delivery mode

| Mode | How it works | You host |
| - | - | - |
| `PUSH` | Endl sends each event to your HTTPS endpoint as a signed `POST`, with retries. **The default.** | A receiver |
| `PULL` | Endl delivers nothing. You poll [the event feed](/webhooks/pull-events) and checkpoint a cursor. | Nothing |

Those are the only two. `SQS` and `SSE` are reserved but not available — asking for
either returns `422`.

<Steps>
  <Step title="Create the subscription">
    Register your receiver URL and the event types you want.

    ```bash POST /api/v0/webhooks/subscriptions theme={null}
    curl -X POST https://api-sandbox.endl.io/api/v0/webhooks/subscriptions \
      -H 'Api-Key: YOUR_API_KEY' \
      -H 'Api-Version: 2026-09.1' \
      -H 'Content-Type: application/json' \
      -d '{
        "target": "https://api.acme.com/webhooks/endl",
        "eventFilter": ["account.opened", "account.updated"]
      }'
    ```
  </Step>

  <Step title="Store the signing secret">
    The response carries `webhook_secret` **once**. Store it before you do anything
    else — it cannot be read back, and there is no rotate endpoint. If you lose it,
    delete the subscription and create a new one.

    ```json 201 Created theme={null}
    {
      "code": 201,
      "message": "Subscription created",
      "status": "SUCCESS",
      "data": {
        "id": "2b1e8f40-3c9a-4d21-9f7e-6a1c0b5d2e34",
        "deliveryMode": "PUSH",
        "target": "https://api.acme.com/webhooks/endl",
        "eventFilter": ["account.opened", "account.updated"],
        "schemaVersion": "2026-09.1",
        "status": "ACTIVE",
        "secretHint": "whsec_…5RtBw",
        "webhook_secret": "whsec_k2p9Fa7QxVn3Jh05RtBw",
        "createdOn": "2026-09-02T12:34:56.789Z"
      },
      "errors": []
    }
    ```
  </Step>

  <Step title="Receive and verify">
    Endl `POST`s each matching event with an `X-WEBHOOK-SIGNATURE` header. Verify
    it against the **raw** request body before trusting anything in the payload.
  </Step>

  <Step title="Inspect deliveries">
    Every attempt is recorded, with the response your endpoint returned.
    [List them](/webhooks/list-deliveries), drill into one, or
    [retry](/webhooks/retry-delivery) the ones that failed.
  </Step>
</Steps>

### Request fields

<ResponseField name="deliveryMode" type="enum" default="PUSH">
  `PUSH` or `PULL`.
</ResponseField>

<ResponseField name="target" type="string" required>
  Required for PUSH. The HTTPS endpoint Endl signs and POSTs every matching event
  to. Must be absent for PULL. Maximum 512 characters. **Stored in canonical
  form** — read `data.target` back rather than assuming your spelling was kept.
</ResponseField>

<ResponseField name="eventFilter" type="string[]" default="[&#x22;*&#x22;]">
  Which event types to receive. At most 50 entries, each non-blank and at most 128
  characters. An entry is `*`, an exact type, or one trailing wildcard such as
  `account.*`. Patterns are trimmed, lower-cased, and underscores folded to dots —
  so `ACCOUNT_OPENED` and `account.opened` mean the same thing.
</ResponseField>

<ResponseField name="schemaVersion" type="string" default="2026-09.1">
  The payload shape every delivery is rendered in. Fixed at creation and cannot be
  changed afterwards.
</ResponseField>

### Response fields

| Field | Description |
| - | - |
| `id` | The subscription id. |
| `deliveryMode` | `PUSH` or `PULL`. |
| `target` | Your receiver, in canonical form. **Read this rather than your own copy.** |
| `eventFilter` | The normalised patterns actually stored. |
| `schemaVersion` | The payload version every delivery is rendered in. |
| `status` | Subscription state, e.g. `ACTIVE`. |
| `secretHint` | Masked form of the secret — safe to quote in a support ticket. |
| `webhook_secret` | **Returned once, here only. Store it now.** |
| `createdOn` | ISO-8601 UTC with three fraction digits. |

<Note>
  **PULL subscriptions.** Omit `target` and set `deliveryMode: "PULL"`. A PULL
  subscription never delivers anything — it authorises and configures the event
  feed. You may have **one live PULL subscription**, and it needs the `events`
  permission rather than `webhooks`. A PULL create returns neither
  `webhook_secret` nor `secretHint`, because a PULL feed is never signed.
</Note>

### Validation order

Validation runs in a fixed order, which is worth building your error handling
against. **The first failing check is the one you get back**, so fixing errors in
this order converges fastest.

1. Delivery-mode availability
2. `target` presence
3. The active-subscription cap
4. Egress and target policy — **this performs a DNS lookup**
5. Duplicate-target and one-live-PULL conflicts
6. `eventFilter` bounds
7. `schemaVersion`

### Conventions on webhook endpoints

| Convention | Detail |
| - | - |
| Identity | Comes from your API key. You only ever see and manage your own subscriptions and deliveries — there is no partner field in any request body. |
| Permissions | Subscription and delivery endpoints need `webhooks`; the PULL feed needs `events`. A key without it gets `403`. |
| Versioning | Every request sends `Api-Version: 2026-09.1`, echoed on the response. |
| Timestamps | ISO-8601 UTC, three fraction digits — `2026-09-02T12:34:56.789Z`. |
| Rate limit | Per API key. A `429` carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. |
| Correlation | Every response carries `X-Request-ID`. Quote it to support and it joins straight to our logs. |

<Warning>
  **Webhook endpoints use the response envelope — accounts do not.** Subscription
  and delivery endpoints answer with `{ code, message, status, data, errors }`, so
  the subscription is under `data`. This is **different** from the
  [Accounts](/guides/accounts) endpoints, where a success is the bare object. If
  you share response-handling code across both, branch on the path.
</Warning>

## Delivery headers

| Header | Description |
| - | - |
| `X-WEBHOOK-SIGNATURE` | 128 lowercase hex characters — HMAC-SHA512 over `timestamp + "." + body`. |
| `X-WEBHOOK-TIMESTAMP` | ISO-8601 UTC with three fraction digits. Also covered by the signature. |
| `X-WEBHOOK-EVENT-ID` | The event id. Use it as your idempotency key. |

<Warning>
  **Three things to get right.** It is **SHA-512**, not SHA-256. The hex is
  **lowercase**. And you must HMAC the **raw received body bytes** — parsing the
  JSON and re-serialising it changes those bytes and breaks the signature.
</Warning>

## The envelope

The top level is always the same six keys, frozen across schema versions. Only
`data` is version-specific.

| Field | Description |
| - | - |
| `eventId` | Unique id for this event. Matches `X-WEBHOOK-EVENT-ID`. |
| `eventType` | The event, e.g. `account.opened`. |
| `eventCategory` | The family the event belongs to, e.g. `account`. |
| `eventCreatedAt` | ISO-8601 UTC with three fraction digits. |
| `version` | Schema version of `data`, e.g. `2026-09.1`. |
| `data` | The payload. Two metadata keys, then the producer's own fields in sorted key order. |

### How `data` is rendered

`data.id` is prefixed — `txn_`, `acct_`, `cus_` — and **that prefix is the only
thing that says what the event is about**. `data.customerId` is omitted entirely
when the event carries no customer. No subscription id is sent.

| Rule | Consequence |
| - | - |
| Every leaf value is a string | Amounts included. Parse numbers yourself, as decimals. |
| Null-valued fields are omitted | A field you expect can simply be absent. **Never assume `null`.** |
| Payload keys are sorted | Field order is stable across deliveries of the same type. |
| A payload field colliding with `id` or `customerId` is dropped | The metadata value wins. A producer cannot shadow either key. |
| Nested objects and arrays are supported, recursively | The same rules apply at every depth. |
| There is no gateway allowlist | Every key the producing service wrote is published. |

<Warning>
  **Treat the catalogue as the current shape, not a closed schema.** Because there
  is no allowlist, new keys can appear inside `data` without a version change.
  Handle an unrecognised `eventType` — or an extra field inside `data` —
  gracefully rather than rejecting the delivery.
</Warning>

## Example delivery

The body is a single compact line, and the signature covers exactly those bytes.

```http POST /webhooks/endl theme={null}
POST /webhooks/endl HTTP/1.1
Host: api.acme.com
Content-Type: application/json
User-Agent: Endl-Webhooks/1.0
X-WEBHOOK-EVENT-ID: 72949438-265e-5aa7-8e6c-56af893a58eb
X-WEBHOOK-TIMESTAMP: 2026-05-22T15:09:58.500Z
X-WEBHOOK-SIGNATURE: d1d6553c34daa198aa8f259715efbd396f0c8a0658a4484f281d15536f889658e79f2e4538258899a4fea5500b0b7e196d2bf1a147e84a09f796291b76512dc5

{"eventId":"72949438-265e-5aa7-8e6c-56af893a58eb","eventType":"payout.completed","eventCategory":"payout","eventCreatedAt":"2026-05-22T15:09:58.500Z","version":"2026-09.1","data":{...}}
```

## Verifying signatures

Compute `HMAC-SHA512(secret, timestamp + "." + rawBody)`, hex-encode it lowercase,
and compare it to `X-WEBHOOK-SIGNATURE` in constant time. **Accept a list of
secrets from the start** — that is what makes a secret cutover safe.

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib

  def verify_webhook(raw_body: bytes, headers: dict, secrets: list[str]) -> bool:
      ts  = headers["X-WEBHOOK-TIMESTAMP"]   # "2026-09-02T12:34:56.789Z"
      sig = headers["X-WEBHOOK-SIGNATURE"]   # 128 lowercase hex chars
      signed = ts.encode("utf-8") + b"." + raw_body
      for secret in secrets:                 # current, plus previous during rotation
          expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha512).hexdigest()
          if hmac.compare_digest(expected, sig):
              return True
      return False
  ```

  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  // rawBody MUST be the raw Buffer, not a parsed-and-restringified object.
  function verifyWebhook(rawBody, headers, secrets) {
    const ts  = headers['x-webhook-timestamp'];
    const sig = headers['x-webhook-signature'];
    if (!ts || !sig) return false;

    const signed = Buffer.concat([Buffer.from(ts, 'utf8'), Buffer.from('.'), rawBody]);
    const given  = Buffer.from(sig, 'utf8');

    return secrets.some((secret) => {
      const expected = Buffer.from(
        crypto.createHmac('sha512', secret).update(signed).digest('hex'), 'utf8');
      return expected.length === given.length &&
             crypto.timingSafeEqual(expected, given);
    });
  }

  // Express: capture the raw body before any JSON parsing.
  // app.use('/webhooks/endl', express.raw({ type: 'application/json' }));
  ```

  ```go Go theme={null}
  package webhooks

  import (
  	"crypto/hmac"
  	"crypto/sha512"
  	"encoding/hex"
  	"net/http"
  )

  // rawBody must be the bytes exactly as received.
  func Verify(rawBody []byte, h http.Header, secrets []string) bool {
  	ts := h.Get("X-WEBHOOK-TIMESTAMP")
  	sig := h.Get("X-WEBHOOK-SIGNATURE")
  	if ts == "" || sig == "" {
  		return false
  	}

  	signed := append(append([]byte(ts), '.'), rawBody...)

  	for _, secret := range secrets { // current, plus previous during rotation
  		mac := hmac.New(sha512.New, []byte(secret))
  		mac.Write(signed)
  		expected := hex.EncodeToString(mac.Sum(nil))
  		if hmac.Equal([]byte(expected), []byte(sig)) {
  			return true
  		}
  	}
  	return false
  }
  ```
</CodeGroup>

## Replay protection

The timestamp is covered by the signature, so you can trust it. **Reject any
delivery whose `X-WEBHOOK-TIMESTAMP` is more than about five minutes old.** A
captured request cannot be replayed with a fresh timestamp, because rewriting it
invalidates the signature.

Then deduplicate on `X-WEBHOOK-EVENT-ID`. Retries and manual sends can deliver the
same event more than once, and a receiver that is idempotent on the event id
handles all of it for free.

## Secrets and cutovers

**There is no rotate endpoint.** The signing secret is returned once, when the
subscription is created, and cannot be read back afterwards. If it is lost, delete
the subscription and create a new one — that mints a fresh secret bound to a new
subscription id, and leaves the old one's delivery history intact.

Each subscription has its own secret, so moving traffic to a new receiver means
both secrets are briefly live while the old subscription drains. Write your
verifier against a **list** of secrets from the start, as the examples above do,
and drop the retired one once nothing signed under it is still in flight.

<Note>
  **`secretHint`** is a masked form of the current secret — `whsec_…` plus its last
  four characters. It exists so a support ticket can answer "which secret are you
  signing with?" without either side handling the secret itself. **Comparing hints
  is safe to paste into a ticket. Comparing secrets is not.**
</Note>

<Warning>
  **PULL feeds are never signed.** A PULL subscription has neither a webhook secret
  nor a `secretHint`. Signature verification applies to PUSH deliveries only.
</Warning>

## Event catalogue

**Account events.** These are the two the accounts surface produces — reading an
account never emits anything.

| Event | Category | When it fires |
| - | - | - |
| `account.opened` | `account` | An account was created **during** a call to Open Account. Fires only when the account actually exists by the time that call returns. |
| `account.updated` | `account` | You called Activate or Deactivate and the change was accepted. |

Other families exist and are documented with their own objects: `deposit.created`,
`deposit.received`, `deposit.failed` for money arriving; `transaction.initiated`,
`transaction.updated`, `transaction.completed`, `transaction.failed`,
`transaction.refunded` for movements; and `wallet.created`, `wallet.deleted` for
wallets.

<Note>
  **Money landing in an account is a `deposit` event, not an account event** —
  subscribe to that family if you are watching for funds. The full list is in the
  [event catalogue](/webhooks/reference).
</Note>

## Account event payloads

Both account events carry the same three producer fields: `category`, `currency`
and `status`, after the two metadata keys. Remember the envelope rules — every leaf
is a string, and a null field is omitted rather than sent.

<CodeGroup>
  ```json account.opened theme={null}
  {
    "eventId": "8f14e45f-ceea-3f2a-9a3f-1b2c0d4e5f60",
    "eventType": "account.opened",
    "eventCategory": "account",
    "eventCreatedAt": "2026-09-30T06:21:44.500Z",
    "version": "2026-09.1",
    "data": {
      "id": "acct_7Kq2XbN4mR8vLp1DcYtZ",
      "customerId": "cus_9FpQ2LmZ8sW4TxKn1BvD",
      "category": "ONRAMP",
      "currency": "gbp",
      "status": "activated"
    }
  }
  ```

  ```json account.updated theme={null}
  {
    "eventId": "3b91d7f0-a25c-38e1-b7d4-9c0e15a6f2bb",
    "eventType": "account.updated",
    "eventCategory": "account",
    "eventCreatedAt": "2026-09-30T07:02:11.184Z",
    "version": "2026-09.1",
    "data": {
      "id": "acct_7Kq2XbN4mR8vLp1DcYtZ",
      "customerId": "cus_9FpQ2LmZ8sW4TxKn1BvD",
      "category": "ONRAMP",
      "currency": "gbp",
      "status": "activated"
    }
  }
  ```
</CodeGroup>

### Payload fields

<ResponseField name="data.id" type="string">
  The account, as `acct_…`.
</ResponseField>

<ResponseField name="data.customerId" type="string">
  The customer that owns it, as `cus_…`.
</ResponseField>

<ResponseField name="data.category" type="string">
  `FIAT` or `ONRAMP`. **Often absent** — the event carries the stored value, and it
  is not always set. Get Account derives one even when the event omits it.
</ResponseField>

<ResponseField name="data.currency" type="string">
  The account currency, **lower-case** (`"gbp"`). The REST API upper-cases it; this
  does not. Compare case-insensitively.
</ResponseField>

<ResponseField name="data.status" type="string">
  Endl's internal account state. **Not the value the API returns** — see the
  warning below.
</ResponseField>

<Warning>
  **`data.status` is not the status the API returns, and can be stale.**

  *Different vocabulary.* You will see `activated` and `deactivated` here where Get
  Account returns `ACTIVE` and `DEACTIVATED`, and values with no API equivalent at
  all. Do not compare `data.status` against the statuses documented for the REST
  API.

  *It can be the pre-change value.* Activate and deactivate are queued, and
  `account.updated` is emitted when the request is **accepted**, not when it lands.
  Treat the event as "something changed, go look" and call
  [Get account](/api-reference/accounts/get-account) for the real state. **Never
  write `data.status` into your own records.**
</Warning>

## Coverage gaps

These matter more than the events themselves, because each one is a state change
you will never be told about.

* **A queued account open never fires `account.opened`.** If Open Account returned
  no `id`, the account did not exist yet, and nothing emits an event when it later
  appears. Poll `GET /api/v0/accounts/{userId}` for those.
* **Status changes Endl makes on its own emit nothing.** If an account is closed,
  frozen or activated by the banking partner or by compliance rather than by your
  API call, no `account.updated` is sent. Re-read accounts you care about on a
  schedule.
* **Funding instructions becoming available emits nothing.** An AED account whose
  IBAN is issued after opening produces no event. Poll Get Account.

<Warning>
  **Absence of an event is not proof that nothing happened.** A delivery can be
  skipped without failing the underlying operation. Anything you must be certain of
  should be confirmed by reading the resource, not inferred from silence.
</Warning>

## Common pitfalls

<AccordionGroup>
  <Accordion title="Every signature fails to verify">
    Almost always the raw body. If your framework parsed the JSON and you
    re-serialised it to verify, the bytes differ and the HMAC will never match.
    Capture the raw body on the webhook route. After that, check you are using
    SHA-**512**, lowercase hex, and signing `timestamp + "." + body` — not the body
    alone.
  </Accordion>

  <Accordion title="Signatures started failing after I moved receivers">
    Each subscription has its own secret, and both are live while the old one
    drains. Verify against a list of secrets and keep the retired one until nothing
    signed under it is in flight.
  </Accordion>

  <Accordion title="I am processing the same event twice">
    Delivery is at-least-once by design. Deduplicate on `X-WEBHOOK-EVENT-ID` and
    make your handler idempotent.
  </Accordion>

  <Accordion title="data.status does not match any status in the API docs">
    Expected. The event carries the internal state name, the API returns the
    partner-facing one. Call Get Account rather than mapping the event value.
  </Accordion>

  <Accordion title="A field I expected is missing from data">
    Null-valued fields are omitted rather than sent as `null`. Treat absence as "no
    value", and never index into `data` assuming a fixed shape.
  </Accordion>

  <Accordion title="I am waiting on an event that never arrives">
    Check the coverage gaps above — queued account opens, provider-driven status
    changes and late funding instructions all emit nothing. Poll for those.
  </Accordion>
</AccordionGroup>

<Note>
  Webhook delivery, signing and replay rules apply to every event family.
  Account-specific behaviour is covered in the
  [Accounts guide](/guides/accounts). The endpoints themselves are in the
  [Webhooks API reference](/webhooks/overview).
</Note>
