Utopia PaymentsDocs

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.
EventWhen it's sent
payment.succeededA payment was captured, including subscription renewals (see billing_reason).
payment.failedA payment attempt was declined or failed.
refund.succeededMoney was returned; data.refund_amount is the amount of this refund.
subscription.activeA subscription started after its first payment.
subscription.renewedA renewal was paid and a new billing period began.
subscription.on_holdA renewal failed. Retries are scheduled and the customer is emailed a payment link.
subscription.cancelledCancelled 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.

Example delivery
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; null until the payment settles.
  • net_amount: what you keep, total_amountrefunded_amountfee_amount; null until 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.

On this page