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
{
"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
GET /api/v2/transactions/{id}Scope: read.
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
GET /api/v2/transactionsScope: 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. |
curl "https://deriapi.site/api/v2/transactions?connection_id=con_6720f1a2b3c4d5e6f7a8&state=processing&limit=50" \
-H "Authorization: Bearer $DERIPAY_API_KEY"{
"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.
- Webhooks. Deripay sends
transaction.updatedon every state change, plustransaction.action_required,transaction.completedortransaction.failed. Deripay also checks open transactions in the background about once a minute, so webhooks arrive even if you never poll. See Webhooks. - Polling. Call
GET /transactions/{id}every 3 to 5 seconds for the first minute, then every 15 to 30 seconds. Stop whenterminalistrue. Back off on429,502and503. Do not poll and stream the same transaction at the same time. - Event stream. Relay
GET /transactions/{id}/eventsfrom 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)
GET /api/v2/transactions/{id}/events
Accept: text/event-streamScope: 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.
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:
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:
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:
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");
}