# 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:

```json
{
  "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](https://dashboard.xendit.co/settings/developers#callbacks).
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.

```javascript
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:

```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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/webhooks/guides/implement-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 `POST`s, 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](https://hookdeck.com) sits in front of your receiver, checks the Xendit token, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Xendit webhooks reliably in minutes.