Skip to main content
Meow POSTs an event to your URL when a transfer changes state or a deposit clears. The wire format is Standard Webhooks — the Python, Node, and 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.
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"
  }'
Response
{
  "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_..."
}
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. 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).

2. Verify the signature

Three headers come with every delivery:
HeaderWhat it is
webhook-idDelivery ID. Same value on every retry.
webhook-timestampEpoch seconds. New on each attempt.
webhook-signatureOne 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.
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")
Use constant-time compare (hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal). == leaks the secret one byte at a time.

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:
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.

4. Pick a payload mode

Set payload_mode per subscription. Change it any time with PATCH.
data carries the full resource. No follow-up GET needed.
{
  "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
}

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:
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.
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.
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.
Attempt12345678910
Delay before05 s5 m30 m2 h5 h10 h14 h20 h24 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

Send a test event

Sends webhook.test to one subscription. Good for verifying a new receiver without waiting for real activity.

Inspect delivery history

Every attempt with HTTP status and response body excerpt.

Redrive a delivery

Resets the counter and re-queues. Works even after failed_permanent.

Rotate the secret

PATCH with rotate_secret: true. Both old and new secrets sign for 7 days; pick whichever your code recognizes.

8. Security checklist

Constant-time compare. == leaks the secret.
Otherwise a leaked payload can be replayed forever.
Same event can arrive twice (retries, redrives). webhook-id doesn’t change.
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.

See also