API reference

Transactions

The transaction object, its states, listing, polling and the live event stream.

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.

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.
  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 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<boolean>;

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<any> {
  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");
}