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.
Each mode holds up to 16 endpoints. Creating a seventeenth in the same mode
returns 409 LIMIT_REACHED, so delete the ones you no longer use.
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.
- Answer 2xx to event types you don't recognise, and ignore them. New types are added over time, and an endpoint that subscribes to everything receives them as soon as they exist; treating one as an error only makes it retry for a day and buries the failures that matter.
| 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, and it is not one
of the types an endpoint can subscribe to: Send test delivers it even to an
endpoint that filters on specific types. It is recorded like any other event, so
it also comes back from GET /events and GET /events?type=webhook.test.
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",
"description": "Pro course",
"customer": {
"customer_id": "cus_2c4e6a8b0d1f4a3c5e7b9d1f3a5c7e9b",
"name": "Layla Hassan",
"email": "customer@example.com",
"phone_number": "+971501234567"
},
"checkout_session_id": "cks_9b1e44a0c3d24f6e8a7b5c2d1e0f9a8b",
"subscription_id": null,
"billing_reason": "payment",
"metadata": { "order_id": "1001" },
"livemode": true,
"created_at": "2026-09-14T09:58:12.000Z",
"paid_at": "2026-09-14T10:00:00.000Z"
}
}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.
import { Webhooks } from '@utopia-payments/node';
// Verification needs no API key, so a receiver needs no client.
const webhooks = new Webhooks();
// Express: keep the raw body for verification
app.post('/webhooks/utopia', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = 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.
Delivery schedule
A delivery is attempted eight times in all: once straight away, then after
5 seconds, 5 minutes, 30 minutes, and 2, 5, 10 and 10 hours. That spans about
28 hours from the event. After the eighth attempt the delivery is abandoned and
is never retried on its own — the event stays in GET /events, and the
Developers dashboard can retry a failed delivery by hand.
Duplicates
The same event can arrive more than once. Use its id (the webhook-id
header) to skip ones you've already handled.
Testing locally
Endpoints must be public HTTPS URLs, so a receiver on your own machine needs a
tunnel that gives it one (ngrok, Cloudflare Tunnel or similar). Add the tunnel's
public URL as a test-mode endpoint, then use Send test to deliver
webhook.test and confirm your handler verifies the signature and answers 2xx
before you involve a real payment. Replace the endpoint when the tunnel's URL
changes: deliveries go to the exact URL you saved.
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.