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:

KeyLooks likeCan do
App keyxpay_test_a3f1…Move money: payments, payouts, refunds, balance. Scoped to one app and one mode.
Account keyxpay_test_9c2e…List and create apps across your company. Cannot move a franc.

Shown once

We store a SHA-256 hash, not the key. If you lose it, nobody at Xendly can read it back to you. Revoke 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: 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" }
  }'
FieldRequiredNotes
amountyesInteger, minor units. RWF has no cents, so 5000 is five thousand francs. Never a float.
msisdnyes07xx xxx xxx or 2507xx xxx xxx. We normalise, and we route 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.

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": "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

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: 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.