Skip to content

Xendit

Setup guide for the xendit payments provider. The shared API (initiate_payment, verify_payment, refund_payment, webhooks, hooks, saved methods) is documented in Payments.

The xendit provider supports one-time payments through Xendit Invoices (initiate_payment, verify_payment; virtual accounts, e-wallets, QRIS, cards, retail outlets and the other channels your account enables), refunds (refund_payment) and webhooks, in the currencies Xendit collects (IDR, PHP, THB, VND, MYR, USD). GET /payments/providers reports no optional capability.

Feature Status
One-time payments (Invoices), verify, refunds Supported
Subscriptions, cancel, plan change, pause, billing portal Not supported (NOT_SUPPORTED, 501): Xendit’s Recurring API needs a customer with a linked payment method and has no hosted start
Disputes Not supported: Xendit has no public disputes API
Saved payment methods, charge_off_session Not supported yet (NOT_SUPPORTED, 501)

It needs no extra install: it calls https://api.xendit.co directly over HTTP, following Xendit’s Invoice and Refund APIs as of September 2026 (/v2/invoices, /refunds).

Why plain HTTP (no Xendit SDK). Xendit’s Python package, xendit-python, needed Python >= 3.10 when Kirak still supported 3.9, and the few endpoints Kirak calls are simple REST.

1. Get credentials. In the Xendit Dashboard, Settings -> API keys, create a secret key with write access to Money-in: xnd_development_... in test mode, xnd_production_... in live mode.

2. Set the webhooks. In Settings -> Webhooks:

  • set the Invoices URL (invoice paid and expired) and the Refund URL (refund succeeded) to https://your-domain.com/payments/webhook/xendit (/payments/webhook/<instance> for another instance name);
  • copy the verification token: Xendit sends it on every webhook in the x-callback-token header.

3. Configure the instance and secrets. In kirak.json:

"xendit": {
"type": "xendit",
"environment": "test",
"success_url": "https://your-domain.com/payment/success"
}

environment is test (the default) or live and must match the secret key’s xnd_development_/xnd_production_ prefix (else a CONFIGURATION_ERROR at startup). success_url is sent as the invoice’s success and failure redirect URL when absolute (http(s)://). In the environment:

Terminal window
KIRAK_PAYMENT_XENDIT_SECRET_KEY=xnd_development_...
KIRAK_PAYMENT_XENDIT_CALLBACK_TOKEN=<the webhook verification token>

For an instance with another name the variables are KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY and KIRAK_PAYMENT_<INSTANCE>_CALLBACK_TOKEN (see docs/reference/configuration.md). Both are required.

4. Use it:

# One-time payment -- send the buyer to data["url"] (Xendit's invoice page)
result = await kirak.payments.initiate_payment({
"provider": "xendit",
"user_id": "42", "amount": 15000000, "currency": "IDR", # minor units: Rp 150,000
"name": "Pro Plan", "type": "one_time",
"payment_email": "buyer@example.com", # optional
})
reference = result["data"]["reference"] # external_id, kirak-<32 hex>
await kirak.payments.verify_payment({"provider": "xendit", "payment_id": reference})
await kirak.payments.refund_payment({"provider": "xendit", "payment_id": reference, "amount": 5000000})

Payments. initiate_payment stores a random external_id (kirak-<32 hex>) on the PENDING row before creating the invoice (amount x quantity, description = name, metadata naming the transaction and instance), then keeps the invoice id on the row and returns its invoice_url as url. The reference is never derived from the idempotency_key, which another Kirak database on the same account could repeat. A 4xx answer marks the row FAILED; a timeout, 5xx or 429 leaves it PENDING.

An invoice callback (the invoice object itself) is never trusted alone: the invoice is re-read with GET /v2/invoices/{id}, and the row completes once (payment_completed, with amount and currency) only when it is PAID or SETTLED, has the row’s external_id and stored invoice id, the row’s currency, a paid_amount of at least amount x quantity, and metadata that, where present, names this transaction and instance. An invoice that does not match writes nothing. An EXPIRED invoice (it can no longer be paid) marks the row FAILED once (payment_failed). An invoice Xendit does not return, or a read that times out, fails the webhook (500) so Xendit retries. verify_payment (payment_id = the reference) sets PROCESSING for a confirmed paid invoice and FAILED for an expired one.

Refunds. refund_payment (payment_id = the reference; optional amount in minor units of the payment’s currency and reason, default REQUESTED_BY_CUSTOMER) calls POST /refunds with the invoice id and an Idempotency-key derived from the caller’s idempotency_key and the transaction’s random reference (random without one), so a retry with the same key never refunds twice. A refund Xendit answers SUCCEEDED is recorded at once; a PENDING one is recorded by the refund.succeeded webhook, which re-reads the refund and its invoice and records it for the row that holds that invoice (keyed by refund id with a running total; REFUNDED never moves back; refund_completed). Xendit supports refunds only for some channels; its error is returned as it comes.

Webhook security. The x-callback-token header must equal KIRAK_PAYMENT_<INSTANCE>_CALLBACK_TOKEN (compared in constant time; missing or wrong is 400). It is a static token, not a signature over the body, which is why every change comes from re-reading the object. Callbacks have no id: deliveries are deduplicated on the sha256 of the raw body.

Currencies. Xendit amounts are major units, sent as JSON numbers built exactly from the decimal. IDR must be a whole number of rupiah at Xendit (CURRENCY_EXPONENTS = {"IDR": 0}), so an IDR amount with sen is rejected with AMOUNT_NOT_REPRESENTABLE (400) before anything is saved; PHP and the others keep their ISO 4217 decimals.

Known limits.

  • Subscriptions, saved payment methods and off-session charges are not supported.
  • There is no disputes API.
  • refund.failed is acknowledged without a change; the refund stays unrecorded.

after_webhook receives these provider-neutral event names (see Webhook Hooks):

Xendit reports payment_completed (an invoice callback whose re-read invoice is PAID or SETTLED and matches the row, event_type invoice; an unmatched one reports none, with invoice.unconfirmed), payment_failed (an expired invoice, invoice.expired) and refund_completed (refund.succeeded, once the re-read refund succeeded, event_type refund.recorded).