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

We store a SHA-256 hash, not the key. If you lose it, nobody at Xendly can read it back to you. Rotate it and mint another. Keep it on your server; a key that reaches a browser is a public key.

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" }
  }'
FieldRequiredNotes
amountyesInteger, minor units. RWF has no cents, so 5000 is five thousand francs. Never a float.
msisdnyesThe 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.
currencynoRWF, and only RWF. Defaults to it.
descriptionnoShown in your dashboard. Not shown to the customer.
referencenoYour order id. Comes back on every webhook, and the easiest way to join our record to yours.
metadatanoAny JSON object. Stored and echoed, never interpreted.

Test key? Only your test numbers work

While the app is in test mode, 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" -hex

The 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

Frameworks that parse JSON for you hand your handler a re-serialised document, and a re-serialised document has a different signature. Capture the raw bytes first. This is the bug in most failed webhook integrations, and it looks exactly like “your signatures are broken” from the inside.

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.