Quickstart
Six steps from signing up to a verified webhook landing on your server. No verification needed for any of it, and test mode works from the first minute.
1. Create an app
An app is one integration: your checkout, your payout job, your marketplace. Each app is either test or live, never both, and carries its own keys, its own webhook URL, and its own ledger. Most companies have two: one test, one live.
Create one in the dashboard. Start with a test app.
2. Take your API key
Creating the app gave you one, on screen, once. It looks like this:
xpay_a3f19c2e_••••••••••••••••••••••••••••••••One key per app, and nothing in it says test or live, because that is the app’s setting and not the key’s. When you go live this key keeps working: no swap, no redeploy, no second secret to pick between.
Shown once
3. Register a test number
Test mode will only prompt numbers you have registered on your test list. This is a deliberate obstacle: test-mode payments are real, so without the list a typo in a fixture could send a payment prompt to a stranger at three in the morning.
Add your own phone. The prompt that arrives is a real one, for real money, and you decide the outcome by approving or declining it.
4. Take a payment
curl https://api.xendlypay.com/v1/payments \
-H "Authorization: Bearer $XENDLY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1043" \
-d '{
"amount": 5000,
"currency": "RWF",
"msisdn": "250781234567",
"description": "Order #1043",
"reference": "1043",
"metadata": { "order_id": "1043" }
}'| Field | Required | Notes |
|---|---|---|
amount | yes | Integer, minor units. RWF has no cents, so 5000 is five thousand francs. Never a float. |
msisdn | yes | The field must be called msisdn. 0788123456, 250788123456 and +250 788 123 456 all work: we normalise to 250788123456 (the format you get back) and route MTN or Airtel on the prefix. |
currency | no | RWF, and only RWF. Defaults to it. |
description | no | Shown in your dashboard. Not shown to the customer. |
reference | no | Your order id. Comes back on every webhook, and the easiest way to join our record to yours. |
metadata | no | Any JSON object. Stored and echoed, never interpreted. |
Test key? Only your test numbers work
msisdn must be one of the numbers on your test list; anything else returns a 400 saying it is not one of your test numbers. Once you go live, any customer’s number works, with the same key and the same code.5. Receive the webhook
Point your app’s webhook URL at an endpoint that can take a POST over HTTPS, then verify every delivery before you trust it. The signature is HMAC-SHA256 over timestamp + "." + raw body:
# Verify with the shell, for a one-off check.
# The raw body must be byte-identical to what we sent.
TIMESTAMP=1789012345
BODY=$(cat delivery.json)
printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$XENDLY_WEBHOOK_SECRET" -hexThe body looks like this:
{
"type": "payment.succeeded",
"data": {
"id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
"merchant_id": "7c1e…",
"app_id": "a3f1…",
"mode": "live",
"direction": "collect",
"amount": 5000,
"currency": "RWF",
"fee": 195,
"msisdn": "250781234567",
"description": "Order #1043",
"reference": "1043",
"metadata": { "order_id": "1043" },
"status": "succeeded",
"failure_code": null,
"failure_message": null,
"network": "mtn",
"xendly_reference": "XP-9F1C-2D3E-4F5A",
"initiated_by": "api",
"reverses_payment_id": null,
"created_at": "2026-09-12T09:14:02Z",
"updated_at": "2026-09-12T09:14:37Z",
"completed_at": "2026-09-12T09:14:37Z",
"settled_at": null
}
}The raw body, byte for byte
6. Go live
Live payments need a verified business: your RDB certificate, read by a human. One PDF. Start it from Verification. Nothing about your code changes: same base URL, same endpoints, same webhook format, same key. You press one button on the app and the money becomes real.
Sending money out, meaning payouts and withdrawals, is available only after approval, whatever mode your app is in. Taking money in is not the risky direction; sending it is.