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

# Receive webhook events from Meow

> Subscribe to Meow webhook events, verify HMAC signatures, handle retries and idempotency, and choose between snapshot and thin payload delivery modes.

Meow POSTs an event to your URL when a transfer changes state or a deposit
clears. The wire format is
[Standard Webhooks](https://www.standardwebhooks.com/) — the
[Python](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/python),
[Node](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/javascript),
and [Go](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/go)
verifier libraries work as-is.

## You'll need

* An API key with `webhooks:write` and `webhooks:read`.
* A public HTTPS URL. Private, loopback, and metadata IPs are blocked at
  both create time and every delivery.

## 1. Create a subscription

Save `signing_secret` from the response — Meow returns it once.

```bash theme={null}
curl -X POST https://api.meow.com/v1/webhooks/subscriptions \
  -H "x-api-key: $MEOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Prod webhook receiver",
    "url": "https://example.com/webhooks/meow",
    "event_types": ["ach_transfer.updated", "wire_transfer.updated"],
    "payload_mode": "snapshot"
  }'
```

```json Response theme={null}
{
  "id": "7b9e8a5f-3c2d-4d11-9a8c-1e6c4f2a5b30",
  "name": "Prod webhook receiver",
  "url": "https://example.com/webhooks/meow",
  "event_types": ["ach_transfer.updated", "wire_transfer.updated"],
  "payload_mode": "snapshot",
  "is_enabled": true,
  "signing_secret": "whsec_..."
}
```

<Tip>
  `event_types: null` subscribes to everything. Pass an array to allowlist
  (empty array is rejected). You can only list events this subscription can
  receive — the rail events in the [catalog](/api-reference/webhooks/events).
  `webhook.test`, `message.attempt.exhausted`, and the partner `application.*`
  events are rejected with a 400 (the meta events are always-on or on-demand;
  `application.*` only delivers to partner subscriptions).
</Tip>

## 2. Verify the signature

Three headers come with every delivery:

| Header              | What it is                                                                                |
| ------------------- | ----------------------------------------------------------------------------------------- |
| `webhook-id`        | Delivery ID. Same value on every retry.                                                   |
| `webhook-timestamp` | Epoch seconds. New on each attempt.                                                       |
| `webhook-signature` | One or more `v1,<base64-hmac>` entries, space-separated. Multiple during secret rotation. |

Sign `f"{webhook-id}.{webhook-timestamp}.{body}"` with HMAC-SHA-256. The
HMAC key is the **base64-decoded** body of your `whsec_<base64>` secret —
not the raw string. Reject anything more than 5 minutes off — that's the
replay window.

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib, base64, time
  from fastapi import HTTPException

  SECRET = "whsec_..."  # rotate via PATCH /webhooks/subscriptions/{id}

  # Strip the `whsec_` prefix and base64-decode the rest to get the HMAC key.
  _HMAC_KEY = base64.b64decode(SECRET.removeprefix("whsec_"))


  def verify(request_body: bytes, msg_id: str, ts: str, sig_header: str) -> None:
      if abs(int(ts) - int(time.time())) > 300:
          raise HTTPException(400, "stale timestamp")

      signed = f"{msg_id}.{ts}.{request_body.decode()}".encode()
      expected = base64.b64encode(
          hmac.new(_HMAC_KEY, signed, hashlib.sha256).digest()
      ).decode()

      # `webhook-signature` may carry multiple `v1,<sig>` during rotation —
      # match any of them.
      for token in sig_header.split():
          version, _, candidate = token.partition(",")
          if version == "v1" and hmac.compare_digest(candidate, expected):
              return
      raise HTTPException(400, "bad signature")
  ```

  ```javascript Node theme={null}
  import crypto from "node:crypto";

  const SECRET = process.env.WEBHOOK_SECRET;
  // Strip `whsec_` and base64-decode to get the HMAC key.
  const HMAC_KEY = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");

  export function verify(body, msgId, ts, sigHeader) {
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      throw new Error("stale timestamp");
    }
    const expected = Buffer.from(
      crypto
        .createHmac("sha256", HMAC_KEY)
        .update(`${msgId}.${ts}.${body}`)
        .digest("base64"),
    );

    // `webhook-signature` may carry multiple `v1,<sig>` during rotation —
    // guard buffer length first because `timingSafeEqual` throws on mismatched
    // lengths, which would 500 on a malformed token before checking the next.
    const ok = sigHeader
      .split(/\s+/)
      .filter(Boolean)
      .some((token) => {
        const [version, sig] = token.split(",");
        if (version !== "v1" || !sig) return false;
        const candidate = Buffer.from(sig);
        return (
          candidate.length === expected.length &&
          crypto.timingSafeEqual(candidate, expected)
        );
      });

    if (!ok) throw new Error("bad signature");
  }
  ```

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

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/base64"
      "errors"
      "fmt"
      "strconv"
      "strings"
      "time"
  )

  // Strip the `whsec_` prefix and base64-decode the rest to get the HMAC key.
  func decodeSecret(secret string) ([]byte, error) {
      return base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  }

  func Verify(body []byte, msgID, ts, sigHeader, secret string) error {
      key, err := decodeSecret(secret)
      if err != nil {
          return fmt.Errorf("bad secret: %w", err)
      }
      epoch, err := strconv.ParseInt(ts, 10, 64)
      if err != nil {
          return fmt.Errorf("bad timestamp: %w", err)
      }
      if d := time.Now().Unix() - epoch; d > 300 || d < -300 {
          return errors.New("stale timestamp")
      }
      mac := hmac.New(sha256.New, key)
      fmt.Fprintf(mac, "%s.%s.%s", msgID, ts, body)
      expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

      for _, token := range strings.Fields(sigHeader) {
          if v, sig, ok := strings.Cut(token, ","); ok && v == "v1" {
              if hmac.Equal([]byte(sig), []byte(expected)) {
                  return nil
              }
          }
      }
      return errors.New("bad signature")
  }
  ```
