# Overview Source: https://deriapi.site/docs Deripay lets your Deriv site take mobile money deposits and pay out mobile money withdrawals. Your customer pays from their phone and the money lands in their Deriv account. They withdraw from Deriv and the money arrives on their phone. You call one REST API from your server. Deripay handles the payment agent transfer on Deriv, the mobile money collection and the payout. ## Markets | Country | Code | Currency | Mobile money | | --- | --- | --- | --- | | Kenya | `KE` | `KES` | M-Pesa (STK push) | | Tanzania | `TZ` | `TZS` | Several networks, chosen per transaction | | Uganda | `UG` | `UGX` | Several networks, chosen per transaction | Which markets are open can change without a release on your side. Always read the live list from `GET /markets` instead of hardcoding it. See [Markets and rates](/docs/markets-and-rates). Deposit amounts are whole units of the local currency. Withdrawal amounts are in USD. Crypto deposits are not offered. ## How it fits together 1. **Deriv app.** You register your own Deriv OAuth app on Deriv and add its client ID to your Deripay dashboard. 2. **Connection.** Your customer signs in with Deriv through your app. Your backend exchanges the code for a Deriv access token (or takes the customer's personal access token) and sends it to `POST /connections`. Deripay returns a connection id such as `con_6720f1a2b3c4d5e6f7a8`. You store it against your customer. 3. **Deposit or withdrawal.** Your backend calls `POST /deposits` or `POST /withdrawals` with the connection id. Deripay returns a transaction such as `txn_6720f9e8d7c6b5a4f3e2`. 4. **Customer step.** For a deposit the customer approves the payment on their phone (an M-Pesa prompt in Kenya, or an OTP, redirect or USSD step elsewhere). For a withdrawal the customer receives a code from Deriv and you submit it. 5. **Result.** Deripay sends a signed webhook to your server as the transaction moves. You can also poll `GET /transactions/{id}` or relay a live event stream. You earn 22% of the exchange margin on every completed deposit and withdrawal made through your connections. ## Base URL ```text https://deriapi.site/api/v2 ``` Every request is made from your server with a secret key in `Authorization: Bearer dpk_live_…`. There is no separate sandbox: every deposit and withdrawal moves real money. ## Where to go next - [Quickstart](/docs/quickstart): from a new account to a first deposit. - [Authentication](/docs/authentication): keys, scopes, rate limits and the response envelope. - [Deriv OAuth](/docs/deriv-oauth): connect a customer's Deriv account. - [Connections](/docs/connections), [Markets and rates](/docs/markets-and-rates), [Deposits](/docs/deposits), [Withdrawals](/docs/withdrawals), [Transactions](/docs/transactions). - [Webhooks](/docs/webhooks): signed events and how to verify them. - [Errors](/docs/errors) and [Idempotency](/docs/idempotency). - [Go-live checklist](/docs/go-live) and [Examples](/docs/examples). - [Using Deripay with AI agents](/docs/ai): the MCP server, `/llms.txt` and `/openapi.json`. - [Changelog](/docs/changelog). --- # Quickstart Source: https://deriapi.site/docs/quickstart This guide takes you from a new account to a first deposit. Every call is made from your server. There is no sandbox, so use a small amount on an account you control. ## 1. Create an account Register at [deriapi.site/register](https://deriapi.site/register) and sign in to the dashboard. ## 2. Register your Deriv app Create an OAuth app on [developers.deriv.com](https://developers.deriv.com) with the scopes `trade account_manage payment` and an `https` redirect URI on your site. Deriv gives you a client ID made of letters and digits. In the Deripay dashboard, open **Deriv apps** and add that client ID, your redirect URI and your site domain. Legacy numeric Deriv App IDs are rejected. Details are in [Deriv OAuth](/docs/deriv-oauth). ## 3. Create an API key Open **API keys**, create a key and tick the scopes you need. For this guide tick all four: `connections`, `deposits`, `withdrawals` and `read`. The secret is shown once. Store it in your server environment: ```bash export DERIPAY_API_KEY="dpk_live_…" ``` Never put the key in browser code, a mobile app or a public repository. ## 4. Check the key ```bash curl https://deriapi.site/api/v2/ping \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` ```json { "success": true, "data": { "ok": true, "developer_id": "6720a0b1c2d3e4f5a6b7", "key_prefix": "dpk_live_1a2b3c4d5e6f", "api_version": "v2", "deripay_reachable": true, "time": "2026-11-03T09:00:00.000Z" } } ``` ## 5. Connect a customer Run the Deriv OAuth flow on your backend (see [Deriv OAuth](/docs/deriv-oauth)). When you have the customer's Deriv access token, create a connection. A personal access token with the same scopes works too: ```bash curl https://deriapi.site/api/v2/connections \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_DERIV_CLIENT_ID", "deriv_access_token": "DERIV_ACCESS_TOKEN", "token_expires_in": 3600, "external_customer_id": "user_1842" }' ``` Save `data.id` (for example `con_6720f1a2b3c4d5e6f7a8`) against your customer. ## 6. Check the market and limits ```bash curl "https://deriapi.site/api/v2/rates?country=KE" \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` Use an amount between `data.deposit_limits.min` and `data.deposit_limits.max`. If `data.deposits_available` is `false`, deposits are paused in that market. ## 7. Make a first deposit ```bash curl https://deriapi.site/api/v2/deposits \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: dep_user_1842_order_0001" \ -d '{ "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "KE", "amount": 500, "phone_number": "0712345678" }' ``` The response is a transaction with `state: "pending"`. In Kenya the customer gets an M-Pesa prompt on their phone. When they approve it, the transaction moves to `processing` and then `completed`. Check it at any time: ```bash curl https://deriapi.site/api/v2/transactions/txn_6720f9e8d7c6b5a4f3e2 \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` ## 8. Receive the webhook Open **Webhooks** in the dashboard, add an `https` URL on your server and copy the signing secret: ```bash export DERIPAY_WEBHOOK_SECRET="whsec_…" ``` Press **Send test event** to check your endpoint. Then verify every delivery as shown in [Webhooks](/docs/webhooks). When the deposit finishes you receive `transaction.updated` and `transaction.completed`. ## Next steps - Handle customer actions outside Kenya: [Deposits](/docs/deposits). - Add withdrawals: [Withdrawals](/docs/withdrawals). - Work through the [Go-live checklist](/docs/go-live). --- # Authentication Source: https://deriapi.site/docs/authentication ## API keys Every request to `https://deriapi.site/api/v2` needs a secret API key. Create keys under **API keys** in the dashboard. A key looks like this: ```text dpk_live_1a2b3c4d5e6f_<48 hex characters> ``` The part up to the second underscore (`dpk_live_1a2b3c4d5e6f`) is the public prefix. The dashboard shows it so you can tell keys apart. The full secret is shown once, when you create the key. Deripay stores only a hash of it, so a lost secret cannot be recovered. Create a new key and revoke the old one. Send the key in the `Authorization` header: ```http GET /api/v2/ping HTTP/1.1 Host: deriapi.site Authorization: Bearer dpk_live_1a2b3c4d5e6f_… ``` The `X-API-Key: dpk_live_…` header is also accepted. Prefer `Authorization: Bearer`. All keys are live keys. There is no test mode. ### Keep the key on your server The key can start deposits and withdrawals for every customer you have connected. Treat it like a database password: - Call Deripay only from your backend. Your website or mobile app calls your backend. - Never ship the key in browser JavaScript, a mobile app binary, a public repository, logs or screenshots. - Store it in a secrets manager or a server environment variable such as `DERIPAY_API_KEY`. - If a key leaks, revoke it in the dashboard at once and create a new one. ## Scopes Each key has one or more scopes. A request with a key that lacks the scope fails with `403 AUTH_INSUFFICIENT_SCOPE`. | Scope | Allows | | --- | --- | | `connections` | `POST /connections`, `PATCH /connections/{id}/default-phone` | | `deposits` | `POST /deposits`, `POST /deposits/{id}/otp` | | `withdrawals` | `POST /withdrawals`, `POST /withdrawals/{id}/confirm`, `POST /withdrawals/{id}/resend` | | `read` | `GET /connections`, `GET /connections/{id}`, `GET /connections/{id}/balance`, `GET /transactions`, `GET /transactions/{id}`, `GET /transactions/{id}/events` | `GET /ping`, `GET /markets` and `GET /rates` work with any valid key. Give each server only the scopes it needs. For example, a reporting job needs only `read`. ## IP allowlist A key can be limited to a list of IP addresses (IPv4 or IPv6, up to 20). Set it when you create the key or later from the key's row in the dashboard. An empty list allows any IP. A request from an address not on the list fails with `403 AUTH_IP_NOT_ALLOWED`. The message names the IP Deripay saw, which helps when your server sits behind a NAT or proxy. ## Rate limits Limits apply per key, per route, over a 60 second window. When you go over, the response is `429 RATE_LIMITED` with a `Retry-After` header in seconds. | Endpoint | Requests per minute | | --- | --- | | `POST /connections` | 30 | | `POST /deposits` | 30 | | `POST /deposits/{id}/otp` | 30 | | `POST /withdrawals` | 20 | | `POST /withdrawals/{id}/confirm` | 20 | | `POST /withdrawals/{id}/resend` | 10 | | `GET /transactions/{id}/events` | 60 | | Every other endpoint | 120 | Routes with an id in the path are counted per path, so polling two different transactions uses two separate budgets. Some payment network checks also return `429`, for example `WITHDRAWAL_COOLDOWN`. Those also carry `Retry-After`. See [Errors](/docs/errors). ## Response envelope Every JSON response has the same shape. Success: ```json { "success": true, "data": { } } ``` Error: ```json { "success": false, "error": { "code": "INVALID_REQUEST", "message": "Some fields are missing or invalid.", "details": [{ "field": "amount", "message": "Whole local currency units only" }], "request_id": "req_9f1c2b3a4d5e6f70" } } ``` - Branch on `error.code`. It is stable and machine readable. - Show or log `error.message` for people. Do not parse it. - `error.details` is present only on some errors. For `INVALID_REQUEST` it lists each failing field. - `error.request_id` identifies the request. Quote it when you contact support. The full list of codes is in [Errors](/docs/errors). ## Response headers | Header | Value | | --- | --- | | `X-Request-Id` | The request id, for example `req_9f1c2b3a4d5e6f70`. Sent on every response, including successes. Log it. | | `Deripay-Version` | `v2` on JSON responses. | | `Cache-Control` | `no-store`. | | `Retry-After` | Seconds to wait. Sent with `429` responses and some others that ask you to wait. | | `Idempotent-Replayed` | `true` when a deposit or withdrawal request was a replay of an earlier one. See [Idempotency](/docs/idempotency). | ## Requests - Send JSON bodies with `Content-Type: application/json`. A body that is not valid JSON fails with `400 INVALID_REQUEST`. - Field names are `snake_case`. - Ids are prefixed: `con_` for connections and `txn_` for transactions. - Timestamps are ISO 8601 in UTC. --- # Deriv OAuth Source: https://deriapi.site/docs/deriv-oauth Deripay never asks your customers for their Deriv password and never issues its own customer tokens. Your customer signs in to Deriv through **your** Deriv OAuth app. Your backend gets a short-lived Deriv access token and hands it to Deripay once, in `POST /connections`. Deripay returns a connection id that you use from then on. Customers who prefer to paste a Deriv personal access token (PAT) can connect that way too. See [Personal access tokens](#personal-access-tokens). What belongs to whom: | Step | Owned by | | --- | --- | | OAuth app, client ID, redirect URI, sign-in page, token endpoint | Deriv | | `state`, PKCE `code_verifier`, callback handler, token exchange | Your backend | | `POST /api/v2/connections` and the `con_…` id | Deripay | ## 1. Register the app on Deriv Create an OAuth app on [developers.deriv.com](https://developers.deriv.com). - **Scopes:** `trade account_manage payment`. All three are required. `trade` lets Deripay list the customer's accounts, `account_manage` resolves the customer's payment agent transfer name, and `payment` allows balance reads and withdrawals. - **Redirect URI:** an `https` URL on your site, for example `https://yoursite.com/api/deriv/callback`. Deriv gives you a **client ID** made of letters and digits. Deripay needs this new OAuth client ID. Old numeric Deriv App IDs are rejected. If you add a scope later, customers who connected before must connect again. Existing tokens do not gain new permissions. ## 2. Add the app to Deripay In the Deripay dashboard, open **Deriv apps** and add: - **Client ID**: the Deriv OAuth client ID. - **Redirect URI**: the same callback URL you registered on Deriv. It must use `https`. - **Site domain**: shown against this app's transactions. A client ID can belong to one Deripay account only. `POST /connections` with a client ID that is not registered and active on your account fails with `400 DERIV_APP_NOT_REGISTERED`. ## 3. Run the flow on your backend Deriv uses OAuth 2.0 Authorization Code with PKCE: - Authorization endpoint: `https://auth.deriv.com/oauth2/auth` - Token endpoint: `https://auth.deriv.com/oauth2/token` For the exact parameters Deriv accepts, and whether your app also has a client secret, see [developers.deriv.com/docs/intro/oauth](https://developers.deriv.com/docs/intro/oauth/). The example below uses the standard OAuth 2.0 parameter names. Rules: - Create a fresh `state` and `code_verifier` for every sign-in attempt. - Use `code_challenge_method=S256`. - Check `state` before you exchange the code. - `redirect_uri` must match the registered URI exactly. - Keep the access token in memory only long enough to call `POST /connections`. Do not store it, log it or send it to the browser. ### Node example (Next.js route handlers) `app/api/deriv/login/route.ts` starts the flow: ```ts import crypto from "node:crypto"; const CLIENT_ID = process.env.DERIV_CLIENT_ID!; const REDIRECT_URI = process.env.DERIV_REDIRECT_URI!; // https://yoursite.com/api/deriv/callback const b64url = (buf: Buffer) => buf.toString("base64url"); export async function GET() { const state = b64url(crypto.randomBytes(24)); const verifier = b64url(crypto.randomBytes(48)); const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); const url = new URL("https://auth.deriv.com/oauth2/auth"); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", "trade account_manage payment"); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); const cookie = (name: string, value: string) => `${name}=${value}; Path=/api/deriv; HttpOnly; Secure; SameSite=Lax; Max-Age=600`; const headers = new Headers({ Location: url.toString() }); headers.append("Set-Cookie", cookie("deriv_state", state)); headers.append("Set-Cookie", cookie("deriv_verifier", verifier)); return new Response(null, { status: 302, headers }); } ``` `app/api/deriv/callback/route.ts` finishes it and creates the connection: ```ts const CLIENT_ID = process.env.DERIV_CLIENT_ID!; const REDIRECT_URI = process.env.DERIV_REDIRECT_URI!; const DERIPAY = "https://deriapi.site/api/v2"; function readCookie(request: Request, name: string): string | null { const raw = request.headers.get("cookie") || ""; for (const part of raw.split(";")) { const [k, ...v] = part.trim().split("="); if (k === name) return v.join("="); } return null; } export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const expectedState = readCookie(request, "deriv_state"); const verifier = readCookie(request, "deriv_verifier"); if (!code || !state || !expectedState || state !== expectedState || !verifier) { return new Response("Sign-in expired. Please try again.", { status: 400 }); } // 1. Exchange the code with Deriv. const tokenRes = await fetch("https://auth.deriv.com/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }), }); if (!tokenRes.ok) return new Response("Deriv sign-in failed.", { status: 502 }); const token = (await tokenRes.json()) as { access_token: string; expires_in?: number }; // 2. Hand the token to Deripay. const userId = await currentUserId(request); // your own session lookup const res = await fetch(`${DERIPAY}/connections`, { method: "POST", headers: { Authorization: `Bearer ${process.env.DERIPAY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ client_id: CLIENT_ID, deriv_access_token: token.access_token, token_expires_in: token.expires_in, external_customer_id: userId, }), }); const body = await res.json(); if (!body.success) { return new Response(`Could not connect: ${body.error.code}`, { status: 400 }); } // 3. Store the connection id against your user. await saveConnectionId(userId, body.data.id); // e.g. con_6720f1a2b3c4d5e6f7a8 const headers = new Headers({ Location: "/cashier" }); headers.append("Set-Cookie", "deriv_state=; Path=/api/deriv; Max-Age=0"); headers.append("Set-Cookie", "deriv_verifier=; Path=/api/deriv; Max-Age=0"); return new Response(null, { status: 302, headers }); } // Replace these with your own user store. declare function currentUserId(request: Request): Promise; declare function saveConnectionId(userId: string, connectionId: string): Promise; ``` ## 4. Create the connection ```http POST /api/v2/connections Authorization: Bearer dpk_live_… Content-Type: application/json ``` ```json { "client_id": "YOUR_DERIV_CLIENT_ID", "deriv_access_token": "DERIV_ACCESS_TOKEN", "token_expires_in": 3600, "external_customer_id": "user_1842", "preferred_account_id": "CR1234567" } ``` | Field | Required | Notes | | --- | --- | --- | | `client_id` | yes | Your Deriv OAuth client ID, registered under **Deriv apps**. 6 to 64 characters. | | `deriv_access_token` | yes | The access token from the token exchange, or the customer's personal access token. It must carry `trade account_manage payment`. | | `token_expires_in` | no | `expires_in` from Deriv's token response, in seconds. Recorded for reference only. Leave it out for a personal access token. | | `external_customer_id` | no | Your own id for this customer, up to 128 characters. Lets you find the connection later. | | `preferred_account_id` | no | The Deriv login id to use by default, for example `CR1234567`. Must be a real USD account. | The customer needs at least one real USD Deriv account. Demo and non-USD accounts cannot move money. The response is a [connection](/docs/connections). ## Personal access tokens A customer can also connect with a Deriv personal access token (PAT) instead of signing in through OAuth. This suits sites that offer a "log in with token" option. 1. The customer creates a PAT in their Deriv account with the scopes `trade account_manage payment`. A PAT without `payment` cannot move money. 2. They paste it into your site, and your backend sends it to `POST /connections` as `deriv_access_token`, together with your registered `client_id`. Leave out `token_expires_in`. 3. From then on the connection works exactly like one made through OAuth: deposits, withdrawals and balance reads all use the `con_…` id. Handle a PAT like a password: - Send it straight from your backend to Deripay. Do not log it, keep it in browser storage or put it in a URL. - Do not store it after `POST /connections` returns. - Your Deriv app still has to be registered under **Deriv apps**, or the call fails with `DERIV_APP_NOT_REGISTERED`. When a PAT stops working (the customer revoked it, or it lacks a scope), the reconnect errors below apply. Ask the customer for a new PAT with the right scopes and call `POST /connections` again. ## Reconnecting Deriv access tokens are short-lived and Deriv does not return a refresh token. Deripay keeps using the token until Deriv itself rejects it. Then: - calls that need the customer's Deriv authorization fail with `DERIV_RECONNECT_REQUIRED` (or a prefixed form such as `DEPOSIT_DERIV_RECONNECT_REQUIRED`), and - `GET /connections/{id}?refresh=true` shows `authorization_status: "reconnect_required"`. Run the OAuth flow again (or ask for a new personal access token) and call `POST /connections` with the new token. Keep the customer signed in to your own site while they reconnect. Do not decide to reconnect by comparing `token_expires_in` with the clock. Also reconnect on `DERIV_AUTH_FAILED`, `DERIV_ACCOUNT_NOT_ELIGIBLE` and `*_SESSION_APP_MISMATCH`. Do not reconnect on `DERIV_REFRESH_UNAVAILABLE`. That one is temporary: retry with backoff. See [Errors](/docs/errors). --- # Webhooks Source: https://deriapi.site/docs/webhooks Deripay sends an HTTP `POST` to your server whenever a transaction changes. Webhooks are the reliable way to learn that a deposit or withdrawal finished, even if the customer closed the page. ## Set up an endpoint In the dashboard, open **Webhooks** and add an endpoint. - The URL must use `https` and be reachable from the internet. `localhost` and private network addresses are refused. - You can have up to 10 endpoints. - Choose the events to send. By default an endpoint gets all four. - The signing secret (`whsec_…`) is shown when you create the endpoint or rotate its secret. Store it as `DERIPAY_WEBHOOK_SECRET`. ## Events | Event | Sent when | | --- | --- | | `transaction.updated` | Any status change. | | `transaction.action_required` | A deposit is waiting on the customer (OTP, redirect or USSD). | | `transaction.completed` | A deposit or withdrawal finished successfully. | | `transaction.failed` | A deposit or withdrawal failed for good. | A change that reaches `action_required`, `completed` or `failed` sends two events: `transaction.updated` and the specific one. Each has its own event id. Subscribe to the ones you need. Test events from the dashboard use the type `ping`. ## Payload ```http POST /webhooks/deripay HTTP/1.1 Content-Type: application/json User-Agent: Deripay-Webhooks/2 Deripay-Signature: t=1762160472,v1=5f2b6c0e3a… Deripay-Event: transaction.completed Deripay-Event-Id: evt_3c1f9a8b7e6d5c4b3a29 ``` ```json { "id": "evt_3c1f9a8b7e6d5c4b3a29", "type": "transaction.completed", "created_at": "2026-11-03T09:01:12.000Z", "data": { "id": "txn_6720f9e8d7c6b5a4f3e2", "object": "transaction", "type": "deposit", "state": "completed", "status": "DEPOSIT_COMPLETED", "status_message": "Deposit completed", "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "KE", "currency": "KES", "operator": "mpesa", "amount": 1000, "amount_usd": 7.52, "phone_number": "2547•••678", "customer_action": null, "terminal": true, "created_at": "2026-11-03T09:00:00.000Z", "updated_at": "2026-11-03T09:01:12.000Z" } } ``` | Field | Description | | --- | --- | | `id` | Event id, `evt_…`. Same as the `Deripay-Event-Id` header. | | `type` | Event type. Same as the `Deripay-Event` header. | | `created_at` | When the event was created. | | `data` | For `transaction.*` events, the full [transaction object](/docs/transactions). For `ping`, `{"message": "..."}`. | ## Verify the signature The `Deripay-Signature` header looks like `t=,v1=`. `v1` is the HMAC-SHA256 of the string `"."`, keyed with your endpoint secret. To verify: 1. Read the **raw** request body as bytes. Do not parse and re-serialise the JSON first. 2. Split the header on `,` and read `t` and `v1`. 3. Reject the request if `t` is more than 5 minutes from your clock. 4. Compute `HMAC-SHA256(secret, t + "." + raw_body)` as lowercase hex. 5. Compare it with `v1` in constant time. Reply `401` if verification fails. ### Node ```ts import crypto from "node:crypto"; export function verifyDeripaySignature( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300, ): boolean { if (!header) return false; const parts: Record = {}; for (const item of header.split(",")) { const i = item.indexOf("="); if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim(); } const t = parts.t; const v1 = parts.v1; if (!t || !/^\d+$/.test(t) || !v1) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); const given = Buffer.from(v1, "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); } ``` In a Next.js route handler, read the body with `await request.text()` and pass that string. In Express, use `express.raw({ type: "application/json" })` and pass `req.body.toString("utf8")`. ### PHP ```php $tolerance) { return false; } $expected = hash_hmac('sha256', $parts['t'] . '.' . $payload, $secret); return hash_equals($expected, $parts['v1']); } $payload = file_get_contents('php://input'); $header = $_SERVER['HTTP_DERIPAY_SIGNATURE'] ?? ''; if (!deripay_verify($payload, $header, getenv('DERIPAY_WEBHOOK_SECRET'))) { http_response_code(401); exit; } $event = json_decode($payload, true); http_response_code(200); ``` ### Python ```python import hashlib import hmac import time def verify_deripay_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = {} for item in (header or "").split(","): key, sep, value = item.strip().partition("=") if sep: parts[key] = value t, v1 = parts.get("t"), parts.get("v1") if not t or not t.isdigit() or not v1: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) ``` With Flask, pass `request.get_data()` as `payload` and `request.headers.get("Deripay-Signature", "")` as `header`. ## Handle events safely - **Answer fast.** Reply with any `2xx` within 10 seconds. Do slow work after you reply, for example in a queue. - **Deduplicate by event id.** Store `Deripay-Event-Id` and skip events you have already processed. The same event can arrive more than once. - **Do not rely on order.** Events can arrive out of order. Use `data.updated_at`, or fetch `GET /transactions/{id}` for the current state, before you change a balance in your own system. - **Credit once.** When you show "deposit complete" or update your own ledger, key it on the transaction id so a repeat cannot double it. - **Check the transaction is yours.** Look up `data.id` and `data.connection_id` in your own records. ## Retries and failures - Any response outside `2xx`, a redirect, a network error or no answer within 10 seconds counts as a failure. - A failed delivery is retried once straight away. - After 25 consecutive failed deliveries the endpoint is disabled. Fix it, then enable it again in the dashboard. That resets the failure count. Because retries are short, do not depend on webhooks alone. Your cashier screen should also poll or stream, and a nightly job can list non-final transactions with `GET /transactions?state=processing` and fetch each one. ## Test and redeliver From the **Webhooks** page in the dashboard you can: - **Send a test event**: a signed `ping` event to check your URL and signature code. - **See deliveries**: each attempt with its status code, response body and time taken. - **Redeliver**: send a past event again with the same event id and payload and a fresh signature. - **Rotate the secret**: issue a new `whsec_…`. The old secret stops working at once, so update your server straight after. --- # Idempotency Source: https://deriapi.site/docs/idempotency A network timeout does not tell you whether a deposit or withdrawal was created. Idempotency keys let you retry without risk: the same key always means the same transaction. ## Where keys are required | Endpoint | Key | | --- | --- | | `POST /deposits` | Required | | `POST /withdrawals` | Required | | `POST /deposits/{id}/otp` | Not used. It acts on an existing transaction. | | `POST /withdrawals/{id}/confirm` | Not used. Fetch the transaction before retrying. | | `POST /withdrawals/{id}/resend` | Not used. | A required key that is missing fails with `400 IDEMPOTENCY_KEY_REQUIRED`. ## Sending the key Send it in the `Idempotency-Key` header: ```http POST /api/v2/deposits Idempotency-Key: dep_user_1842_order_0001 ``` If you cannot set headers, send `idempotency_key` in the JSON body instead. When both are present, the header wins. Format: 8 to 120 characters of letters, digits, `.`, `_`, `:` and `-`. Anything else fails with `400 INVALID_REQUEST`. ## Choosing keys Use one key per logical attempt and save it before you send the request. Build it from your own ids so a retry naturally reuses it: ```text dep__ wd__ ``` A random UUID also works, as long as you store it and reuse it on retries. Keys are scoped to your Deripay account. ## What happens on a repeat | You send | Result | | --- | --- | | Same key, same request | `200`, the same transaction (with its latest state), and the header `Idempotent-Replayed: true`. Nothing new is created. | | Same key, different request | `409 IDEMPOTENCY_KEY_REUSED`. | | New key | A new transaction. | "Same request" means the same type (deposit or withdrawal), connection, country, amount, phone number and operator. Phone numbers are compared after they are normalised, so `0712345678` and `254712345678` count as the same number. The first successful request returns `201`. A replay returns `200`. Check `Idempotent-Replayed` if you need to tell them apart. ## Timeouts and 5xx errors If a create call times out, or returns `502 UPSTREAM_UNAVAILABLE` or another `5xx`, the request may still have landed. Retry with the **same** key and the same body, with backoff: ```ts async function createDepositWithRetry(body: object, idempotencyKey: string) { for (let attempt = 0; attempt < 5; attempt++) { try { const res = await fetch("https://deriapi.site/api/v2/deposits", { method: "POST", headers: { Authorization: `Bearer ${process.env.DERIPAY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), signal: AbortSignal.timeout(60_000), }); const json = await res.json(); if (json.success) return json.data; const retryable = res.status === 429 || res.status >= 500; if (!retryable) throw new Error(json.error.code); const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt; await new Promise((r) => setTimeout(r, wait * 1000)); } catch (err) { if (err instanceof Error && !/abort|timeout|fetch failed/i.test(err.message)) throw err; await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); } } throw new Error("Deposit not confirmed yet; retry later with the same key"); } ``` While Deripay waits for the network to confirm, the transaction can show `status: "SUBMISSION_PENDING"` (`state: "pending"`). The retry picks it up. Never switch to a new key because a request timed out. That can charge the customer twice. ## Refused requests When the payment network refuses a request outright with a `4xx`, for example `WITHDRAWAL_ALREADY_ACTIVE` or `WITHDRAWAL_COOLDOWN`, nothing was created and the key is released. You can send the same logical request again later with the same key. It is treated as a new attempt. Validation errors from the gateway (`INVALID_REQUEST`, `AMOUNT_OUT_OF_RANGE`, `INVALID_PHONE_NUMBER`, …) are checked before anything is recorded, so they also do not use up the key. --- # Go-live checklist Source: https://deriapi.site/docs/go-live There is no sandbox. Every deposit and withdrawal moves real money. Run these checks with your own Deriv account and small amounts before you open the cashier to customers. ## Keys and secrets - [ ] The API key lives only on your server, in a secrets manager or server environment variable (`DERIPAY_API_KEY`). - [ ] No key appears in browser code, mobile apps, repositories, logs, analytics or screenshots. - [ ] Each server has a key with only the scopes it needs. - [ ] Keys used from fixed servers have an IP allowlist. - [ ] The webhook secret (`DERIPAY_WEBHOOK_SECRET`) is stored the same way. - [ ] Your logs redact Deriv access tokens, API keys, withdrawal codes, OTPs and connection ids. - [ ] You know how to revoke a key and rotate a webhook secret in the dashboard. ## Deriv connection - [ ] Your Deriv OAuth app uses the new client ID (not a numeric App ID) and is added under **Deriv apps**. - [ ] It requests `trade account_manage payment`. - [ ] The redirect URI is `https` and matches Deriv and Deripay exactly. - [ ] Your callback checks `state` and uses PKCE with `S256`. - [ ] You never store the Deriv access token after `POST /connections`. - [ ] You store the `con_…` id against your customer, server side. - [ ] You handle `DERIV_RECONNECT_REQUIRED` and its prefixed forms by sending the customer through OAuth again, and retry `DERIV_REFRESH_UNAVAILABLE` instead. ## Deposits - [ ] You read `GET /markets` and offer only the markets and operators it returns. - [ ] You read `GET /rates?country=…` when the form opens and enforce `deposit_limits` and `deposits_available`. - [ ] Amounts are whole numbers in the local currency, labelled with the market's `currency`. - [ ] Every deposit sends an idempotency key you saved first, and retries reuse it. - [ ] You handle `customer_action` steps `otp`, `redirect`, `ussd` and `unsupported` in Tanzania and Uganda. - [ ] One test deposit per market you launch reached `completed` and the Deriv account was credited once. - [ ] Replaying that request with the same key returned the same transaction with `Idempotent-Replayed: true`. ## Withdrawals - [ ] You check `withdraw_limits_usd` and the main wallet `balance` before you offer an amount. - [ ] You read the payout number back to the customer. - [ ] You save the transaction id before asking for the Deriv code, and confirm only that transaction. - [ ] You offer **Resend code** while the state is `awaiting_code`. - [ ] You handle `WITHDRAWAL_ALREADY_ACTIVE`, `WITHDRAWAL_COOLDOWN` and `WITHDRAWAL_VELOCITY_LIMIT`, honouring `Retry-After`. - [ ] You show success only at `completed`, and treat `WITHDRAWAL_B2C_FAILED` as still processing. - [ ] One test withdrawal per market reached `completed` and the money arrived on the phone. ## Status and webhooks - [ ] A webhook endpoint is set up on `https` and the test event passes. - [ ] You verify `Deripay-Signature` on the raw body and reject timestamps older than 5 minutes. - [ ] You deduplicate events by `Deripay-Event-Id` and reply `2xx` within 10 seconds. - [ ] Your cashier screen also polls or relays the event stream from your server, never from the browser with the key. - [ ] Polling stops at `terminal: true` and backs off on `429`, `502` and `503`. - [ ] A job alerts you about transactions that stay in `processing` too long. It never starts a replacement transaction. ## Errors and support - [ ] Your code branches on `error.code`, not on `error.message`. - [ ] You log `X-Request-Id` for every call. - [ ] Customers can see their pending transactions after they leave and come back. - [ ] Your support team knows where to find a transaction by id in the dashboard. --- # Connections Source: https://deriapi.site/docs/connections A connection links one of your customers' Deriv accounts to Deripay through your Deriv app, using either a Deriv OAuth access token or the customer's personal access token. Every deposit and withdrawal names a connection. Store the connection id on your backend against your own customer record. Treat it as sensitive: do not put it in URLs, browser storage or analytics. ## The connection object ```json { "id": "con_6720f1a2b3c4d5e6f7a8", "object": "connection", "client_id": "YOUR_DERIV_CLIENT_ID", "external_customer_id": "user_1842", "deriv_login_id": "CR1234567", "accounts": [ { "login_id": "CR1234567", "currency": "USD", "is_virtual": false } ], "authorization_status": "active", "default_phone_number": "254712345678", "created_at": "2026-11-03T09:00:00.000Z", "last_authorized_at": "2026-11-03T09:00:00.000Z" } ``` | Field | Type | Description | | --- | --- | --- | | `id` | string | Connection id, `con_…`. | | `object` | string | Always `connection`. | | `client_id` | string | The Deriv OAuth client ID the customer connected through. | | `external_customer_id` | string or null | Your id for the customer, as you sent it. | | `deriv_login_id` | string or null | The selected Deriv login id, or the primary one. | | `accounts` | array | Deriv accounts returned at connect time, each with `login_id`, `currency` and `is_virtual`. At most 20. | | `authorization_status` | string | `active`, or `reconnect_required` once Deriv has rejected the token. | | `default_phone_number` | string or null | Saved default mobile number, if any. | | `created_at` | string | When the connection was first created. | | `last_authorized_at` | string or null | When `POST /connections` last succeeded for it. | ## Create or update a connection ```http POST /api/v2/connections ``` Scope: `connections`. Rate limit: 30 per minute. ```bash curl https://deriapi.site/api/v2/connections \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_id": "YOUR_DERIV_CLIENT_ID", "deriv_access_token": "DERIV_ACCESS_TOKEN", "token_expires_in": 3600, "external_customer_id": "user_1842", "preferred_account_id": "CR1234567" }' ``` | Field | Required | Type | Description | | --- | --- | --- | --- | | `client_id` | yes | string, 6-64 | Your Deriv OAuth client ID, registered under **Deriv apps**. | | `deriv_access_token` | yes | string, 10-4000 | Deriv OAuth access token or personal access token, with scopes `trade account_manage payment`. | | `token_expires_in` | no | positive integer | `expires_in` from Deriv's token response. | | `external_customer_id` | no | string, 1-128 | Your own id for the customer. | | `preferred_account_id` | no | string, 2-32 | Deriv login id to use by default. Must be a real USD account. | Returns `201` with the connection object. **Upsert.** Calling it again for the same Deriv account through the same client ID updates the existing connection and returns the same `con_…` id. Use this to reconnect after `DERIV_RECONNECT_REQUIRED` or to change the preferred account. A different client ID can produce a separate connection. Always store the id from the latest successful response. Errors you may see: `DERIV_APP_NOT_REGISTERED`, `DERIV_AUTH_FAILED`, `DERIV_ACCOUNT_MISMATCH`, `DERIV_ACCOUNT_NOT_ELIGIBLE`. See [Errors](/docs/errors). ## Get a connection ```http GET /api/v2/connections/{id} ``` Scope: `read`. Returns the stored connection. Add `?refresh=true` to re-read it from Deripay's payment network first, which updates `accounts`, `deriv_login_id`, `authorization_status` and `default_phone_number`. ```bash curl "https://deriapi.site/api/v2/connections/con_6720f1a2b3c4d5e6f7a8?refresh=true" \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` An id that does not exist or belongs to another Deripay account returns `404 CONNECTION_NOT_FOUND`. ## List connections ```http GET /api/v2/connections ``` Scope: `read`. | Query | Description | | --- | --- | | `external_customer_id` | Only connections with this exact `external_customer_id`. | | `limit` | 1 to 100. Default 25. | | `cursor` | A `con_…` id. Returns the page after it. Use `next_cursor` from the previous page. | Results are newest first. ```bash curl "https://deriapi.site/api/v2/connections?external_customer_id=user_1842" \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` ```json { "success": true, "data": { "items": [{ "id": "con_6720f1a2b3c4d5e6f7a8", "object": "connection", "...": "..." }], "total": 1, "next_cursor": null } } ``` `total` is the number of matching connections. `next_cursor` is `null` when there are no more pages. ## Get the Deriv balance ```http GET /api/v2/connections/{id}/balance ``` Scope: `read`. ```json { "success": true, "data": { "connection_id": "con_6720f1a2b3c4d5e6f7a8", "login_id": "CR1234567", "balance": 25.5, "currency": "USD" } } ``` `balance` is the spendable USD balance in the customer's main Deriv wallet. Withdrawals are paid from that wallet, so check it before you offer a withdrawal amount. A token without the `payment` scope fails with `DERIV_AUTH_FAILED`; ask the customer to reconnect. ## Set the default phone number ```http PATCH /api/v2/connections/{id}/default-phone ``` Scope: `connections`. ```bash curl -X PATCH https://deriapi.site/api/v2/connections/con_6720f1a2b3c4d5e6f7a8/default-phone \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "0712345678" }' ``` | Field | Required | Description | | --- | --- | --- | | `phone_number` | yes | Mobile number, 6-20 characters, local (`0712…`) or international (`254712…`) form. | Returns the updated connection. The number is stored in international form, for example `254712345678`. This is a Kenyan number store: for Tanzania and Uganda, send `phone_number` on each deposit and withdrawal instead. An invalid number returns `400 INVALID_PHONE_NUMBER`. The saved number is for your convenience, for example to prefill a form. Deposits and withdrawals still need `phone_number` in the request. --- # Markets and rates Source: https://deriapi.site/docs/markets-and-rates A market is a country Deripay moves money in. Read the open markets from the API rather than hardcoding them: a market can open or close without a change on your side, and what you must ask the customer for differs by market. ## List markets ```http GET /api/v2/markets ``` Any valid key. ```json { "success": true, "data": { "markets": [ { "country": "KE", "name": "Kenya", "currency": "KES", "dialing_code": "254", "national_number_length": 9, "deposit_limits": { "min": 10, "max": 250000 }, "operators": [{ "code": "mpesa", "name": "M-Pesa" }], "requires_operator": false }, { "country": "UG", "name": "Uganda", "currency": "UGX", "dialing_code": "256", "national_number_length": 9, "deposit_limits": { "min": 5000, "max": 5000000 }, "operators": [ { "code": "airtel", "name": "Airtel Money" }, { "code": "mtn", "name": "MTN Mobile Money" } ], "requires_operator": true } ] } } ``` Values above are illustrative. | Field | Description | | --- | --- | | `country` | Two-letter country code. Send it as `country` on deposits, withdrawals and `/rates`. | | `name` | Market name to show. | | `currency` | Local currency (`KES`, `TZS`, `UGX`). Every local amount in this market is a whole number of it. | | `dialing_code` | Country calling code without `+`. | | `national_number_length` | Digits after the dialing code. A number of the wrong length is rejected for this market. | | `deposit_limits` | The provider's own floor and ceiling, in whole local units. The window that applies right now comes from `/rates`. | | `operators` | Mobile money networks: `code` to send, `name` to show. | | `requires_operator` | `true` when the customer must choose a network. Send its `code` as `operator`. | Operator codes in use today: | Market | Operator codes | | --- | --- | | `KE` | `mpesa` (you can omit `operator` in Kenya) | | `TZ` | `airtel`, `halopesa`, `tigo`, `vodafone` | | `UG` | `airtel`, `mtn` | Use the codes exactly as returned. In Tanzania the Vodafone M-Pesa code is `vodafone`. When a market has one operator and does not require a choice, Deripay picks it for you. ## Get rates and limits ```http GET /api/v2/rates?country=UG ``` Any valid key. Without `country` the answer is for Kenya. A country that is not open returns `400 MARKET_NOT_AVAILABLE`. ```json { "success": true, "data": { "country": "UG", "currency": "UGX", "deposit_rate": 3900, "withdraw_rate": 3550, "deposits_available": true, "deposit_limits": { "min": 20000, "max": 2000000 }, "withdraw_limits_usd": { "min": 5, "max": 500 }, "effective_date": "2026-11-03", "fetched_at": "2026-11-03T08:00:00Z" } } ``` Values above are illustrative. | Field | Description | | --- | --- | | `country` | The market. | | `currency` | Local currency for this market. Label amounts with it, never with `KES` outside Kenya. | | `deposit_rate` | Local units per USD used to convert a deposit. The customer's Deriv credit is about `amount / deposit_rate` USD. | | `withdraw_rate` | Local units per USD used to estimate a withdrawal payout. The payout is about `amount_usd * withdraw_rate`. | | `deposits_available` | `false` when deposits in this market are paused. Disable your deposit button. | | `deposit_limits` | `min` and `max` for a deposit right now, in whole local units. | | `withdraw_limits_usd` | `min` and `max` for a withdrawal right now, in USD. | | `effective_date` | Business date of the stored rate. | | `fetched_at` | When the rate was fetched. | Rates and limits change during the day. Fetch them when you open the deposit or withdrawal form and again before you retry. The server checks every amount, so a stale limit gives you `400 AMOUNT_OUT_OF_RANGE` instead of a bad transaction. ## Whole units Every local mobile money rail settles in whole units. A deposit `amount` must be an integer: `1000` KES, `500000` UGX. A value such as `1000.5` fails with `400 INVALID_REQUEST`. Reject decimals in your form instead of rounding them. Withdrawal `amount_usd` is in dollars and can have up to two decimals, for example `12.5`. ## Phone numbers Collect a number from the market's own country. Deripay accepts local (`0712345678`), national (`712345678`) and international (`254712345678`, `+254 712 345 678`) forms and strips spaces and symbols. A number that does not fit the market fails with `400 INVALID_PHONE_NUMBER`. Most markets cannot confirm the recipient's name before money is sent. Read the number back to the customer before a withdrawal. --- # Deposits Source: https://deriapi.site/docs/deposits A deposit collects money from the customer's phone and credits their Deriv account through a payment agent transfer. The API answers at once with a transaction. Settlement happens afterwards, so follow the transaction to a final state. ## Create a deposit ```http POST /api/v2/deposits Idempotency-Key: dep_user_1842_order_0001 ``` Scope: `deposits`. Rate limit: 30 per minute. An idempotency key is required. ```bash curl https://deriapi.site/api/v2/deposits \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: dep_user_1842_order_0001" \ -d '{ "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "UG", "amount": 50000, "phone_number": "0772123456", "operator": "mtn" }' ``` | Field | Required | Type | Description | | --- | --- | --- | --- | | `connection_id` | yes | string | The customer's `con_…` id. | | `country` | no | string | Market code from `GET /markets`. Default `KE`. | | `amount` | yes | integer | What the customer pays, in whole units of the market currency. | | `phone_number` | yes | string, 6-20 | The paying mobile number, in the market's own country. | | `operator` | conditionally | string | Operator `code` from `GET /markets`. Required when the market has `requires_operator: true`. Ignored in Kenya. | | `account_login_id` | no | string | Deriv login id to credit, for example `CR1234567`. Default: the connection's selected account. | | `idempotency_key` | conditionally | string | Use this only if you cannot send the `Idempotency-Key` header. | Before you call it: 1. Call `GET /rates?country=…` and keep `amount` between `deposit_limits.min` and `deposit_limits.max`. 2. If `deposits_available` is `false`, do not offer deposits in that market. Response: `201` with a [transaction](/docs/transactions). A replay with the same idempotency key returns `200`, the same transaction and the header `Idempotent-Replayed: true`. See [Idempotency](/docs/idempotency). ```json { "success": true, "data": { "id": "txn_6720f9e8d7c6b5a4f3e2", "object": "transaction", "type": "deposit", "state": "pending", "status": "DEPOSIT_HONEYCOIN_PENDING", "status_message": null, "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "UG", "currency": "UGX", "operator": "mtn", "amount": 50000, "amount_usd": 12.82, "phone_number": "2567•••456", "customer_action": null, "terminal": false, "created_at": "2026-11-03T09:00:00.000Z", "updated_at": "2026-11-03T09:00:01.000Z" } } ``` Store `data.id` before you show the next screen. ## Kenya: M-Pesa prompt In Kenya the customer gets an M-Pesa STK prompt on their phone straight away. Tell them to check their phone and enter their M-Pesa PIN. You do nothing else. The transaction goes from `pending` to `processing` once M-Pesa confirms, then to `completed` once Deriv has been credited. ## Tanzania and Uganda: customer actions Outside Kenya the provider can ask the customer for an extra step. The transaction then has `state: "action_required"` and a `customer_action` object: ```json { "state": "action_required", "status": "DEPOSIT_HONEYCOIN_ACTION_REQUIRED", "customer_action": { "step": "otp", "redirect_url": null, "instructions": ["message: Enter the code sent to your phone"] } } ``` | `customer_action.step` | What to do | | --- | --- | | `otp` | Ask the customer for the code their network sent them and submit it with `POST /deposits/{id}/otp`. | | `redirect` | Send the customer to `redirect_url` (always an `https` URL from the provider) to approve the payment, then keep following the transaction. | | `ussd` | Show `instructions` so the customer can dial the code on their phone. Keep following the transaction. | | `unsupported` | A step the API cannot complete. Show `instructions` if present and contact support. | `instructions` is a list of short strings from the provider. It can be empty. The step can change while the state stays `action_required`. Read `customer_action` from the latest transaction every time. Finishing a step is not proof of payment: wait for `completed`. You receive a `transaction.action_required` webhook when a deposit enters this state. ## Submit an OTP ```http POST /api/v2/deposits/{id}/otp ``` Scope: `deposits`. Rate limit: 30 per minute. No idempotency key. ```bash curl https://deriapi.site/api/v2/deposits/txn_6720f9e8d7c6b5a4f3e2/otp \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otp": "012345" }' ``` | Field | Required | Description | | --- | --- | --- | | `otp` | yes | The code from the customer's network, 3 to 12 characters. Send it as a string so leading zeros survive. | Returns `200` with the current transaction. It can still be `action_required` or `pending`. Keep following it. - A badly formed code returns `400 DEPOSIT_OTP_INVALID`. - A code sent when the deposit is not waiting for one, or for a withdrawal, returns `409 DEPOSIT_OTP_INVALID`. - Too many tries returns `429 DEPOSIT_OTP_INVALID`. The provider allows at most five submissions per deposit. - If the deposit has not reached the payment network yet, you get `409 TRANSACTION_NOT_READY`. Fetch it again in a moment. Do not resend a code automatically after a timeout. Fetch the transaction first. ## Deposit states | `state` | Meaning | What to show | | --- | --- | --- | | `pending` | Created, or waiting for the customer to approve on their phone. | "Check your phone to approve the payment." | | `action_required` | Waiting for an OTP, redirect or USSD step. | The step from `customer_action`. | | `processing` | Payment received. Deriv is being credited. | "Payment received, crediting your account." | | `completed` | Deriv account credited. Final. | Success. | | `failed` | The deposit did not go through. Final. | `status_message`, and offer to try again. | The raw `status` values behind each state are listed in [Transactions](/docs/transactions). ## Errors | Code | Status | Meaning | | --- | --- | --- | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | No `Idempotency-Key`. | | `IDEMPOTENCY_KEY_REUSED` | 409 | The key was used for a different request. | | `CONNECTION_NOT_FOUND` | 404 | Unknown `connection_id`. | | `MARKET_NOT_AVAILABLE` | 400 | `country` is not open. | | `INVALID_PHONE_NUMBER` | 400 | The number does not belong to the market. | | `OPERATOR_REQUIRED` | 400 | The market needs `operator`, or the code is not one of its operators. | | `AMOUNT_OUT_OF_RANGE` | 400 | Outside the current limits. `error.details` has `min`, `max` and `currency`. | | `DEPOSITS_PAUSED` | 503 | Deposits in this market are temporarily off. | | `DEPOSIT_NICKNAME_NOT_FOUND` | 412 | The customer has no Deriv nickname for the transfer. They must set one in Deriv. | | `DEPOSIT_DERIV_RECONNECT_REQUIRED` | 401 | The customer must reconnect Deriv. | | `UPSTREAM_UNAVAILABLE` | 502 | No answer in time. Retry with the same idempotency key. | The full list is in [Errors](/docs/errors). --- # Withdrawals Source: https://deriapi.site/docs/withdrawals A withdrawal has two steps. You start it with an amount in USD. Deriv sends the customer a verification code. You submit the code, Deriv releases the money, and Deripay pays it out to the customer's phone in the local currency. ## 1. Start a withdrawal ```http POST /api/v2/withdrawals Idempotency-Key: wd_user_1842_req_0001 ``` Scope: `withdrawals`. Rate limit: 20 per minute. An idempotency key is required. ```bash curl https://deriapi.site/api/v2/withdrawals \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: wd_user_1842_req_0001" \ -d '{ "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "KE", "amount_usd": 10, "phone_number": "0712345678" }' ``` | Field | Required | Type | Description | | --- | --- | --- | --- | | `connection_id` | yes | string | The customer's `con_…` id. | | `country` | no | string | Market the payout goes to. Default `KE`. | | `amount_usd` | yes | number | Amount to take from Deriv, in USD, at most two decimals. | | `phone_number` | yes | string, 6-20 | The number to pay out to, in the market's own country. | | `operator` | conditionally | string | Operator `code` from `GET /markets`. Required when the market has `requires_operator: true`. Ignored in Kenya. | | `account_login_id` | no | string | Deriv login id, for example `CR1234567`. Default: the connection's selected account. | | `idempotency_key` | conditionally | string | Use this only if you cannot send the `Idempotency-Key` header. | Before you call it: 1. Read `withdraw_limits_usd` from `GET /rates?country=…`. 2. Read the main wallet balance from `GET /connections/{id}/balance`. The amount must not be more than `balance`. 3. Read the payout number back to the customer. Most networks cannot check the recipient's name. Response: `201` with a [transaction](/docs/transactions) in `state: "awaiting_code"`. `amount` is the estimated local payout and `amount_usd` is what leaves Deriv. Store `data.id` before you ask for the code. ```json { "success": true, "data": { "id": "txn_6720f9e8d7c6b5a4f3e2", "object": "transaction", "type": "withdrawal", "state": "awaiting_code", "status": "WITHDRAWAL_EMAIL_SENT", "status_message": null, "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "KE", "currency": "KES", "operator": "mpesa", "amount": 1250, "amount_usd": 10, "phone_number": "2547•••678", "customer_action": null, "terminal": false, "created_at": "2026-11-03T09:00:00.000Z", "updated_at": "2026-11-03T09:00:02.000Z" } } ``` ## 2. The customer gets a code Deriv sends a verification code to the contact registered on the customer's Deriv account, usually by email. Ask the customer to enter it in your form. Keep the code as a string: it can start with zero. Deriv codes are currently six digits. ### Resend the code ```http POST /api/v2/withdrawals/{id}/resend ``` Scope: `withdrawals`. Rate limit: 10 per minute. No body. ```bash curl -X POST https://deriapi.site/api/v2/withdrawals/txn_6720f9e8d7c6b5a4f3e2/resend \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` Works while the state is `awaiting_code`. Returns the same transaction. Use the newest code. Once the withdrawal has moved on, resend returns `409 WITHDRAWAL_INVALID_TRANSACTION_STATE`. Respect Deriv's own limits on how often a code can be sent. ## 3. Confirm with the code ```http POST /api/v2/withdrawals/{id}/confirm ``` Scope: `withdrawals`. Rate limit: 20 per minute. ```bash curl https://deriapi.site/api/v2/withdrawals/txn_6720f9e8d7c6b5a4f3e2/confirm \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "verification_code": "048213" }' ``` | Field | Required | Description | | --- | --- | --- | | `verification_code` | yes | The code Deriv sent the customer, as a string. | Returns `200` with the transaction, usually in `state: "processing"`. The money is not on the customer's phone yet. Show success only when the state is `completed`. If the code is wrong or expired you get an error such as `WITHDRAWAL_INVALID_OTP`. Fetch the transaction: if it is back in `awaiting_code`, let the customer re-enter the code or resend it, on the **same** transaction. Never apply a code to a different transaction. If confirm times out, fetch the transaction before retrying. Confirming a withdrawal that already completed returns the completed transaction. ## Processing until paid out After a correct code the withdrawal stays in `processing` while Deriv releases the funds and the mobile money payout is sent. A payout that fails on the first try is retried by Deripay. That shows as raw status `WITHDRAWAL_B2C_FAILED`, which is still `processing`, not final. Do not start a new withdrawal to "fix" a slow one. Once Deriv has released the money, a second withdrawal would take money from the customer again. Wait for `completed` or `failed`, and contact support with the transaction id if it stays in `processing` longer than you expect. ## One active withdrawal at a time Deripay allows one active withdrawal per customer, Deriv login and payout number. A different idempotency key does not get round it. | Status | Code | What to do | | --- | --- | --- | | 409 | `WITHDRAWAL_ALREADY_ACTIVE` | Fetch the withdrawal you already have and wait for it to finish. Do not start another. | | 429 | `WITHDRAWAL_COOLDOWN` | Wait for the `Retry-After` header (seconds), then try again. | | 429 | `WITHDRAWAL_VELOCITY_LIMIT` | Too many withdrawals in a short period. Wait for `Retry-After`. Do not change the phone or account to get round it. | When one of these refuses a request, nothing was created, so you can retry the same logical request later with the same idempotency key. ## Withdrawal states | `state` | Meaning | What to show | | --- | --- | --- | | `awaiting_code` | Waiting for the customer's Deriv code. | The code form, and a resend link. | | `processing` | Code accepted. Deriv release and payout under way. | "Your withdrawal is on its way." | | `completed` | Money sent to the phone. Final. | Success. | | `failed` | The withdrawal did not go through. Final. | `status_message`. | | `pending` | The request was not confirmed by the payment network yet (raw status `SUBMISSION_PENDING`). | A spinner. Retry `POST /withdrawals` with the same idempotency key. | ## Errors | Code | Status | Meaning | | --- | --- | --- | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | No `Idempotency-Key`. | | `IDEMPOTENCY_KEY_REUSED` | 409 | The key was used for a different request. | | `AMOUNT_OUT_OF_RANGE` | 400 | Outside the current USD limits. `error.details` has `min_usd` and `max_usd`. | | `INVALID_PHONE_NUMBER` | 400 | The number does not belong to the market. | | `OPERATOR_REQUIRED` | 400 | The market needs `operator`. | | `WITHDRAWAL_ALREADY_ACTIVE` | 409 | Another withdrawal is in progress. | | `WITHDRAWAL_COOLDOWN` | 429 | Wait for `Retry-After`. | | `WITHDRAWAL_INVALID_OTP` | varies | Wrong or expired code. | | `WITHDRAWAL_INVALID_TRANSACTION_STATE` | 409 | The withdrawal cannot do that from its current state. | | `DERIV_RECONNECT_REQUIRED` | 401 | The customer must reconnect Deriv. Also sent in a prefixed form. | The full list is in [Errors](/docs/errors). --- # Transactions Source: https://deriapi.site/docs/transactions Every deposit and withdrawal is a transaction with an id such as `txn_6720f9e8d7c6b5a4f3e2`. The same object is returned by `POST /deposits`, `POST /withdrawals`, the follow-up actions, `GET /transactions`, the event stream and the `data` of every transaction webhook. ## The transaction object ```json { "id": "txn_6720f9e8d7c6b5a4f3e2", "object": "transaction", "type": "deposit", "state": "completed", "status": "DEPOSIT_COMPLETED", "status_message": "Deposit completed", "connection_id": "con_6720f1a2b3c4d5e6f7a8", "country": "KE", "currency": "KES", "operator": "mpesa", "amount": 1000, "amount_usd": 7.52, "phone_number": "2547•••678", "customer_action": null, "terminal": true, "created_at": "2026-11-03T09:00:00.000Z", "updated_at": "2026-11-03T09:01:12.000Z" } ``` | Field | Type | Description | | --- | --- | --- | | `id` | string | Transaction id, `txn_…`. | | `object` | string | Always `transaction`. | | `type` | string | `deposit` or `withdrawal`. | | `state` | string | Simple state: `pending`, `action_required`, `awaiting_code`, `processing`, `completed` or `failed`. Drive your UI from this. | | `status` | string | Detailed status from the payment network, for example `DEPOSIT_STK_PENDING`. Useful for support and logs. New values can appear. | | `status_message` | string or null | Human-readable note on the status. Safe to show to the customer. | | `connection_id` | string | The connection, `con_…`. | | `country` | string | Market code, for example `KE`. | | `currency` | string | Local currency, for example `KES`. | | `operator` | string or null | Mobile money operator code, for example `mpesa` or `mtn`. | | `amount` | integer or null | Local amount in whole units. Deposits: what the customer pays. Withdrawals: the local payout (an estimate until the payout is made). | | `amount_usd` | number or null | USD amount. Deposits: the expected Deriv credit. Withdrawals: what leaves Deriv. | | `phone_number` | string or null | The mobile number, masked, for example `2547•••678`. | | `customer_action` | object or null | Set only when `state` is `action_required`. See below. | | `terminal` | boolean | `true` once the transaction is `completed` or `failed`. It will not change again. | | `created_at` | string | When the transaction was created. | | `updated_at` | string | When it last changed. | ### `customer_action` | Field | Type | Description | | --- | --- | --- | | `step` | string | `otp`, `redirect`, `ussd` or `unsupported`. | | `redirect_url` | string or null | For `redirect`: the `https` URL to send the customer to. | | `instructions` | array of strings | Provider instructions to show. Can be empty. | How to handle each step is in [Deposits](/docs/deposits#tanzania-and-uganda-customer-actions). ## States Write your logic against `state`. `status` is more detailed and can gain new values. Any `status` you do not recognise counts as `pending`. | `status` | `state` | Type | | --- | --- | --- | | `DEPOSIT_INITIATED` | `pending` | deposit | | `DEPOSIT_STK_PENDING` | `pending` | deposit (Kenya: M-Pesa prompt sent) | | `DEPOSIT_HONEYCOIN_PENDING` | `pending` | deposit (Tanzania, Uganda: waiting on the provider) | | `DEPOSIT_HONEYCOIN_ACTION_REQUIRED` | `action_required` | deposit (customer step needed) | | `DEPOSIT_CALLBACK_RECEIVED` | `processing` | deposit (Kenya: M-Pesa answered, being checked) | | `DEPOSIT_CONFIRMED` | `processing` | deposit (payment confirmed) | | `DEPOSIT_DERIV_PENDING` | `processing` | deposit (crediting Deriv) | | `DEPOSIT_COMPLETED` | `completed` | deposit, final | | `DEPOSIT_FAILED` | `failed` | deposit, final | | `WITHDRAWAL_INITIATED` | `awaiting_code` | withdrawal (code being sent) | | `WITHDRAWAL_EMAIL_SENT` | `awaiting_code` | withdrawal (code sent) | | `WITHDRAWAL_DERIV_PENDING` | `processing` | withdrawal (Deriv releasing funds) | | `WITHDRAWAL_DERIV_COMPLETED` | `processing` | withdrawal (Deriv done, payout next) | | `WITHDRAWAL_B2C_PENDING` | `processing` | withdrawal (mobile money payout sent) | | `WITHDRAWAL_B2C_FAILED` | `processing` | withdrawal (payout failed, Deripay retries it; **not** final) | | `WITHDRAWAL_COMPLETED` | `completed` | withdrawal, final | | `WITHDRAWAL_FAILED` | `failed` | withdrawal, final | | `SUBMISSION_PENDING` | `pending` | either (the request may not have reached the network; retry the create call with the same idempotency key) | Only `completed` and `failed` are final. `terminal` is `true` exactly then. ## Get a transaction ```http GET /api/v2/transactions/{id} ``` Scope: `read`. ```bash curl https://deriapi.site/api/v2/transactions/txn_6720f9e8d7c6b5a4f3e2 \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` Each read asks the payment network for the latest status before it answers, so polling this endpoint is enough to move a transaction on. If the network does not answer, you get the last known state. An unknown id returns `404 TRANSACTION_NOT_FOUND`. ## List transactions ```http GET /api/v2/transactions ``` Scope: `read`. Results are newest first and come from Deripay's own records, without a fresh network read. | Query | Description | | --- | --- | | `connection_id` | Only this connection's transactions. | | `type` | `deposit` or `withdrawal`. | | `state` | One of the six states. | | `limit` | 1 to 100. Default 25. | | `cursor` | A `txn_…` id. Returns the page after it. Use `next_cursor` from the previous page. | ```bash curl "https://deriapi.site/api/v2/transactions?connection_id=con_6720f1a2b3c4d5e6f7a8&state=processing&limit=50" \ -H "Authorization: Bearer $DERIPAY_API_KEY" ``` ```json { "success": true, "data": { "items": [{ "id": "txn_6720f9e8d7c6b5a4f3e2", "object": "transaction", "...": "..." }], "total": 1, "next_cursor": null } } ``` ## Following a transaction Pick one way per transaction. Webhooks are the main signal. Use polling or the event stream when the customer is watching the screen. 1. **Webhooks.** Deripay sends `transaction.updated` on every state change, plus `transaction.action_required`, `transaction.completed` or `transaction.failed`. Deripay also checks open transactions in the background about once a minute, so webhooks arrive even if you never poll. See [Webhooks](/docs/webhooks). 2. **Polling.** Call `GET /transactions/{id}` every 3 to 5 seconds for the first minute, then every 15 to 30 seconds. Stop when `terminal` is `true`. Back off on `429`, `502` and `503`. Do not poll and stream the same transaction at the same time. 3. **Event stream.** Relay `GET /transactions/{id}/events` from your server. See below. Keep the transaction id so the customer can come back to the screen later. Never create a new transaction because a status check failed. ## Live events (SSE) ```http GET /api/v2/transactions/{id}/events Accept: text/event-stream ``` Scope: `read`. Rate limit: 60 per minute. The response is a [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream. Each event's `data` is JSON. | Event | `data` | What to do | | --- | --- | --- | | `status` | A transaction object. | Update the screen and keep listening. | | `complete` | The final transaction object. | Show the result and close. | | `heartbeat` | `{"ts": 1762160400}` | Nothing. Keep-alive only. | | `timeout` | `{"message": "reconnect"}` | Open the stream again. | | `error` | `{"code": "...", "message": "..."}` | Stop streaming and poll `GET /transactions/{id}` instead. | A stream closes after about 25 seconds with a `timeout` event, so it survives common proxy limits. Reconnect with a short, growing delay. Each new stream starts from the latest state, so you do not need an event cursor. If the transaction is already final, the stream sends one `complete` event and closes. Errors that happen before the stream starts (bad key, wrong scope, unknown id) come back as a normal JSON error response, not as an event. ```bash curl --no-buffer https://deriapi.site/api/v2/transactions/txn_6720f9e8d7c6b5a4f3e2/events \ -H "Authorization: Bearer $DERIPAY_API_KEY" \ -H "Accept: text/event-stream" ``` ### Never connect from the browser The browser's `EventSource` cannot send an `Authorization` header, and the API key must never reach a browser. Open the stream from your server and relay it to your page through your own route, after checking that the signed-in user owns the transaction. ### Node relay (Next.js route handler) `app/api/cashier/transactions/[id]/events/route.ts`: ```ts const DERIPAY = "https://deriapi.site/api/v2"; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; // Your own check: does the signed-in user own this transaction? if (!(await userOwnsTransaction(request, id))) { return new Response("Not found", { status: 404 }); } const upstream = await fetch(`${DERIPAY}/transactions/${encodeURIComponent(id)}/events`, { headers: { Authorization: `Bearer ${process.env.DERIPAY_API_KEY}`, Accept: "text/event-stream", }, signal: request.signal, }); if (!upstream.ok || !upstream.body) { return new Response("Live status unavailable", { status: 502 }); } return new Response(upstream.body, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store, no-transform", "X-Accel-Buffering": "no", }, }); } declare function userOwnsTransaction(request: Request, transactionId: string): Promise; ``` In the page, the browser connects to your own route. `EventSource` reconnects by itself when the stream closes after a `timeout`: ```ts const es = new EventSource(`/api/cashier/transactions/${transactionId}/events`); es.addEventListener("status", (e) => render(JSON.parse((e as MessageEvent).data))); es.addEventListener("complete", (e) => { render(JSON.parse((e as MessageEvent).data)); es.close(); }); es.addEventListener("error", () => { // Network drop or an `error` event: fall back to polling your backend. }); ``` ### Reading the stream on the server If you want the final state on your server without a browser, read the stream directly: ```ts async function waitForFinal(id: string): Promise { for (let attempt = 0; attempt < 40; attempt++) { const res = await fetch(`https://deriapi.site/api/v2/transactions/${id}/events`, { headers: { Authorization: `Bearer ${process.env.DERIPAY_API_KEY}`, Accept: "text/event-stream" }, }); if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += value; let sep: number; while ((sep = buffer.indexOf("\n\n")) !== -1) { const block = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const event = /^event: (.+)$/m.exec(block)?.[1]; const data = /^data: (.+)$/m.exec(block)?.[1]; if (event === "complete" && data) return JSON.parse(data); if (event === "error") throw new Error(data || "stream error"); } } await new Promise((r) => setTimeout(r, Math.min(1000 * (attempt + 1), 5000))); } throw new Error("still not final"); } ``` --- # Errors Source: https://deriapi.site/docs/errors Every error has the same shape: ```json { "success": false, "error": { "code": "AMOUNT_OUT_OF_RANGE", "message": "Deposits in Kenya must be a whole number between 10 and 250000 KES.", "details": { "min": 10, "max": 250000, "currency": "KES" }, "request_id": "req_9f1c2b3a4d5e6f70" } } ``` - Branch on `code`. Never branch on `message` or `details` text. - `message` is safe to log and usually safe to show. - Quote `request_id` (also in the `X-Request-Id` header) when you contact support. - When a `Retry-After` header is present, wait that many seconds before trying again. ## How to react | Kind of error | Retry? | | --- | --- | | `400` validation (`INVALID_REQUEST`, `AMOUNT_OUT_OF_RANGE`, …) | No. Fix the request. | | `401` / `403` key or scope errors | No. Fix the key. | | Reconnect errors (`DERIV_RECONNECT_REQUIRED`, …) | No. Send the customer through Deriv OAuth again, or ask for a new personal access token. | | `409` state conflicts | No. Fetch the transaction and follow its state. | | `429` | Yes, after `Retry-After`. | | `502`, `503`, `504` | Yes, with backoff. For deposits and withdrawals use the **same** idempotency key. | Never start a new deposit or withdrawal because a request timed out. Retry with the same key, or fetch the transaction. ## Gateway errors These come from the Deripay v2 API itself. | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `AUTH_MISSING_API_KEY` | 401 | No API key on the request. | Send `Authorization: Bearer ` from your server. | | `AUTH_INVALID_API_KEY` | 401 | The key is unknown, revoked or malformed. | Check the key in the dashboard; rotate it if it leaked. | | `AUTH_INSUFFICIENT_SCOPE` | 403 | The key lacks the scope this endpoint needs. | Create a key with the required scope. | | `AUTH_IP_NOT_ALLOWED` | 403 | The request came from an IP outside the key's allowlist. | Add your server IP to the key or clear the allowlist. | | `ACCOUNT_SUSPENDED` | 403 | Your Deripay developer account is suspended. | Contact support. | | `RATE_LIMITED` | 429 | Too many requests for this key. | Back off for `Retry-After` seconds. | | `INVALID_REQUEST` | 400 | The body or query failed validation. | Fix the fields listed in `error.details`. | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | A money-moving request was sent without `Idempotency-Key`. | Send a unique key per logical attempt and reuse it on retries. | | `IDEMPOTENCY_KEY_REUSED` | 409 | The idempotency key was already used for a different request. | Use a new key for a new attempt. | | `DERIV_APP_NOT_REGISTERED` | 400 | `client_id` is not one of your registered Deriv apps. | Add the Deriv OAuth client ID under Deriv apps in the dashboard. | | `CONNECTION_NOT_FOUND` | 404 | No connection with this ID belongs to your account. | Use the `con_…` ID returned when the customer connected. | | `TRANSACTION_NOT_FOUND` | 404 | No transaction with this ID belongs to your account. | Use the `txn_…` ID returned when the transaction was created. | | `MARKET_NOT_AVAILABLE` | 400 | The country is not an open market. | Call `GET /markets` and offer only the countries it returns. | | `INVALID_PHONE_NUMBER` | 400 | The phone number is not a valid mobile number for the market. | Collect a number in the market's own country. | | `OPERATOR_REQUIRED` | 400 | The market has several mobile money networks and none was chosen. Also returned when `operator` is not one of the market's codes. | Send `operator` using a code from `GET /markets`. | | `AMOUNT_OUT_OF_RANGE` | 400 | The amount is outside the market's current limits. `details` has the limits. | Read limits from `GET /rates?country=` before submitting. | | `DEPOSITS_PAUSED` | 503 | Deposits in this market are temporarily unavailable. | Disable the deposit button and try again later. | | `UPSTREAM_UNAVAILABLE` | 502 | Deripay's payment network did not answer in time. | Retry with the same idempotency key, with backoff. | | `INTERNAL_ERROR` | 500 | Unexpected gateway error. | Retry; contact support with the `request_id` if it persists. | | `TRANSACTION_NOT_READY` | 409 | An OTP, resend or confirm was sent before the transaction reached the payment network. | Fetch the transaction again shortly, then retry. | | `UPSTREAM_ERROR` | 502 | The payment network failed without a specific code. | Retry with backoff; contact support if it persists. | | `API_VERSION_RETIRED` | 410 | A request was sent to `/api/v1`. | Move to `/api/v2` with a new key. See [Changelog](/docs/changelog). | ## Payment network errors These come from Deripay's payment network and are passed through unchanged, with their own HTTP status. Transaction errors carry the prefix of the operation: `DEPOSIT_` or `WITHDRAWAL_`. Connection errors are not prefixed. The same base code can appear in either form, for example `DERIV_RECONNECT_REQUIRED` and `DEPOSIT_DERIV_RECONNECT_REQUIRED`. Handle both. ### Connections and Deriv authorization | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `DERIV_AUTH_FAILED` | 401 or 403 (varies) | Deriv did not accept the access token, or it lacks a required scope such as `payment`. | Send the customer through Deriv OAuth again, or ask for a new personal access token, with `trade account_manage payment`. | | `DERIV_RECONNECT_REQUIRED` (or prefixed) | 401 | Deriv has rejected the stored token. | Reconnect the customer, then retry. | | `DERIV_SESSION_EXPIRED` (or prefixed) | 401 | Older form of `DERIV_RECONNECT_REQUIRED`. | Treat it the same way. | | `DERIV_REFRESH_UNAVAILABLE` (or prefixed) | 503 | Token state is temporarily unavailable. | Retry with backoff. Do not reconnect. | | `DERIV_ACCOUNT_MISMATCH` | 400 | `preferred_account_id` was not among the customer's Deriv accounts. | Fix the account id and connect again. | | `DERIV_ACCOUNT_NOT_ELIGIBLE` (or prefixed) | 400 or 412 | No real USD Deriv account is available. | Ask the customer to connect with a real USD account. | | `NICKNAME_NOT_FOUND`, `DEPOSIT_NICKNAME_NOT_FOUND` | 412 | Deriv has not given the customer a nickname, which the payment agent transfer needs. | Ask the customer to set up their nickname in Deriv, then retry. | | `DEPOSIT_SESSION_APP_MISMATCH`, `WITHDRAWAL_SESSION_APP_MISMATCH` | 401 | The stored connection cannot be used for this operation. | Reconnect the customer. | | `DERIV_CONNECTION_NOT_FOUND` (or prefixed) | 404 | The payment network no longer knows this connection. | Reconnect the customer with `POST /connections`. | ### Deposits | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `DEPOSIT_INVALID_DEPOSIT_AMOUNT` | 400 | The amount was refused by the payment network. | Refresh limits from `GET /rates` and ask again. | | `DEPOSIT_INVALID_REQUEST` | 400 | Required data was missing or malformed. | Fix the request. Do not retry blindly. | | `DEPOSIT_OTP_INVALID` | 400 | The OTP is badly formed. | Ask the customer for the code again. | | `DEPOSIT_OTP_INVALID` | 409 | The deposit is not waiting for an OTP, or the transaction is a withdrawal. | Fetch the transaction and follow `customer_action`. | | `DEPOSIT_OTP_INVALID` | 429 | Too many OTP attempts (at most five per deposit). | Stop. Fetch the transaction; it will end in `failed` or move on. | Other provider failures on a deposit can use other `DEPOSIT_…` codes. Fetch the transaction before deciding what to tell the customer. ### Withdrawals | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `WITHDRAWAL_ALREADY_ACTIVE` | 409 | The customer, Deriv login or phone already has a withdrawal in progress. | Fetch the existing withdrawal and wait for it to finish. | | `WITHDRAWAL_COOLDOWN` | 429 | A cooldown applies after a recent withdrawal. | Wait for `Retry-After`, then retry the same request. | | `WITHDRAWAL_VELOCITY_LIMIT` | 429 | Too many withdrawals in a rolling window. | Wait for `Retry-After`. Do not switch phone or account to get round it. | | `WITHDRAWAL_INVALID_WITHDRAWAL_AMOUNT` | 400 | The amount was refused by the payment network, for example above the main wallet balance. | Check `GET /connections/{id}/balance` and `GET /rates`. | | `WITHDRAWAL_INVALID_OTP` | varies | The Deriv verification code is wrong or expired. | Fetch the transaction. If it is `awaiting_code`, let the customer re-enter or resend the code. | | `WITHDRAWAL_INVALID_TRANSACTION_STATE` | 409 | The withdrawal cannot do that from its current state, or the transaction is a deposit. | Fetch the transaction and update your screen. | | `WITHDRAWAL_INVALID_REQUEST` | 400 | Required data was missing or malformed. | Fix the request. Do not retry blindly. | | `WITHDRAWAL_FORBIDDEN` | 403 | The transaction does not belong to this connection. | Check the ids you stored. | ## Errors in the event stream `GET /transactions/{id}/events` can send an `error` event inside a `200` stream: ```text event: error data: {"code":"MONITOR_UPSTREAM_ERROR","message":"Live status is unavailable. Poll GET /api/v2/transactions/{id}."} ``` Stop streaming and poll `GET /transactions/{id}`. See [Transactions](/docs/transactions#live-events-sse). ## Asynchronous failures A transaction can fail after the API has answered `201`. That is not an error response: the transaction moves to `state: "failed"` with a `status_message`, and you receive `transaction.failed`. Show `status_message` to the customer. --- # Examples Source: https://deriapi.site/docs/examples Each example is a small, complete server-side integration: connect a customer, start a deposit and receive webhooks. Replace the stubbed functions (marked `declare`) with your own user store. Idempotency keys built from your ids must stay within 8-120 characters of letters, digits, `.`, `_`, `:` and `-`. Environment variables used throughout: ```bash DERIPAY_API_KEY="dpk_live_…" DERIPAY_WEBHOOK_SECRET="whsec_…" DERIV_CLIENT_ID="your Deriv OAuth client ID" ``` ## Next.js (route handlers) ### `lib/deripay.ts` ```ts const BASE = "https://deriapi.site/api/v2"; export class DeripayError extends Error { constructor(public status: number, public code: string, message: string, public requestId?: string) { super(message); } } export async function deripay( path: string, init: { method?: string; body?: unknown; idempotencyKey?: string } = {}, ): Promise { const headers: Record = { Authorization: `Bearer ${process.env.DERIPAY_API_KEY}`, Accept: "application/json", }; if (init.body !== undefined) headers["Content-Type"] = "application/json"; if (init.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; const res = await fetch(`${BASE}${path}`, { method: init.method ?? "GET", headers, body: init.body === undefined ? undefined : JSON.stringify(init.body), cache: "no-store", signal: AbortSignal.timeout(60_000), }); const json = await res.json(); if (!json.success) { throw new DeripayError(res.status, json.error.code, json.error.message, json.error.request_id); } return json.data as T; } export interface Transaction { id: string; object: "transaction"; type: "deposit" | "withdrawal"; state: "pending" | "action_required" | "awaiting_code" | "processing" | "completed" | "failed"; status: string; status_message: string | null; connection_id: string; country: string; currency: string; operator: string | null; amount: number | null; amount_usd: number | null; phone_number: string | null; customer_action: { step: string; redirect_url: string | null; instructions: string[] } | null; terminal: boolean; created_at: string; updated_at: string; } ``` ### Connect: `lib/connect.ts` Call this from your Deriv OAuth callback route as soon as you hold the access token, so the token never passes through the browser. The full callback is in [Deriv OAuth](/docs/deriv-oauth). ```ts import { deripay } from "@/lib/deripay"; declare function saveConnectionId(userId: string, connectionId: string): Promise; export async function connectCustomer(userId: string, token: { access_token: string; expires_in?: number }) { const conn = await deripay<{ id: string; deriv_login_id: string | null; authorization_status: string }>( "/connections", { method: "POST", body: { client_id: process.env.DERIV_CLIENT_ID, deriv_access_token: token.access_token, token_expires_in: token.expires_in, external_customer_id: userId, }, }, ); await saveConnectionId(userId, conn.id); // con_6720f1a2b3c4d5e6f7a8 return conn; } ``` ### Deposit: `app/api/deripay/deposit/route.ts` ```ts import crypto from "node:crypto"; import { deripay, DeripayError, type Transaction } from "@/lib/deripay"; declare function currentUserId(request: Request): Promise; declare function getConnectionId(userId: string): Promise; declare function saveTransaction(userId: string, tx: Transaction, idempotencyKey: string): Promise; export async function POST(request: Request) { const userId = await currentUserId(request); const connectionId = await getConnectionId(userId); if (!connectionId) return Response.json({ error: "NOT_CONNECTED" }, { status: 400 }); const input = (await request.json()) as { country: string; amount: number; phone_number: string; operator?: string; // Your form generates this once per attempt and resends it on retry. attempt_id?: string; }; if (!Number.isInteger(input.amount) || input.amount <= 0) { return Response.json({ error: "Whole amounts only" }, { status: 400 }); } const idempotencyKey = `dep_${userId}_${input.attempt_id ?? crypto.randomUUID()}`; try { const tx = await deripay("/deposits", { method: "POST", idempotencyKey, body: { connection_id: connectionId, country: input.country, amount: input.amount, phone_number: input.phone_number, ...(input.operator ? { operator: input.operator } : {}), }, }); await saveTransaction(userId, tx, idempotencyKey); return Response.json({ id: tx.id, state: tx.state, customer_action: tx.customer_action, status_message: tx.status_message, }); } catch (err) { if (err instanceof DeripayError) { return Response.json({ error: err.code, message: err.message }, { status: err.status }); } throw err; } } ``` ### Webhook: `app/api/deripay/webhook/route.ts` ```ts import crypto from "node:crypto"; import type { Transaction } from "@/lib/deripay"; declare function alreadyProcessed(eventId: string): Promise; declare function markProcessed(eventId: string): Promise; declare function applyTransactionUpdate(tx: Transaction): Promise; function verify(rawBody: string, header: string | null, secret: string): boolean { if (!header) return false; const parts: Record = {}; for (const item of header.split(",")) { const i = item.indexOf("="); if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim(); } if (!parts.t || !/^\d+$/.test(parts.t) || !parts.v1) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest(); const given = Buffer.from(parts.v1, "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); } export async function POST(request: Request) { const raw = await request.text(); if (!verify(raw, request.headers.get("deripay-signature"), process.env.DERIPAY_WEBHOOK_SECRET!)) { return new Response("bad signature", { status: 401 }); } const event = JSON.parse(raw) as { id: string; type: string; created_at: string; data: any }; if (event.type === "ping") return new Response("ok"); if (await alreadyProcessed(event.id)) return new Response("ok"); if (event.type.startsWith("transaction.")) { // Keyed on data.id, and ignores updates older than what you already hold. await applyTransactionUpdate(event.data as Transaction); } await markProcessed(event.id); return new Response("ok"); } ``` ## PHP (curl) ```php $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); if ($raw === false) { $error = curl_error($ch); curl_close($ch); throw new RuntimeException('Network error: ' . $error); } $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($raw, true); if (!is_array($json) || empty($json['success'])) { $code = $json['error']['code'] ?? 'UNKNOWN'; throw new RuntimeException("Deripay {$status} {$code}: " . ($json['error']['message'] ?? '')); } return $json['data']; } // Connect, after your Deriv OAuth callback has the access token. $connection = deripay('POST', '/connections', [ 'client_id' => getenv('DERIV_CLIENT_ID'), 'deriv_access_token' => $derivAccessToken, 'token_expires_in' => $derivExpiresIn, 'external_customer_id' => (string) $userId, ]); $connectionId = $connection['id']; // con_… // Check limits, then deposit. $rates = deripay('GET', '/rates?country=KE'); $amount = 1000; if (!$rates['deposits_available'] || $amount < $rates['deposit_limits']['min'] || $amount > $rates['deposit_limits']['max']) { throw new RuntimeException('Amount outside current limits'); } $idempotencyKey = "dep_{$userId}_{$orderId}"; $tx = deripay('POST', '/deposits', [ 'connection_id' => $connectionId, 'country' => 'KE', 'amount' => $amount, 'phone_number' => '0712345678', ], $idempotencyKey); echo $tx['id'] . ' ' . $tx['state'] . PHP_EOL; // txn_… pending ``` Webhook endpoint (`webhook.php`): ```php dict: headers = { "Authorization": f"Bearer {os.environ['DERIPAY_API_KEY']}", "Accept": "application/json", } if idempotency_key: headers["Idempotency-Key"] = idempotency_key res = requests.request(method, BASE + path, json=body, headers=headers, timeout=60) data = res.json() if not data.get("success"): err = data.get("error", {}) raise DeripayError(res.status_code, err.get("code", "UNKNOWN"), err.get("message", "")) return data["data"] # Connect, after your Deriv OAuth callback has the access token. connection = deripay("POST", "/connections", { "client_id": os.environ["DERIV_CLIENT_ID"], "deriv_access_token": deriv_access_token, "token_expires_in": deriv_expires_in, "external_customer_id": str(user_id), }) connection_id = connection["id"] # con_… # Uganda needs an operator. Read it from /markets. markets = deripay("GET", "/markets")["markets"] uganda = next(m for m in markets if m["country"] == "UG") operator = uganda["operators"][0]["code"] rates = deripay("GET", "/rates?country=UG") amount = 50000 assert rates["deposits_available"] assert rates["deposit_limits"]["min"] <= amount <= rates["deposit_limits"]["max"] idempotency_key = f"dep_{user_id}_{uuid.uuid4()}" # save this before sending tx = deripay("POST", "/deposits", { "connection_id": connection_id, "country": "UG", "amount": amount, "phone_number": "0772123456", "operator": operator, }, idempotency_key=idempotency_key) if tx["state"] == "action_required" and tx["customer_action"]["step"] == "otp": otp = input("Code from your network: ") tx = deripay("POST", f"/deposits/{tx['id']}/otp", {"otp": otp}) print(tx["id"], tx["state"]) ``` Webhook endpoint with Flask: ```python import hashlib import hmac import os import time from flask import Flask, request app = Flask(__name__) def verify(payload: bytes, header: str, secret: str) -> bool: parts = {} for item in (header or "").split(","): key, sep, value = item.strip().partition("=") if sep: parts[key] = value t, v1 = parts.get("t"), parts.get("v1") if not t or not t.isdigit() or not v1 or abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) @app.post("/webhooks/deripay") def deripay_webhook(): payload = request.get_data() if not verify(payload, request.headers.get("Deripay-Signature", ""), os.environ["DERIPAY_WEBHOOK_SECRET"]): return "bad signature", 401 event = request.get_json() # Skip event["id"] if already processed; otherwise update event["data"]["id"] in your records. return "ok", 200 ``` --- # Using Deripay with AI agents Source: https://deriapi.site/docs/ai Deripay runs an MCP server so your AI coding agent can read these docs, look up error codes, manage your Deriv apps, keys and webhooks, and check your integration, without you pasting secrets into a chat. ## The MCP server ```text https://deriapi.site/mcp ``` - Transport: Streamable HTTP. - Sign-in: OAuth with your Deripay account. The first time your agent connects, a browser window opens and you sign in. You never paste an API key into the agent. - The server **never moves money**. It has no tool to start a deposit, a withdrawal, an OTP or a confirm. Those only happen through the REST API with your secret key, from your server. ## Setup ### Claude Code ```bash claude mcp add --transport http deripay https://deriapi.site/mcp ``` ### Codex Add to `~/.codex/config.toml`: ```toml [mcp_servers.deripay] url = "https://deriapi.site/mcp" ``` ### Cursor Add to `.cursor/mcp.json` in your project: ```json { "mcpServers": { "deripay": { "url": "https://deriapi.site/mcp" } } } ``` ## Tools ### Docs | Tool | What it does | | --- | --- | | `search_docs` | Keyword search across these docs. | | `get_doc` | One docs page as Markdown, by slug (for example `deposits`). Without a slug, lists the pages. | | `get_integration_guide` | The integration pages in order, plus examples for your framework (`nextjs`, `node`, `php`, `python`). | | `get_code_example` | Code samples from these docs for a topic and language. | | `get_error_code` | Meaning and fix for one error code. | | `get_openapi` | The OpenAPI document for `/api/v2`. | ### Account | Tool | What it does | | --- | --- | | `get_account` | Your Deripay developer account. | | `list_deriv_apps`, `add_deriv_app`, `remove_deriv_app` | Manage the Deriv OAuth client IDs on your account. | | `list_api_keys`, `create_api_key`, `revoke_api_key` | Manage API keys. A new key's secret is shown once. | | `list_webhooks`, `create_webhook`, `update_webhook`, `delete_webhook` | Manage webhook endpoints. | | `send_test_webhook` | Send a signed `ping` event to an endpoint. | ### Data (read only) | Tool | What it does | | --- | --- | | `list_transactions`, `get_transaction` | Your transactions. | | `list_connections` | Your customer connections. | | `get_earnings` | Your earnings from completed transactions. | | `get_markets`, `get_rates` | Open markets, live rates and limits. | ### Checks | Tool | What it does | | --- | --- | | `check_integration` | Checks your setup (Deriv app with an `https` redirect, an active key that has been used, a working webhook endpoint, connected customers, recent transactions) and says what is missing. Can send a test webhook. | ## The `implement_cashier` prompt The server also offers a prompt named `implement_cashier`. It gives your agent the full brief for adding a Deripay cashier to your project: Deriv OAuth with PKCE, connections, deposits with customer actions, withdrawals with the Deriv code, webhooks with signature checks, and the go-live checklist. In Claude Code, MCP prompts appear as slash commands, here `/mcp__deripay__implement_cashier`. Every docs page is also available as an MCP resource at `deripay://docs/{slug}`. ## Machine-readable docs | URL | Contents | | --- | --- | | [`/llms.txt`](https://deriapi.site/llms.txt) | Short index of every docs page, for agents. | | [`/llms-full.txt`](https://deriapi.site/llms-full.txt) | All docs pages in one Markdown file. | | [`/openapi.json`](https://deriapi.site/openapi.json) | OpenAPI document for `/api/v2`, generated from the same request schemas the API validates with. | ## A prompt to give your agent Copy this into Claude Code, Codex or Cursor after you add the MCP server: ```text Add a Deripay mobile money cashier to this project. Use the deripay MCP server for docs (search_docs, get_doc, get_code_example, get_error_code) and follow them exactly; do not guess field names. Requirements: - All Deripay calls are server side, base URL https://deriapi.site/api/v2, header Authorization: Bearer from the DERIPAY_API_KEY environment variable. The key must never reach the browser. - Deriv sign-in: OAuth 2.0 Authorization Code with PKCE (S256), scopes "trade account_manage payment", check state, then POST /connections and store the con_ id against the user. A pasted Deriv personal access token with the same scopes can be sent to POST /connections instead. Do not store the Deriv token. - Deposits: read GET /markets and GET /rates?country=, enforce whole-number limits, POST /deposits with an Idempotency-Key saved before sending, and handle customer_action steps otp, redirect, ussd and unsupported. - Withdrawals: POST /withdrawals with amount_usd, ask for the Deriv code, POST /withdrawals/{id}/confirm, offer resend, handle WITHDRAWAL_ALREADY_ACTIVE and Retry-After. - Status: a webhook route that verifies Deripay-Signature with DERIPAY_WEBHOOK_SECRET on the raw body (5 minute window) and dedupes by event id, plus a server-side relay of GET /transactions/{id}/events for the cashier screen. - Branch on error.code. Show success only when state is "completed". When done, run check_integration and walk me through the go-live checklist. ``` Your agent can set up Deriv apps, keys and webhooks for you through the account tools. It cannot move money, and it should never print a full API key or webhook secret into your code. Put those in your environment. --- # Changelog Source: https://deriapi.site/docs/changelog ## 2026-11-04: Personal access tokens `POST /connections` also accepts a customer's Deriv personal access token with the scopes `trade account_manage payment`. Deposits and withdrawals then work the same as for an OAuth connection. See [Personal access tokens](/docs/deriv-oauth#personal-access-tokens). ## 2026-11-03: API v2 on deriapi.site Deripay API v2 is live at `https://deriapi.site/api/v2`. ### Breaking changes - **v1 is retired.** Every request to `/api/v1/...` now returns `410` with the code `API_VERSION_RETIRED`. - **Old keys stop working.** v1 keys are not accepted by v2. Create a new `dpk_live_…` key under **API keys** and send it as `Authorization: Bearer`. - **New Deriv OAuth client IDs only.** Connections go through your own Deriv OAuth app, registered under **Deriv apps** with its new client ID. Legacy numeric Deriv App IDs are rejected. - **New ids and field names.** Connections are `con_…`, transactions are `txn_…`, and all fields are `snake_case`. ### New - **Markets.** Kenya (M-Pesa), Tanzania and Uganda (mobile money). Read the open list from `GET /markets` and live rates and limits from `GET /rates?country=`. - **Customer actions.** Deposits outside Kenya can ask the customer for an OTP, a redirect or a USSD step, reported in `customer_action`. - **Simple states.** Every transaction has a `state` (`pending`, `action_required`, `awaiting_code`, `processing`, `completed`, `failed`) next to its detailed `status`. - **Idempotency.** Required on deposits and withdrawals, with `Idempotent-Replayed: true` on replays. - **Signed webhooks.** `transaction.updated`, `transaction.action_required`, `transaction.completed` and `transaction.failed`, signed with `Deripay-Signature`. - **Live events.** `GET /transactions/{id}/events` streams status as Server-Sent Events for a server-side relay. - **Scoped keys** with optional IP allowlists. - **MCP server** at `https://deriapi.site/mcp` for AI coding agents, plus `/llms.txt`, `/llms-full.txt` and `/openapi.json`. See [Using Deripay with AI agents](/docs/ai). ### Not offered - Crypto deposits.