02Quickstart

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-token authorize against any app_id. Tick the Payments checkbox at mint time — without it, deposits and withdrawals both fail.
  • OAuth tokens require your Deriv app itself to have payments in 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.

bash
# 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-KeykeyId:secret (the full key you saved)
  • X-Timestamp — ISO-8601 datetime, within ±300s of server clock
  • X-Signature — HMAC-SHA256(secret, payload)

Where payload is:${timestamp}\n${method}\n${path}\n${sha256(body)}

Node.js helper

javascript
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 also payments
  • Not expired (~7 days idle for OAuth tokens) or revoked

Standard pattern: send users through Deriv OAuth on your real app_id:

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

bash
# 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

javascript
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

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

  1. Initiate → Deriv emails the user a 6-digit code
  2. Confirm with the code → upstream debits Deriv and starts the M-Pesa B2C payout
  3. 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

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

javascript
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). Submitting fyyBtoSF with 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_processingcompleted when M-Pesa confirms the B2C payout.

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

text
https://yoursite.com/redirect?action=payment_agent_withdraw&code=ABC123XYZ&loginid=CR12345

You need a small page at that path that:

  1. Reads the code + loginid query params
  2. Saves them to localStorage
  3. 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:

javascript
// 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 36 to 72.
  • 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