</CodeGroup>

<Warning>
  Use constant-time compare (`hmac.compare_digest`, `crypto.timingSafeEqual`,
  `hmac.Equal`). `==` leaks the secret one byte at a time.
</Warning>

## 3. Dispatch on `payload.status`

Event names stay coarse — `{resource}.created` and `{resource}.updated`.
The lifecycle stage lives on the payload, so one handler covers the whole
flow:

```python theme={null}
def handle_ach(event: dict) -> None:
    transfer = event["data"]
    match transfer["status"]:
        case "pending" | "processing":
            mark_in_flight(transfer["id"])
        case "sent":
            mark_settled(transfer["id"])
        case "returned" | "error":
            alert_ops(transfer["id"], reason=transfer.get("error"))
        case "canceled":
            mark_canceled(transfer["id"])
```

Per-event payload shapes are in the [event catalog](/api-reference/webhooks/events).

## 4. Pick a payload mode

Set `payload_mode` per subscription. Change it any time with `PATCH`.

<Tabs>
  <Tab title="snapshot (default)">
    `data` carries the full resource. No follow-up GET needed.

    ```json theme={null}
    {
      "type": "ach_transfer.updated",
      "timestamp": "2026-04-28T08:00:00Z",
      "data": {
        "id": "withdrawal_txc_15wp3bd309xenf6p",
        "object": "ach_transfer",
        "status": "sent",
        "amount": "1500.00",
        "counterparty_name": "Acme Corp",
        "...": "..."
      },
      "sequence": 4
    }
    ```
  </Tab>

  <Tab title="thin">
    `data` carries only `{id, object}`. Your handler fetches the current
    state from the REST API, so out-of-order retries can't show you stale
    data.

    ```json theme={null}
    {
      "type": "ach_transfer.updated",
      "timestamp": "2026-04-28T08:00:00Z",
      "data": { "id": "withdrawal_txc_15wp3bd309xenf6p", "object": "ach_transfer" },
      "sequence": 4
    }
    ```

    Test events and `message.attempt.exhausted` have no `id` to GET, so
    they always come through with the full body.
  </Tab>
</Tabs>

## 5. Handle out-of-order deliveries

Deliveries are **not ordered**. Retries, redrives, and concurrent workers
mean an older state for a resource can land after a newer one. Two fields
let you stay correct.

Every resource event carries a **`sequence`** — a counter that increments
once per resource, the same for every subscriber, stable across retries and
redrives. Track the highest `sequence` you've applied **per resource** and
advance it with a single **atomic conditional write**, so a stale delivery
can't overwrite newer state:

