Guide to Xendit Webhooks: Features and Best Practices
Xendit webhooks tell your systems when money moves: a payment is authorised, captured or fails, a saved payment method is activated or expires, a payment request expires, or a refund succeeds or fails. Many Southeast Asian payment methods complete asynchronously, sometimes hours or days after the customer starts, so if you take payments with Xendit, webhooks are how you learn the outcome.
This guide covers how Xendit webhooks are configured and authenticated, the Payments API events and their payloads, Xendit's retry and resend tools, and the practices that keep a Xendit receiver safe and reliable.
What are Xendit webhooks?
Xendit sends a JSON POST to the URL you save in the dashboard's Webhook settings whenever a business event happens on your account. URLs are set per product, so payments, invoices and disbursements can each go to a different endpoint, or to the same one.
Xendit does not sign webhook payloads. Its docs say Xendit "can sign each webhook event", but what they describe is a token: your account's webhook verification token, sent unchanged in the x-callback-token header on every request. Your receiver compares the header with the token from the dashboard. Nothing is computed over the body.
This guide focuses on the Payments API, whose webhooks share one envelope with an event field. Xendit's older products, such as invoices, virtual accounts and disbursements, send their own callback shapes, but carry the same x-callback-token.
Xendit webhook features
| Feature | Details |
|---|---|
| Configuration | The dashboard's Webhook settings, one URL per product |
| Method | POST with a JSON body |
| Authentication | Webhook verification token, sent verbatim in x-callback-token |
| Body signature | None |
| Replay window | None. No timestamp is signed |
| Success | Any 2xx response |
| Retries | Up to 6, at 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours after the original attempt |
| Manual resend | One event at a time, or a filtered batch of up to 500, from the Webhook tab |
| Ordering | Not guaranteed |
| Duplicates | Possible. Use payment_id and capture_id to deduplicate |
| Event history | Per-event attempts and response codes in the dashboard |
Xendit documents no response timeout, delivery ID header or source IP addresses for webhooks.
Common events
The Payments API sends ten events, named in the body's event field.
| Resource | Event | Fires when |
|---|---|---|
| Payment request | payment_request.expiry | A payment request expires and stops accepting payments |
| Payment token | payment_token.activation | A payment token is authenticated and confirmed by the payment method |
| Payment token | payment_token.failure | A payment token fails authentication |
| Payment token | payment_token.expiry | A token reaches its expiry, or a card passes its expiry date |
| Payment | payment.authorization | A payment is reserved on the customer's account |
| Payment | payment.capture | A payment is collected and confirmed |
| Payment | payment.expiry | A successful authorisation expires |
| Payment | payment.failure | A payment fails to be collected |
| Refund | refund.succeeded | A refund succeeds |
| Refund | refund.failed | A refund fails |
Xendit notes that payment_request.expiry is subject to race conditions, so a payment can still arrive close to expiry.
Every Payments API payload has four top-level keys: event, business_id, created and data. Here is Xendit's payment.capture example:
{
"event": "payment.capture",
"business_id": "603f1c4172bbe840979fd408",
"created": "2026-08-26T07:28:23.731Z",
"data": {
"payment_id": "py-66269236-7447-450f-b0d8-ed4255b11d8a",
"business_id": "603f1c4172bbe840979fd408",
"reference_id": "nw-2026-08-26T07:28:23.260Z",
"payment_request_id": "pr-c5a38204-58a9-4968-a8ad-68a79b172a66",
"customer_id": "cust-279ab1f7-3b7a-43f0-8f5e-7a2835a854a8",
"type": "PAY",
"country": "ID",
"currency": "IDR",
"request_amount": 1234,
"capture_method": "AUTOMATIC",
"channel_code": "DANA",
"status": "SUCCEEDED",
"captures": [
{
"capture_id": "cptr-66269236-7447-450f-b0d8-ed4255b11d8a",
"capture_timestamp": "2026-08-26T07:28:37.2Z",
"capture_amount": 1234
}
],
"payment_details": {},
"metadata": { "metametadata": "metametametadata" },
"created": "2026-08-26T07:28:23.731Z",
"updated": "2026-08-26T07:28:37.032Z"
}
}
data holds the payment, payment token or refund object. reference_id is the reference you supplied, which makes it the easiest way to match an event to your own order, and metadata returns whatever you attached when you created the request.
Setting up Xendit webhooks
- Build an endpoint that accepts
POSTrequests and checksx-callback-token. - In the Xendit dashboard, open the Webhook settings.
- Copy the webhook verification token and store it server-side, for example as
XENDIT_CALLBACK_TOKEN. - Enter your endpoint URL for each product you use and save.
If you receive through Hookdeck, create a Xendit source, use its URL for each product's webhook, and paste the token into the source's Webhook Verification Token field. Hookdeck accepts each request, then records any request whose x-callback-token does not match as failed verification, so it never becomes an event for your service.
Securing Xendit webhooks
There is nothing to compute. Read x-callback-token and compare it with your stored token in constant time. Fail closed if no token is configured, so an empty header can never match an empty secret.
const crypto = require('crypto');
const express = require('express');
function verifyXenditToken(headerValue, expectedToken) {
if (!headerValue || !expectedToken) return false;
const received = Buffer.from(headerValue, 'utf8');
const expected = Buffer.from(expectedToken, 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/xendit', express.raw({ type: '*/*' }), (req, res) => {
const token = process.env.XENDIT_CALLBACK_TOKEN;
if (!token) return res.sendStatus(500); // fail closed
if (!verifyXenditToken(req.get('x-callback-token'), token)) {
return res.sendStatus(401);
}
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Payments API: route on body.event. Older products: check body.status instead.
res.sendStatus(200);
});
The same check in Python:
import hmac
def verify_xendit_token(header_value, expected_token: str) -> bool:
if not header_value or not expected_token:
return False
return hmac.compare_digest(header_value.encode("utf-8"), expected_token.encode("utf-8"))
# In FastAPI: verify_xendit_token(request.headers.get("x-callback-token"),
# os.environ["XENDIT_CALLBACK_TOKEN"])
Xendit recommends verifying every webhook and keeping the token secret. Because the token travels with every request and doesn't protect the body, also serve the endpoint over HTTPS only, and never handle webhooks in client-side code.
Make Xendit webhooks production-ready. Hookdeck Event Gateway checks the
x-callback-tokenheader, acknowledges Xendit straight away, and durably queues every payment and refund event before your service sees it.
Xendit webhook limitations and pain points
A token, not a signature
The Problem: Nothing proves the body came from Xendit unchanged, and a captured request can be replayed.
Why It Happens: Xendit sends a static token rather than a signature computed over the body, and nothing is timestamped.
Workarounds:
- Use HTTPS only and keep the token out of logs and client code.
- Before you release goods or credit an account, confirm the payment's status with the Xendit API, using the
payment_idfrom the webhook.
How Hookdeck Can Help: Hookdeck holds the token and checks it on every request, so your own services don't need it, and requests with a missing or wrong token never reach them.
Duplicates and jumbled order
The Problem: A payment is fulfilled twice, or a later status is overwritten by an earlier one.
Why It Happens: Xendit retries until it gets a 2xx, and its payment channel providers can resend events when recovering from their own outages. Xendit also says events can arrive in any order, for example a subscription cycle success before the plan activation.
Workarounds:
- Deduplicate on
payment_idandcapture_id, as Xendit recommends, together withevent. - Store each resource's latest
updatedvalue and status, and ignore older events. - Buffer events in a queue, as Xendit suggests, so you can process them once all related events have arrived.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeats based on body fields such as data.payment_id and event, and its queue gives you the buffer Xendit recommends without running one yourself.
Long gaps between retries
The Problem: After a brief outage, a payment confirmation arrives 15 minutes or more later, and customers wait.
Why It Happens: Xendit's first retry comes 15 minutes after the original attempt, and the gaps grow from there: 1 hour, 3 hours, 6 hours, 12 hours and 24 hours. After the sixth retry, the event is only available through a manual resend.
Workarounds:
- Keep the endpoint available, and acknowledge before doing any slow work.
- After an outage, use the dashboard's Custom Resend to resend a filtered batch. Batches are capped at 500 events, so split larger windows.
- Poll the API for payments still pending in your system.
How Hookdeck Can Help: Hookdeck accepts each event as soon as Xendit sends it, so your downtime doesn't push deliveries onto Xendit's slow retry schedule. Hookdeck then retries delivery to your service on a schedule you set and lets you replay anything that failed.
Different shapes for different products
The Problem: A handler built for Payments API events crashes on an invoice callback.
Why It Happens: Only the Payments API uses the event, business_id, created and data envelope. Older products send their own bodies, such as the invoice itself with a status.
Workarounds:
- Give each product its own URL, or detect the shape before dispatching.
- Treat a body without
eventas a legacy callback and route on itsstatusand product.
How Hookdeck Can Help: Hookdeck filters can route requests by the presence and value of event, so Payments API events and legacy callbacks go to different destinations.
Best practices
Authenticate before parsing
Check x-callback-token before you parse or log the body, and return 401 when it fails.
Acknowledge fast and process asynchronously
Xendit says to return 2xx quickly and not to run complex logic synchronously. Store the event, respond, then do the work. See why to process webhooks asynchronously.
Make handlers idempotent
Use payment_id, capture_id and refund IDs as natural keys, and check whether you've already processed an event before you fulfil an order. See our guide to webhook idempotency.
Match events with your own references
Set reference_id and metadata when you create payment requests. They come back in every related webhook and save a lookup.
Watch the Webhook tab
Each event's history shows every attempt and the response code Xendit received. Check it when a payment seems stuck, and resend from there once the cause is fixed.
Conclusion
Xendit webhooks are unsigned JSON POSTs, authenticated by the account's verification token in x-callback-token. Compare the token in constant time over HTTPS, route Payments API events on event, deduplicate on payment_id and capture_id, expect events out of order, and confirm high-value outcomes through the API.
Hookdeck Event Gateway sits in front of your receiver, checks the Xendit token, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Xendit webhooks reliably in minutes.