Webhooks overview
Send Chain sends outbound, signed HTTP POST notifications for events on your account — payment lifecycle, wishlist funding, and prepaid-balance top-ups. Register an endpoint and choose which event types it receives from your dashboard’s Settings → Webhooks screen.
Envelope
Section titled “Envelope”Every delivery is a JSON body shaped like:
{ "id": "evt_5f3c1e7b9a4d4e8a9c6f0b1a2d3e4f5a", "type": "payment.completed", "createdAt": "2026-09-13T00:00:00Z", "data": { "...": "one of the event-specific shapes below" }}id is unique per delivery attempt group — use it to deduplicate, since
delivery is at-least-once (a retried delivery reuses the same id).
Headers
Section titled “Headers”| Header | Meaning |
|---|---|
Tribute-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> — see Verifying below. During a secret rotation’s rollover window, two v1= values may be present; either matching your secret verifies the request. |
Tribute-Event-Id |
Same value as the body’s id, for convenience when you only need headers. |
Verifying a signature
Section titled “Verifying a signature”The signed payload is "<t>.<raw request body>", HMAC-SHA256’d with your
endpoint’s signing secret (shown once at creation), hex-encoded. Always
verify against the raw body bytes, before any JSON parsing, and reject
timestamps outside a reasonable tolerance (5 minutes is a sane default) to
guard against replay.
# Illustrative — verification happens in your receiver's code, not on the# command line. Given a received body $BODY and header# "Tribute-Signature: t=1700000000,v1=…":TIMESTAMP="1700000000"SIGNED_PAYLOAD="${TIMESTAMP}.${BODY}"EXPECTED=$(printf '%s' "$SIGNED_PAYLOAD" | openssl dgst -sha256 -hmac "$ENDPOINT_SECRET" | sed 's/^.* //')echo "expected v1=$EXPECTED"import crypto from "node:crypto";
function verify(secret, headerValue, body, toleranceSeconds = 300) { const parts = Object.fromEntries(headerValue.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"); const received = headerValue.match(/v1=([0-9a-f]+)/g) ?? []; return received.some((v1) => crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1.split("=")[1])), );}import hmac, hashlib, time
def verify(secret, header_value, body, tolerance_seconds=300): parts = dict(p.split("=", 1) for p in header_value.split(",")) t = int(parts.get("t", 0)) if not t or abs(time.time() - t) > tolerance_seconds: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() received = [v for k, v in (p.split("=", 1) for p in header_value.split(",")) if k == "v1"] return any(hmac.compare_digest(expected, mac) for mac in received)<?phpfunction verify(string $secret, string $headerValue, string $body, int $tolerance = 300): bool { $parts = []; foreach (explode(",", $headerValue) as $pair) { [$k, $v] = array_pad(explode("=", $pair, 2), 2, null); $parts[$k][] = $v; } $t = isset($parts["t"][0]) ? (int) $parts["t"][0] : 0; if (!$t || abs(time() - $t) > $tolerance) return false; $expected = hash_hmac("sha256", "{$t}.{$body}", $secret); foreach ($parts["v1"] ?? [] as $mac) { if (hash_equals($expected, $mac)) return true; } return false;}Retries & delivery
Section titled “Retries & delivery”A non-2xx response (or timeout) is retried up to six times on exponential backoff. Every delivery attempt is recorded and visible in your dashboard’s webhook log, where you can also manually redeliver a past event. An endpoint that fails persistently is auto-paused; resuming it re-queues everything from the outage window.
Event types
Section titled “Event types”payment.detected— a matching transaction was first seen, before confirmation.payment.completed— confirmed for the full expected amount.payment.partial— confirmed for less than expected.payment.overpaid— confirmed for more than expected.payment.paid_after_expiry— confirmed after the quote expired.payment.unrecognised— funds arrived in an unexpected asset or chain.payment.reversed— a completed payment was undone by a chain reorganisation.wishlist_item.funded— a wishlist item reached its goal.billing.topup_credited— a prepaid-balance top-up cleared.billing.topup_unrecognised— a top-up transfer couldn’t be matched.