Gareth Wilson Gareth Wilson

Guide to Xendit Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe dashboard's Webhook settings, one URL per product
MethodPOST with a JSON body
AuthenticationWebhook verification token, sent verbatim in x-callback-token
Body signatureNone
Replay windowNone. No timestamp is signed
SuccessAny 2xx response
RetriesUp to 6, at 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours after the original attempt
Manual resendOne event at a time, or a filtered batch of up to 500, from the Webhook tab
OrderingNot guaranteed
DuplicatesPossible. Use payment_id and capture_id to deduplicate
Event historyPer-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.

ResourceEventFires when
Payment requestpayment_request.expiryA payment request expires and stops accepting payments
Payment tokenpayment_token.activationA payment token is authenticated and confirmed by the payment method
Payment tokenpayment_token.failureA payment token fails authentication
Payment tokenpayment_token.expiryA token reaches its expiry, or a card passes its expiry date
Paymentpayment.authorizationA payment is reserved on the customer's account
Paymentpayment.captureA payment is collected and confirmed
Paymentpayment.expiryA successful authorisation expires
Paymentpayment.failureA payment fails to be collected
Refundrefund.succeededA refund succeeds
Refundrefund.failedA 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

  1. Build an endpoint that accepts POST requests and checks x-callback-token.
  2. In the Xendit dashboard, open the Webhook settings.
  3. Copy the webhook verification token and store it server-side, for example as XENDIT_CALLBACK_TOKEN.
  4. 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-token header, 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_id from 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_id and capture_id, as Xendit recommends, together with event.
  • Store each resource's latest updated value 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 event as a legacy callback and route on its status and 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.