https://payment.kuvarpay.comkal_9f2c…. That is your payout destination. You never receive their KuvarSend user id, phone number, or balance.201 response means the money has already moved. There is no "pending" state to poll and no reversal API.The name of the thing: the authorization is an account link ( kal_…). The money movement is a linked payout (kwp_…).
| Requirement | Who controls it | How you get it |
|---|---|---|
| Business verification approved | KuvarPay admin — a human reviews your business documents | Submit your business documents in the dashboard, then wait for review. This is the approval gate. |
| KYC verified | You — completed in the dashboard | Complete KYC in the dashboard |
| "Merchant-initiated payouts" switched on | You — self-serve, but the button only unlocks after the two rows above are green | Dashboard → Settings → Developers → Account Linking → Enable payouts |
| A registered HTTPS callback URI | You — self-serve | Dashboard → Settings → Developers → Account Linking → Registered callbacks |
| A LIVE secret key with the four payout scopes | You — self-serve | Dashboard → Settings → Developers → API Keys → tick "KuvarSend account linking and payouts" |
| Your server IPs on the payout allowlist | You — self-serve | Dashboard → Settings → Developers → Whitelisted Domains/IP → Payout IP allowlist |
PAYOUT_CURRENCY_UNAVAILABLE;403: ACCOUNT_LINK_NOT_ALLOWED — "Account linking is not enabled." — while the account is not yet eligible, ACCOUNT_LINK_LIVE_ONLY if you used a sandbox key, and an AUTH_004 "Insufficient permissions" error if your IP is not on the payout allowlist. ACCOUNT_LINK_NOT_ALLOWED does not say which requirement is outstanding, so work through the checklist below.https:// — no http, no localhost, no IP address;https://app.example.com/kuvarsend/callback and https://app.example.com/kuvarsend/callback/ are different URIs.account_links:read, account_links:write, payouts:read, payouts:write. Dashboard → Settings → Developers → API Keys → tick "KuvarSend account linking and payouts". The scopes are granted to the secret key only (rsp_secret_…); your publishable/client key can never move money.403. Add the public egress IP of each server that will call the API.kuvarsend.payout.completed and account_link.revoked.X-API-Key: rsp_secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/jsonX-Business-ID is optional; if you send it, it must match the key's business.403 ACCOUNT_LINK_LIVE_ONLY — "Account linking runs in live mode only; use a live secret API key." YOUR BACKEND KUVARPAY KUVARSEND APP (your user)
| | |
1. |--- POST account-link-request-->| |
|<-- alr_… + authorization_url --| |
2. |--- send user to that URL -------------------------------------> |
| | 3. consent screen + PIN
| |<--- approve -------------------- |
4. |<-- redirect to your callback: ?code=kpal_…&state=… ------------ |
5. |--- POST exchange (code + verifier) -->| |
|<-- kal_… (linked_account_id) ---------| |
| | |
6. |--- POST payouts/kuvarsend ---->| debit your balance, credit their wallet
|<-- 201 kwp_… (COMPLETED) -----| ---- push notification --------> |
|<-- webhook kuvarsend.payout.completed |state — a random value you generate, store server-side, and compare when the user comes back. It stops someone else's callback being replayed at your endpoint.code_verifier / code_challenge — you generate a high-entropy verifier, send only its SHA-256 (code_challenge), and reveal the verifier only when you exchange. It stops a stolen authorization code from being useful to anyone but you."Allow this business to identify this KuvarSend account as a payout destination and send payouts to it. The business cannot view your balance or history, log in, withdraw, or debit your account."
scope: payouts:receive). The link is credit-only in your favour: you can pay in, and you can never pull out.@kuvarpay/sdk, which generate PKCE and state and parse the callback for you.SDK version: use @kuvarpay/sdk1.9.2 or newer. Earlier versions have the same helpers, but their TypeScript types describe the payout debit as USD only. The raw HTTP endpoints are the contract either way.
201 Created
{
"data": {
"id": "alr_4b7c2f1e9a0d4c6b8e3f5a7c9d1b2e4f",
"authorization_url": "https://pay.kuvarpay.com/account-link/alr_4b7c2f1e9a0d4c6b8e3f5a7c9d1b2e4f",
"expires_at": "2026-08-26T12:41:07.000Z"
}
}external_user_reference is your user id. We store only a keyed hash of it, never the raw value.state must be at least 22 characters. Generate it randomly and keep it in the user's server-side session next to the code verifier.Idempotency-Key is required. Retrying with the same key and the same body returns the same request; the same key with a different body is 409 IDEMPOTENCY_CONFLICT.authorization_url?code=…&state=…. Denying redirects to the same URI with ?error=access_denied&state=….code and state. Compare state against the session, then exchange:200 OK
{
"data": {
"linked_account_id": "kal_9f2c8a1d3e5b7c9a0d2f4e6b8c1a3d5f",
"status": "ACTIVE",
"scope": "payouts:receive",
"display_name": null,
"kuvar_tag": "ada",
"linked_at": "2026-08-26T12:33:41.000Z"
}
}request_id instead of code and poll — the PKCE verifier is your proof:{
"request_id": "alr_4b7c2f1e9a0d4c6b8e3f5a7c9d1b2e4f",
"redirect_uri": "https://app.example.com/kuvarsend/callback",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}409 AUTHORIZATION_NOT_READY — keep polling every few seconds. Stop at the expires_at you got in Step 1 and start a new request: an unapproved request keeps answering AUTHORIZATION_NOT_READY, so it is your own timer that ends the wait.code or request_id; sending both or neither is 400 INVALID_REQUEST.linked_account_idkuvar_tag is the recipient's KuvarSend tag as it stood at link time, useful for showing the user who they connected. display_name is reserved and is currently always null — do not build UI that depends on it.external_user_reference) already has an ACTIVE link with you, a second exchange returns 409 ACCOUNT_LINK_CONFLICT. Revoke the old one first.200 OK
{
"data": {
"quote_id": "kwq_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"linked_account_id": "kal_9f2c…",
"amount": "10000.00",
"currency": "NGN",
"source_currency": "NGN",
"source_amount": "10000.00",
"rate": "1533.5",
"fee_percent": "2",
"fee_amount": "200.00",
"recipient_amount": "9800.00",
"expires_at": "2026-08-26T12:35:41.000Z"
}
}source_currency is your balance's currency and source_amount is exactly what it will be debited. Here the business balance is in NGN, so paying NGN debits the same 10,000. A business with a USD balance would see "source_currency": "USD" and a USD cost such as "6.53".201 Created
{
"data": {
"payout_id": "kwp_7c3e1a9b5d2f4e6a8c0b1d3f5a7e9c2b",
"status": "COMPLETED",
"linked_account_id": "kal_9f2c…",
"amount": "10000.00",
"currency": "NGN",
"external_reference": "invoice_55213",
"source_currency": "NGN",
"source_amount": "10000.00",
"source_amount_usd": "6.53",
"created_at": "2026-08-26T12:34:02.000Z"
}
}201 means done — your balance is debited and the user's wallet is credited atomically. There is no pending state.Idempotency-Key is required. Repeat the identical request and you get 200 with the same payout_id and no second debit. The same key with different content is 409 IDEMPOTENCY_CONFLICT.amount is a string in destination-currency major units. Use a string, not a float, so 0.1 + 0.2 never becomes your payout. It may not carry more decimal places than the currency supports (400 INVALID_PAYOUT_AMOUNT if it does).source_currency and source_amount are what your balance was actually debited; source_amount_usd is the same charge valued in USD. All three are returned only here, on create. GET /api/v1/payouts/kuvarsend/{payoutId} does not repeat them — store them if you need them for your books.GET /api/v1/payouts/kuvarsend/{payoutId} — one receipt.GET /api/v1/payouts/kuvarsend?linked_account_id=…&limit=50&cursor=… — newest first, keyset pagination. Follow next_cursor until it is null.GET /api/v1/payouts/account-links?status=ACTIVE — your links, same pagination.GET /api/v1/payouts/account-links/{linkId} — one link.DELETE /api/v1/payouts/account-links/{linkId}, or await kv.revokeKuvarSendAccountLink('kal_9f2c…') with the SDK. Idempotent — revoking twice returns the link unchanged.account_link.revoked webhook is sent, and the next payout to that link fails with 410 LINKED_ACCOUNT_REVOKED before any money moves. Treat that code as "ask the user to re-link", not as a retryable error.| Term | Meaning |
|---|---|
amount | What you send, in the destination currency. This is the figure you name. |
fee_amount | The recipient fee, deducted from amount. The quote returns it, with fee_percent, before you commit. |
recipient_amount | amount − fee_amount — what actually lands in the user's wallet. |
source_currency | Your balance's currency — the one this payout debits. |
source_amount | What your balance is debited for this payout, in source_currency. |
source_amount_usd | The same charge valued in USD (create response only). |
10,000 NGN, the quoted fee is deducted from it, the user is credited recipient_amount, and your balance is debited source_amount. The fee comes out of the amount you name, never on top of your debit — so the quote tells you your exact cost before you commit. The figures in these examples are illustrative; use the ones your own quote returns.amount. If your balance is in USD, you are debited the USD cost at the quote's rate (destination units per USD). If it is another currency, that USD cost is converted into your balance's currency. Either way the quote's source_amount is the exact debit, so quote before you charge and you always know your cost up front.fee_percent and fee_amount from the quote rather than hard-coding a number, and contact support if you need different terms.422 PAYOUT_CURRENCY_UNAVAILABLE. Probe with the quote endpoint — it runs exactly the same validation as the payout — or ask support for the current list.422 PAYOUT_AMOUNT_TOO_SMALL when the amount is too small to price.kuvarsend.payout.completed — sent as soon as the payout completes, and retried until it is acknowledged.{
"event": "kuvarsend.payout.completed",
"timestamp": "2026-08-26T12:34:02.512Z",
"webhook_id": "…",
"data": {
"data": {
"schema_version": 1,
"event_id": "…",
"event_type": "kuvarsend.payout.completed",
"created_at": "2026-08-26T12:34:02.000Z",
"business_id": "…",
"environment": "LIVE",
"payout_id": "kwp_7c3e…",
"status": "COMPLETED",
"linked_account_id": "kal_9f2c…",
"amount": "10000.00",
"currency": "NGN",
"external_reference": "invoice_55213"
}
}
}Note the nesting. The event body is at body.data.data— the outerdatais the standard KuvarPay webhook envelope, the inner one is this event's payload. Readbody.data.data.payout_id, notbody.data.payout_id.
account_link.revoked — same envelope; the inner payload carries linked_account_id and status: "REVOKED" instead of the payout fields.| Header | Value |
|---|---|
X-KuvarPay-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your webhook's secret |
X-KuvarPay-Event | the event name |
X-KuvarPay-Webhook-Id | the webhook subscription id |
X-KuvarPay-Timestamp | ISO-8601 send time |
2xx to acknowledge. Anything else is retried with exponential backoff until it succeeds, so make your handler idempotent — deduplicate on event_id. Your endpoint must be reachable from the public internet and answer promptly. The webhook is a notification, not the source of truth: the payout was already complete when your API call returned.| Method | Path | Scope | Notes |
|---|---|---|---|
POST | /api/v1/payouts/account-link-requests | account_links:write | Idempotency-Key required · 30/min |
POST | /api/v1/payouts/account-links/exchange | account_links:write | code xor request_id · 30/min |
GET | /api/v1/payouts/account-links | account_links:read | status, limit, cursor |
GET | /api/v1/payouts/account-links/{linkId} | account_links:read | |
DELETE | /api/v1/payouts/account-links/{linkId} | account_links:write | idempotent |
POST | /api/v1/payouts/kuvarsend/quote | payouts:write | no money moves · 120/min |
POST | /api/v1/payouts/kuvarsend | payouts:write | Idempotency-Key required · 60/min |
GET | /api/v1/payouts/kuvarsend | payouts:read | linked_account_id, limit, cursor |
GET | /api/v1/payouts/kuvarsend/{payoutId} | payouts:read |
429.{ "success": false, "code": "LINKED_ACCOUNT_REVOKED", "error": "Linked account has been revoked." }code, never on the message text.| HTTP | code | What it means / what to do |
|---|---|---|
| 400 | INVALID_REQUEST | Body failed validation, or Idempotency-Key is missing where required. |
| 400 | INVALID_STATE | state is shorter than 22 characters or contains control characters. |
| 400 | INVALID_EXTERNAL_REFERENCE | Empty or longer than 255 characters. |
| 400 | INVALID_IDEMPOTENCY_KEY | Empty or longer than 255 characters. |
| 400 | INVALID_AUTHORIZATION_REQUEST | code_challenge_method is not S256, or scope is not payouts:receive. |
| 400 | REDIRECT_URI_NOT_REGISTERED | The URI is not registered, is revoked, or does not match byte for byte. |
| 400 | INVALID_PAYOUT_AMOUNT | More decimal places than the currency allows, or not positive. |
| 400 | INVALID_CURSOR | Pagination cursor is not a kwp_… id. |
| 401 | INVALID_AUTHORIZATION_CODE | Unknown/reused code, wrong business, wrong redirect URI, or the PKCE verifier does not match. Start over. |
| 403 | ACCOUNT_LINK_LIVE_ONLY | You used a sandbox key. Use the live secret key. |
| 403 | ACCOUNT_LINK_NOT_ALLOWED | A setup gate is missing (verification, KYC, payouts toggle, scope, key status) — or the KuvarSend account cannot be linked. Work through §3, then contact support. |
| 403 | PAYOUT_RECIPIENT_UNAVAILABLE | The KuvarSend account is not in a state to receive. No money moved. |
| 403 | (auth error, different shape) | Secret key missing/wrong type, or your IP is not on the payout allowlist. These come back as {"success": false, "error": {"code": "AUTH_004", …}}. |
| 404 | LINKED_ACCOUNT_NOT_FOUND | Unknown link, or it belongs to another business. |
| 404 | PAYOUT_NOT_FOUND | Unknown payout, or it belongs to another business. |
| 409 | IDEMPOTENCY_CONFLICT | That key was already used with different content. Use a new key. |
| 409 | AUTHORIZATION_NOT_READY | Cross-device polling: the user has not approved yet. Keep polling. |
| 409 | ACCOUNT_LINK_CONFLICT | An ACTIVE link already exists for this user. Revoke it first. |
| 410 | AUTHORIZATION_CODE_EXPIRED | Older than 2 minutes, or already exchanged. Start over. |
| 410 | LINKED_ACCOUNT_REVOKED | The link is dead. No money moved. Ask the user to re-link. |
| 422 | INSUFFICIENT_MERCHANT_BALANCE | Your balance is too low. Top up. |
| 422 | PAYOUT_CURRENCY_UNAVAILABLE | That currency cannot receive linked payouts. |
| 422 | PAYOUT_RATE_UNAVAILABLE | No usable rate for that currency right now. Retry shortly. |
| 422 | PAYOUT_AMOUNT_TOO_SMALL | Below the smallest amount we can price. |
| 429 | — | Rate limited. Back off and retry. |
409 IDEMPOTENCY_CONFLICT, 410, 403 and 404 are terminal — fix the cause, do not retry blindly. 422 PAYOUT_RATE_UNAVAILABLE, 429 and network timeouts are safe to retry with the same Idempotency-Key: that is exactly what it is for.state and code_verifier stored server-side per user session and compared on the way back.Idempotency-Key per payout — derived from your own order/invoice id, and persisted before the call so a crash-and-retry reuses it.kuvarsend.payout.completed and account_link.revoked — and reading body.data.data.410 LINKED_ACCOUNT_REVOKED handled as "re-link", not as a failure to retry.alr_… / kal_… / kwp_… id involved, and the error code — never your API key.