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 an API key
Two kinds, and the difference matters:
| Key | Looks like | Can do |
|---|---|---|
| App key | xpay_test_a3f1… | Move money: payments, payouts, refunds, balance. Scoped to one app and one mode. |
| Account key | xpay_test_9c2e… | List and create apps across your company. Cannot move a franc. |
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: it means a typo in a test fixture cannot send a payment prompt to a stranger at three in the morning.
Add your own phone. Nothing moves and no real prompt is sent. The transaction is simulated end to end, including the webhook.
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": "0788924941",
"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 | 07xx xxx xxx or 2507xx xxx xxx. We normalise, and we route 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. |
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": "250788924941",
"description": "Order #1043",
"reference": "1043",
"metadata": { "order_id": "1043" },
"status": "succeeded",
"failure_code": null,
"failure_message": null,
"network": "mtn",
"provider": "intouch",
"provider_ref": "TX123456789",
"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: an RDB certificate and a TIN, reviewed by a human. Start it from Verification. Nothing about your code changes: same base URL, same endpoints, same webhook format. You swap a test key for a live one, and the money becomes real.
Sending money out, meaning payouts and withdrawals, is available only after approval, whatever your key says. Taking money in is not the risky direction; sending it is.