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-tokenheader.
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:
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.failedis acknowledged without a change; the refund stays unrecorded.
Webhook events
Section titled “Webhook events”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).