Webhooks
Receive signed events for payments and subscriptions, and verify every delivery.
Add an endpoint in Dashboard → Developers,
or with POST /webhooks, and copy its whsec_… signing secret. Each event is
sent as a POST with a JSON body. Answer with any 2xx within 10 seconds; failed
deliveries are retried after 5 seconds, 5 minutes, 30 minutes, and 2, 5, 10 and
10 hours.
Endpoint requirements
- Use an
https://URL on a public host. Private, loopback, link-local and internal addresses are rejected, including private addresses returned by DNS. - Do not put a username or password in the URL.
- Redirects are not followed. The exact configured URL must accept the POST.
- Read the raw request body before JSON parsing so signature verification sees exactly the bytes Utopia signed.
- Return a 2xx only after durable processing. Any other status, timeout or connection error schedules a retry.
| Event | When it's sent |
|---|---|
payment.succeeded | A payment was captured, including subscription renewals (see billing_reason). |
payment.failed | A payment attempt was declined or failed. |
refund.succeeded | Money was returned; data.refund_amount is the amount of this refund. |
subscription.active | A subscription started after its first payment. |
subscription.renewed | A renewal was paid and a new billing period began. |
subscription.on_hold | A renewal failed. Retries are scheduled and the customer is emailed a payment link. |
subscription.cancelled | Cancelled by you, at the end of its period, or after the last failed retry. |
The dashboard's Send test action sends webhook.test to the selected
endpoint. It is a connectivity and signature test; it does not create a
payment. This event is not emitted by normal payment activity.
POST /webhooks/utopia
webhook-id: evt_5d0c2b8f1e3a4c6b9d7e8f0a1b2c3d4e
webhook-timestamp: 1789412400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_5d0c2b8f1e3a4c6b9d7e8f0a1b2c3d4e",
"type": "payment.succeeded",
"livemode": true,
"created_at": "2026-09-14T10:00:00.000Z",
"data": {
"object": "payment",
"payment_id": "pay_7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d",
"status": "succeeded",
"total_amount": 34900,
"refunded_amount": 0,
"fee_amount": 3012,
"net_amount": 31888,
"currency": "AED",
"billing_reason": "payment",
"checkout_session_id": "cks_9b1e44a0c3d24f6e8a7b5c2d1e0f9a8b",
"subscription_id": null,
"customer": { "email": "customer@example.com" },
"metadata": { "order_id": "1001" }
}
}Payment events carry the payment as GET /payments/:id returns it, with
amounts as integers in the currency's minor unit:
fee_amount: Utopia's processing fee, fixed when the payment succeeds and kept after a refund;nulluntil the payment settles.net_amount: what you keep,total_amount−refunded_amount−fee_amount;nulluntil the payment settles, and below zero after a full refund because the fee is kept.
Verify every delivery
Signatures follow the Standard Webhooks specification, so any of its libraries also works. Verify against the raw request body, before parsing it.
// Express: keep the raw body for verification
app.post('/webhooks/utopia', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = utopia.webhooks.unwrap(req.body, req.headers, process.env.UTOPIA_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
switch (event.type) {
case 'payment.succeeded':
fulfilOrder(event.data.metadata.order_id);
break;
case 'subscription.cancelled':
revokeAccess(event.data.customer.email);
break;
}
res.sendStatus(204);
});The algorithm
signed_content = webhook-id + "." + webhook-timestamp + "." + raw_body
key = base64_decode(secret without the "whsec_" prefix)
expected = "v1," + base64(HMAC_SHA256(key, signed_content))Accept the delivery when any space-separated entry of webhook-signature
equals expected (compare in constant time) and webhook-timestamp is within
five minutes of now.
Duplicates
The same event can arrive more than once. Use its id (the webhook-id
header) to skip ones you've already handled.
Recover missed events
GET /events is the durable event history for the current merchant and mode.
Filter it by type, paginate through it, and run missed payloads through the
same idempotent business handler used after verification:
curl "https://utopia-payments.com/api/v1/events?type=payment.succeeded&limit=100" \
-H "Authorization: Bearer $UTOPIA_API_KEY"Reading an event does not redeliver it over HTTP. The Developers dashboard shows delivery attempts and lets an authorised merchant retry a failed delivery. Event history and delivery history are isolated between live and test mode.