```python theme={null}
def handle(event: dict) -> None:
    data = event["data"]
    state = fetch_state(data)   # in thin mode, GET the resource by data["id"] first

    # Apply the new state and advance the high-water mark in ONE atomic
    # conditional write, keyed per resource. The WHERE clause makes the
    # database the arbiter: concurrent workers race the same row, and an
    # older sequence updates zero rows. Don't read-then-write in separate
    # steps — two workers can read the same mark, both pass the check, and
    # the older one lands last, clobbering newer state.
    db.execute(
        """
        INSERT INTO resource_state (object, id, state, last_sequence)
        VALUES (%(object)s, %(id)s, %(state)s, %(sequence)s)
        ON CONFLICT (object, id) DO UPDATE
          SET state = EXCLUDED.state,
              last_sequence = EXCLUDED.last_sequence
          WHERE resource_state.last_sequence < EXCLUDED.last_sequence
        """,
        {
            "object": data["object"],
            "id": data["id"],
            "state": state,
            "sequence": event["sequence"],
        },
    )
    # Zero rows updated → a newer sequence already landed; this delivery is stale.
```

<Warning>
  `sequence` is **monotonic, not gapless**. A hole (you see 4 then 7) is
  normal — not every internal change emits an event, and a redelivered event
  keeps its original number. Never wait for a missing number or treat a gap as
  lost data; only the per-resource ordering is guaranteed.
</Warning>

`sequence` is absent on `webhook.test` and `message.attempt.exhausted` (they
describe no resource). For everything else, pair it with deduplication on
`webhook-id` (next section): `sequence` discards stale states, `webhook-id`
discards exact duplicates.

## 6. Retries

Meow retries up to **10 times** over \~91 hours.

| Attempt      | 1 | 2   | 3   | 4    | 5   | 6   | 7    | 8    | 9    | 10   |
| ------------ | - | --- | --- | ---- | --- | --- | ---- | ---- | ---- | ---- |
| Delay before | 0 | 5 s | 5 m | 30 m | 2 h | 5 h | 10 h | 14 h | 20 h | 24 h |

* Each non-zero delay gets up to 20% extra jitter.
* `Retry-After` on a 429 or 503 is honored, up to 24 hours.
* 5 failures in a row open a circuit breaker on the subscription. New
  deliveries wait out a cooldown (1 → 30 min) without burning an attempt.
  A success closes it.
* After 10 failed attempts the subscription is auto-disabled
  (`disabled_reason=retry_exhausted`) and a `message.attempt.exhausted`
  event fires.

To recover: `PATCH /webhooks/subscriptions/{id}` with `{"is_enabled": true}`,
or replay a single delivery with `POST /webhooks/deliveries/{id}/redrive`.

## 7. Operate

<CardGroup cols={2}>
  <Card title="Send a test event" icon="paper-plane" href="/api-reference/webhooks/send-test-event">
    Sends `webhook.test` to one subscription. Good for verifying a new
    receiver without waiting for real activity.
  </Card>

  <Card title="Inspect delivery history" icon="clock-rotate-left" href="/api-reference/webhooks/list-deliveries">
    Every attempt with HTTP status and response body excerpt.
  </Card>

  <Card title="Redrive a delivery" icon="rotate-right" href="/api-reference/webhooks/redrive-delivery">
    Resets the counter and re-queues. Works even after `failed_permanent`.
  </Card>

  <Card title="Rotate the secret" icon="key" href="/api-reference/webhooks/update-subscription">
    `PATCH` with `rotate_secret: true`. Both old and new secrets sign for
    7 days; pick whichever your code recognizes.
  </Card>
</CardGroup>

## 8. Security checklist

<AccordionGroup>
  <Accordion title="Verify the signature">
    Constant-time compare. `==` leaks the secret.
  </Accordion>

  <Accordion title="Reject timestamps older than 5 minutes">
    Otherwise a leaked payload can be replayed forever.
  </Accordion>

  <Accordion title="Dedupe on `webhook-id`">
    Same event can arrive twice (retries, redrives). `webhook-id` doesn't change.
  </Accordion>

  <Accordion title="Drop stale states with `sequence`">
    Advance the highest `sequence` per resource with an atomic conditional
    write so a stale delivery can't overwrite newer state.
    See [Handle out-of-order deliveries](#5-handle-out-of-order-deliveries).
  </Accordion>
</AccordionGroup>

## See also

* [Event catalog](/api-reference/webhooks/events) — payload shape per event.
* [Standard Webhooks](https://www.standardwebhooks.com/) — the wire format.
* [`POST /webhooks/subscriptions`](/api-reference/webhooks/create-subscription) — full subscription contract.
