From zero to your first deposit.
Under ten minutes. No SDK required.
0. Have a registered Deriv app
Before anything else: you need a Deriv application registered at api.deriv.com/dashboard. Deriv assigns it a numeric app_id (e.g. 52947) — this is what your API key will be bound to.
Critical: register the app with the read, trading_information, and payments scopes. The payments scope is what lets both deposits and withdrawalswork — Deripay is a payment-agent flow on both sides, so the underlying Deriv calls (paymentagent_transfer, paymentagent_withdraw) need it regardless of direction. It's opt-in, not default. See troubleshooting if you skip it.
Two things to know about tokens — both kinds need payments:
- Manual tokens from
app.deriv.com/account/api-tokenauthorize against anyapp_id. Tick thePaymentscheckbox at mint time — without it, deposits and withdrawals both fail. - OAuth tokens require your Deriv app itself to have
paymentsin its allowed-scopes list (set in DevHub) andyour authorize URL must request it. If the app doesn't allow the scope, Deriv silently drops it from the OAuth response — the token comes back looking valid but can't move money in either direction.
1. Register on Deripay
Create a developer account. You'll add your Deriv app IDs per API key after registering — not at signup.
2. Create an API key
Open API Keys in the portal and issue a key. Bind it to your Deriv app_id from step 0. The plaintext secret is only shown once — save it as an env var on your server.
# Your full key looks like
# dpk_live_<12hex>:<64hex>
export DERIPAY_API_KEY="dpk_live_xxxxxxxxxxxx:yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"3. Bind a domain at key creation
When you create the API key, you set its primary domain (e.g. yoursite.com). This is the hostname every transaction made with the key gets attributed to — for browsers, mobile apps, and server scripts alike. The domain is locked at creation: to switch domains, rotate the key (preserves the domain) or revoke and create a new one.
No DNS verification or allowlist setup is required. The API key + HMAC signature are the credentials.
4. Sign a request
Every /api/v1/* call needs three headers:
X-API-Key—keyId:secret(the full key you saved)X-Timestamp— ISO-8601 datetime, within ±300s of server clockX-Signature— HMAC-SHA256(secret, payload)
Where payload is:${timestamp}\n${method}\n${path}\n${sha256(body)}
Node.js helper
import crypto from "node:crypto";
const BASE_URL = "https://deripay.site";
const FULL_KEY = process.env.DERIPAY_API_KEY; // dpk_live_xxx:yyy
const [keyId, secret] = FULL_KEY.split(":");
export async function deripayCall(method, path, body) {
const timestamp = new Date().toISOString();
const rawBody = body ? JSON.stringify(body) : "";
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const payload = `${timestamp}\n${method.toUpperCase()}\n${path}\n${bodyHash}`;
const signature = crypto.createHmac("sha256", secret).update(payload).digest("hex");
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-API-Key": FULL_KEY,
"X-Timestamp": timestamp,
"X-Signature": signature,
},
body: rawBody || undefined,
});
return res.json();
}5. Sourcing the user's Deriv token
For each call, you supply the user's loginid and userToken. The token must be:
- Minted by your Deriv app from step 0 (token + app_id are bound — tokens from a different app will be rejected)
- Granted at least
read; for withdrawals alsopayments - Not expired (~7 days idle for OAuth tokens) or revoked
Standard pattern: send users through Deriv OAuth on your real app_id:
https://oauth.deriv.com/oauth2/authorize
?app_id=<your-app-id>
&l=EN
&scope=read,trading_information,payments
&redirect_uri=<your-registered-redirect>
# Deriv redirects back with:
# ?acct1=CR1234567&token1=a1-xxxxxx&...
# Use token1 as userToken in your deposit call.Verify any token + app_id pair before debugging the gateway:
# In your project (or any Node script):
node -e "
const ws = new (require('ws'))('wss://ws.derivws.com/websockets/v3?app_id=YOUR_APP_ID');
ws.on('open', () => ws.send(JSON.stringify({ authorize: 'YOUR_TOKEN' })));
ws.on('message', d => { console.log(JSON.parse(d.toString())); ws.close(); });
"
# Look for the 'scopes' array in the authorize response.6. Initiate a deposit
const result = await deripayCall("POST", "/api/v1/deposit", {
phoneNumber: "254712345678",
usdAmount: 10,
currency: "USD", // optional, defaults to USD
loginid: "CR12345", // user's Deriv loginid
userToken: "deriv-token", // user's Deriv API token (from OAuth)
appId: "52947", // MUST match the app_id bound to your API key
});
console.log(result);
// {
// success: true,
// transactionId: "abc123",
// checkoutRequestId: "ws_CO_...",
// quote: { usdAmount: 10, amountKes: 1300, customerRate: 130 },
// message: "STK push sent to your phone"
// }7. Poll for completion
async function pollUntilDone(transactionId) {
for (let i = 0; i < 36; i++) {
const res = await deripayCall("GET", `/api/v1/transactions/${transactionId}`);
if (res.transaction.status === "completed") return res;
if (res.transaction.status === "failed" || res.transaction.status === "cancelled") {
throw new Error(res.transaction.statusMessage);
}
await new Promise(r => setTimeout(r, 5000));
}
throw new Error("Timeout — check the dashboard");
}Or skip polling entirely by registering a webhook for transaction.completed.
Withdrawals
Withdrawals are a two-step flow because Deriv requires the user to confirm the withdrawal via an emailed verification:
- Initiate → Deriv emails the user a 6-digit code
- Confirm with the code → upstream debits Deriv and starts the M-Pesa B2C payout
- Poll for completion (same endpoint as deposits)
The user's userToken for withdrawals must include the payments scope, in addition to read. See troubleshooting if you skip it.
Two email delivery modes — read this first
Deriv decides what your user sees in the email based on whether your Deriv app has a Verification URL configured in the DevHub:
- Verification URL NOT set (recommended) — Deriv emails a plain 6-digit code. The user copies it and pastes it into your form. Simple.
- Verification URL set — Deriv emails a clickable link instead of a code. Clicking the link opens your URL with the code as a query parameter (see "If your Deriv app has a verification URL" below).
Recommendation: leave the Verification URL field blank in your Deriv app settings. The 6-digit-code flow is simpler, has no extra redirect page to build, and works identically on desktop and mobile.
8. Initiate the withdrawal
const init = await deripayCall("POST", "/api/v1/withdrawals/initiate", {
phoneNumber: "254712345678",
usdAmount: 10,
currency: "USD", // optional, defaults to USD
loginid: "CR12345",
userToken: "deriv-token", // MUST include the "payments" scope
appId: "52947", // MUST match the app_id bound to your API key
});
console.log(init);
// {
// success: true,
// transactionId: "abc123",
// upstreamTransactionId: "edgepro-tx-id",
// message: "A Deriv verification code has been sent to the user's email...",
// quote: { usdAmount: 10, amountKes: 1280, customerRate: 128 },
// paymentAgent: { loginid: "CR1234567", name: "DForge PA" }
// }
// 👉 Show the user a "check your email" screen and a code input field.
// Persist init.transactionId — that's the ID you'll poll on, NOT the
// upstreamTransactionId. Both are returned, but only transactionId is
// used by /api/v1/transactions/:id and /withdrawals/confirm.9. Confirm with the verification code
After the user pastes the 6-digit code from their email:
const confirm = await deripayCall("POST", "/api/v1/withdrawals/confirm", {
transactionId: init.transactionId,
verificationCode: "fyyBtoSf", // CASE-SENSITIVE — preserve uppercase/lowercase exactly
});
console.log(confirm);
// {
// success: true,
// transactionId: "abc123",
// upstreamTransactionId: "edgepro-tx-id",
// transaction: {
// status: "deriv_processing", // ← interim, NOT final
// rawStatus: "WITHDRAWAL_B2C_PENDING"
// }
// }
// 👉 A successful confirm response does NOT mean the payout has completed.
// Continue to step 10 and poll until status is "completed".Three gotchas on confirm:
- The verification code is case-sensitive. Deriv uses mixed-case alphanumeric codes (e.g.
fyyBtoSf). SubmittingfyyBtoSFwith the wrong final case is rejected. Trim whitespace but never lowercase the input. - The Deriv token must still be valid.If the token has expired between initiate and confirm, the confirm call fails. Don't hold the cashier modal open for hours and expect confirm to still work — re-mint via OAuth if your flow allows long pauses.
- Confirm success ≠ final completion. A 200 from confirm typically returns
deriv_processing/WITHDRAWAL_B2C_PENDING. The M-Pesa B2C payout is still in flight. Polling is mandatory.
10. Poll for the M-Pesa payout
Same polling helper as deposits — the status flow goes from awaiting_email_verification → deriv_processing → completed when M-Pesa confirms the B2C payout.
const final = await pollUntilDone(init.transactionId);
console.log(final.transaction.status); // "completed"If your Deriv app has a Verification URL
Important:Once a Verification URL is set on a Deriv app, Deriv does not allow you to remove it — you can only update it to a different URL. Set it carefully. If you don't need one, leave it blank in your Deriv app settings.
If your Deriv app has a Verification URL configured, Deriv sends the customer an email containing a clickable link in this shape:
https://yoursite.com/redirect?action=payment_agent_withdraw&code=ABC123XYZ&loginid=CR12345You need a small page at that path that:
- Reads the
code+loginidquery params - Saves them to localStorage
- Redirects back to the page that hosts the cashier modal
The redirect page itself renders nothing — it is just a launcher that captures the verification code and bounces the user back to your cashier:
// app/redirect/page.tsx (Next.js App Router) — or an equivalent route in
// any framework. Intercept the URL Deriv emails the user, capture the
// verification code, persist it, then bounce back to the cashier.
"use client";
import { useEffect } from "react";
const KEY = "payment_agent_withdraw_verification";
const TTL_MS = 30 * 60 * 1000;
export default function RedirectPage() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const action = params.get("action");
const code = params.get("code")?.trim();
const loginid = params.get("loginid")?.trim();
if (action === "payment_agent_withdraw" && code) {
localStorage.setItem(
KEY,
JSON.stringify({ code, loginid, createdAt: Date.now() })
);
// Bounce back to the page that hosts your cashier modal
window.location.replace("/?payment_agent_withdraw=1");
} else {
window.location.replace("/");
}
}, []);
return null;
}
// Helper to read it back from your modal:
export function takePendingVerificationCode() {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(KEY);
if (!raw) return null;
try {
const { code, loginid, createdAt } = JSON.parse(raw);
if (!code || Date.now() - createdAt > TTL_MS) {
localStorage.removeItem(KEY);
return null;
}
localStorage.removeItem(KEY); // single-use
return { code, loginid };
} catch {
return null;
}
}On your cashier modal mount (or when you detect the ?payment_agent_withdraw=1 URL flag), call takePendingVerificationCode(). If it returns a code, auto-fill the verify step input and surface a "click Withdraw to complete" message — the user already proved possession by clicking the link in their email, so they shouldn't have to type the code manually.
Don't forget to also restore the transactionId from your withdraw-draft localStorage entry — clicking the email link does a full page reload and wipes React state, so the modal needs to rehydrate the in-flight transaction id from storage to feed it into /api/v1/withdrawals/confirm.
UX tips for the withdrawal flow
- Persist the transactionId in localStorage the moment you receive it from the initiate call. Mobile users frequently switch to their email app to grab the code, and that tab-switch can wipe React state. When the page rehydrates, restore the verify step from localStorage.
- The verification code is single-use.If the user enters a wrong code, the confirm call returns 401 — let them retry with the same code (in case of typo) or fall back to a "resend code" button that calls initiate again.
- Withdrawals can take longer than depositsbecause M-Pesa B2C payouts go through Safaricom's queue. Poll with a longer timeout (≥ 6 minutes) than for deposits — change the loop cap from
36to72. - Don't close the modal mid-flow. If the user dismisses the modal while polling, transactions can complete silently. Block the close button during polling, or surface a warning.
What's next
- API reference — full request / response shapes for every endpoint
- Webhooks — skip polling entirely with signed HTTP callbacks
- AI flow — paste a single prompt into Claude / Cursor / Codex and have it implement everything above end-to-end
- Troubleshooting — common errors decoded