Webhooks
Overview
Tilt sends a signed HTTP POST to your registered endpoint URL when a payment changes state. Use webhooks instead of polling for latency-sensitive or high-volume integrations.
Register endpoint URLs in the admin portal under Partner → Integrations → Webhooks.
Event types
| Event kind | Fired when |
|---|---|
payment.approved | Processor approves the payment |
payment.refunded | A refund is confirmed by the processor |
payment.voided | A payment is voided before settlement |
payment.cancelled | A pending payment is cancelled by an operator |
payment.expired | A pending payment’s TTL elapses without resolution |
payment.reversal_settled | A reversal (refund/void on settled charge) is confirmed |
payment.reversal_failed | A reversal is rejected by the processor — manual intervention required |
Event envelope
{ "v": 1, "event_id": "evt_01j2…", "event_kind": "payment.approved", "occurred_at": "2024-01-15T10:30:47Z", "partner_id": "uuid", "resource": { "payment_id": "uuid", "order_id": "uuid", "status": "approved", "amount_cents": 5000, "method": "card", "external_reference": "INV-001" }}event_id is a stable UUID — safe to use as an idempotency key.
Signature verification
Every webhook POST includes an X-Tilt-Signature header:
X-Tilt-Signature: hmac-sha256=3d5a0b…The signature is HMAC-SHA256 over the raw request body using the signing secret configured on your endpoint.
import crypto from "node:crypto";
function verifySignature(rawBody, sigHeader, secret) { const expected = "hmac-sha256=" + crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(sigHeader), Buffer.from(expected) );}import hashlib, hmac
def verify_signature(raw_body: bytes, sig_header: str, secret: str) -> bool: expected = "hmac-sha256=" + hmac.new( secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(sig_header, expected)Retry policy
If your endpoint returns a non-2xx response or times out (>30s), Tilt retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After 6 failed attempts the delivery is moved to a dead-letter queue. You can replay DLQ deliveries from the admin portal → Integrations → Webhooks.
Idempotency
event_id is stable across retries. Your listener must deduplicate on event_id — the same event may be delivered more than once (at-least-once delivery guarantee).
Recommended pattern: record event_id in your own database on first receipt; discard if already seen.
Ordering
Events are delivered in rough chronological order but not guaranteed to be strictly ordered. For example, payment.approved could arrive before a payment.failed for a different payment on the same order. Always treat each event independently and re-read the order state from the API if you need the full picture.
Registering an endpoint
Register endpoints in the admin portal under Partner → Integrations → Webhooks, or via the API:
POST /admin/v1/partners/{partner_id}/webhook-endpointsRequired scope: tilt/webhooks:subscribe (granted by super_admin).
Each endpoint has:
- A URL (must be
https://) - An event-kind filter (subscribe to all events, or a specific subset)
- A Tilt-generated signing secret (shown once at creation — copy it before dismissing)