Skip to main content

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.

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

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",
    "...": "..."
  }
}

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

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

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

See also