DPay API
DPay is payment infrastructure for Pakistan. You integrate once; your customers pay by bank transfer or wallet straight into your own NayaPay or Meezan account; DPay verifies the transfer by reading the bank's own alert email and then sends you a signed payment.succeeded webhook. DPay never holds funds.
Base URL https://api.dpay.com.pk/v1 · JSON in, JSON out · bearer API keys · amounts are integers in paisa (PKR × 100) unless you use amount_decimal.
The API answers on its own host, and so do the pages a customer opens. Every one of those (checkout_url, hosted_invoice_url, a payment link url) comes back to you as an absolute URL, so you never build one yourself.
Quickstart
- Create an account and name your business.
- Add a receiving account under Receiving accounts: NayaPay (recommended, verifies in seconds) or Meezan Bank (beta, alerts arrive in 3–5 minutes).
- Connect the Gmail inbox your bank alerts arrive in (Integrations).
- Create an API key (Developers). Test keys work on every plan; live keys need a paid plan.
- From your server,
POST /payment_sessionsand redirect the customer tocheckout_url. - Handle
payment.succeededon your webhook and fulfil the order. Never fulfil on a redirect alone.
Authentication
Send your secret key as a bearer token. Test keys start with dpay_test_sk_, live keys with dpay_live_sk_. Keys are stored hashed and shown once, at creation.
Authorization: Bearer dpay_test_sk_…
Objects created with a live key carry livemode: true, and so do the webhook events about them.
Amounts
amount is an integer in paisa: 500000 = PKR 5,000.00. Where a request accepts money you may send amount_decimal (string or number, up to two decimals) instead. Responses always include both amount and amount_decimal. Currency is always PKR.
Idempotency
Send an Idempotency-Key header on POST /payment_sessions and retries return the original session with 200 instead of creating another. Use your order id.
Errors
Every error is JSON with one shape:
{ "error": { "type": "invalid_request_error", "code": "invalid_amount", "message": "Amount must be at least PKR 1.00" } }| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request, invalid_amount, invalid_invoice, invalid_subscription, no_payment_methods | Fix the request |
| 401 | unauthorized | Missing, wrong or revoked key |
| 402 | plan_limit_reached, plan_required | The merchant's plan blocks this; upgrade in Billing |
| 404 | not_found | No such object for this merchant |
| 409 | session_closed, illegal_transition, invoice_paid, invoice_void, subscription_cancelled | The object is in a state that forbids this |
| 429 | rate_limited | 60 requests then 1/second per key; honour retry_after |
| 5xx | internal, billing_not_configured | Retry later |
Rate limits
Each key has a bucket of 60 requests that refills at one per second. x-ratelimit-remaining is on every response.
Pagination
List endpoints take limit (default 20, max 100) and starting_after=<id>, the id of the last object you saw. Responses are { "object": "list", "data": [...], "has_more": true|false }, newest first.
Payment sessions
A payment session is one checkout: an amount, a customer, a hosted page, and a lifecycle that ends in succeeded, expired, cancelled, ambiguous or failed.
Create
POST /payment_sessions
| Field | Type | Notes |
|---|---|---|
amount | integer | Paisa. Or send amount_decimal. |
amount_decimal | string or number | PKR with up to 2 decimals. |
description | string, required | Shown to the customer at checkout. |
order_id | string | Your reference; echoed back. |
customer | object | { "name", "email" }. An email builds a customer record on success. |
payment_methods | string[] | Which of your receiving accounts may be used: nayapay, meezan. Defaults to all you have enabled. |
success_url, cancel_url | url | Where the customer goes afterwards. |
expires_in | integer | Seconds the customer has to pay, 120–86400. Default 900. |
metadata | object | String key/values echoed on the object and every webhook. |
curl https://api.dpay.com.pk/v1/payment_sessions \
-H "Authorization: Bearer dpay_test_sk_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ORD-1042" \
-d '{"amount_decimal":"5000","description":"Premium Product","order_id":"ORD-1042","customer":{"email":"ayesha@example.pk"},"success_url":"https://yourstore.pk/thanks"}'// Node: create on your server, then redirect the browser
const res = await fetch(`${process.env.DPAY_API_BASE_URL}/payment_sessions`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DPAY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": order.id },
body: JSON.stringify({ amount: order.totalPaisa, description: order.title, order_id: order.id, customer: { email: order.email }, success_url: `${SITE}/thanks/${order.id}` }),
});
const session = await res.json();
redirect(session.checkout_url);Response 201:
{
"id": "dpay_ps_x7k2…", "object": "payment_session",
"amount": 500000, "amount_decimal": "5000.00", "currency": "PKR",
"description": "Premium Product", "order_id": "ORD-1042",
"customer": { "name": null, "email": "ayesha@example.pk" },
"status": "awaiting_payment", "reference": "DPAY-8F4K29",
"provider": null, "customer_bank": null, "customer_account_last4": null,
"checkout_url": "https://pay.dpay.com.pk/dpay_ps_x7k2…",
"success_url": "https://yourstore.pk/thanks", "cancel_url": null,
"verification": null, "expires_at": "2026-09-09T11:15:00.000Z",
"metadata": {}, "livemode": false,
"created_at": "2026-09-09T11:00:00.000Z", "updated_at": "2026-09-09T11:00:00.000Z"
}Once verified, verification is filled: { "status": "matched", "confidence": 1, "matched_signals": ["merchant_account", "amount", …], "transaction_id": "txn_…", "verified_at": "…" }, and provider / customer_bank say which of your accounts received it and where the customer paid from.
Retrieve, list, cancel, verify
GET /payment_sessions/:id GET /payment_sessions?status=succeeded&limit=20&starting_after=dpay_ps_… POST /payment_sessions/:id/cancel POST /payment_sessions/:id/verify # force a verification run now
Payment states
created → awaiting_payment → customer_claimed_paid → verification_pending
├── succeeded
├── ambiguous (two credits could match, merchant review)
└── expired (window elapsed; late funds can still be honoured)
also: failed · cancelled · refunded · partially_refunded · disputedOnly status: "succeeded", read from the API or from a signature-verified webhook, means the money arrived. A redirect to success_url proves nothing; the customer can type that URL.
Payment links
A reusable link: every open creates a fresh payment session with its own reference.
POST /payment_links { "title", "amount" | "amount_decimal", "description"?, "payment_methods"? }
GET /payment_links
GET /payment_links/:id
POST /payment_links/:id/deactivateObject: { "id": "plink_…", "object": "payment_link", "title", "amount", "amount_decimal", "currency", "description", "payment_methods", "active", "url", "times_used", "created_at" }.
Invoices
Line items, a sequential number, an optional due date, and a hosted page at hosted_invoice_url where the customer presses Pay, which creates an ordinary payment session.
POST /invoices { "customer": {"name","email"}, "line_items": [{"description","quantity","unit_amount"}], "memo"?, "due_at"? }
GET /invoices?status=open|paid|void
GET /invoices/:id
POST /invoices/:id/voidObject: { "id": "inv_…", "object": "invoice", "number": "INV-0001", "status": "open|paid|void", "customer", "line_items": [{ "description", "quantity", "unit_amount", "amount" }], "amount", "amount_decimal", "currency", "memo", "due_at", "hosted_invoice_url", "payment_session", "subscription", "created_at", "paid_at" }. unit_amount is in paisa. invoice.created and invoice.paid webhooks fire.
Subscriptions
Recurring billing without a card. Bank transfer cannot auto-debit, so each period DPay issues an invoice (due in 7 days) and advances a month; the customer pays that invoice like any other. Send them latest_invoice_url from the subscription.updated webhook.
POST /subscriptions { "customer": {"name","email"}, "description", "amount", "start_at"? }
GET /subscriptions?status=active|paused|cancelled
GET /subscriptions/:id # includes its invoices
POST /subscriptions/:id/cancelObject: { "id": "sub_…", "object": "subscription", "status", "customer", "description", "amount", "amount_decimal", "currency", "interval": "month", "next_invoice_at", "latest_invoice", "invoices_issued", "created_at", "cancelled_at" }. Create returns latest_invoice_object too (null when start_at is in the future).
Customers
Built automatically from every succeeded payment that carried an email.
GET /customers GET /customers/:id # includes recent payments
Object: { "id": "cus_…", "object": "customer", "email", "name", "payments_count", "total_volume", "total_volume_decimal", "currency", "last_bank", "first_paid_at", "last_paid_at", "created_at" }.
Events
Everything that happened, for audit and reconciliation.
GET /events?type=payment.state_changed&limit=50&starting_after=evt_… GET /events/:id
Object: { "id": "evt_…", "object": "event", "type", "payment_session", "from", "to", "data", "created_at" }.
Webhook endpoints
POST /webhook_endpoints { "url", "description"?, "events"?: ["payment.succeeded", …] } → includes "secret" once
GET /webhook_endpoints
DELETE /webhook_endpoints/:idOmit events (or send ["*"]) to receive everything. Store the returned secret as DPAY_WEBHOOK_SECRET; it is never shown again.
Webhooks
DPay POSTs a JSON event to each enabled endpoint that subscribes to its type:
{ "id": "evt_…", "object": "event", "type": "payment.succeeded", "created": 1757415600, "livemode": false,
"data": { "object": { "id": "dpay_ps_…", "object": "payment_session", "status": "succeeded", "amount": 500000, "order_id": "ORD-1042", "metadata": {} } } }Headers: DPay-Signature (t=<unix>,v1=<hex hmac>), DPay-Event-Id, DPay-Event-Type, Idempotency-Key (same as the event id). Respond 2xx within 10 seconds. Failures retry after 30s, 2m, 10m, 1h and 6h (six attempts in all), so handle each `DPay-Event-Id` once and make your handler idempotent.
Event types: payment.created, payment.pending, payment.processing, payment.succeeded, payment.failed, payment.expired, payment.ambiguous, payment.refunded, payment.disputed, invoice.created, invoice.paid, subscription.created, subscription.updated, subscription.cancelled.
Verify signatures
Compute HMAC-SHA256(secret, "<t>.<raw body>"), compare in constant time to v1, and reject timestamps more than 5 minutes old. Use the raw request body: re-serialising JSON breaks the signature.
// Node
import crypto from "node:crypto";
export function verifyDPay(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex"), b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}# Python
import hmac, hashlib, time
def verify_dpay(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)<?php // PHP
function verifyDPay(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(",", "&", $header), $p);
if (empty($p["t"]) || empty($p["v1"]) || abs(time() - (int)$p["t"]) > 300) return false;
$expected = hash_hmac("sha256", $p["t"] . "." . $rawBody, $secret);
return hash_equals($expected, $p["v1"]);
}What the customer sees
At checkout_url the customer chooses the bank or wallet they are paying from (any of 45 Pakistani institutions, not only the ones you accept) and optionally the last 4 digits of that account. DPay shows your receiving account with the exact amount and a reference to put in the transfer note. They pay in their own banking app, press I have sent the payment, and the page polls until the bank's alert is matched. NayaPay alerts land within seconds; Meezan's in 3–5 minutes.
Sandbox
Exercise verification without moving money: while signed in to the dashboard, POST a simulated bank alert for any payment the customer has marked as sent, and verification runs on it exactly as it would on the real email:
# On the app host: the sandbox is authenticated by your dashboard session, not an API key.
curl https://dpay.com.pk/api/dev/inbox -H "Content-Type: application/json" \
-d '{ "simulate": { "session_id": "dpay_ps_…", "variant": "exact" } }'
# variants: exact · wrong_amount · wrong_account · duplicate · debitA local webhook receiver that verifies signatures lives at https://dpay.com.pk/api/webhooks/echo.
Plans and limits
Every checkout counts once against the merchant's monthly plan (API, dashboard, link, invoice). Free: 25 payments a month and test keys only. Paid plans raise the limit and unlock live keys; Growth and Scale also brand the hosted checkout with the merchant's logo, accent colour and trust line (Settings → Checkout branding). Over the limit, creation fails with 402 plan_limit_reached; nothing is charged automatically. Machine-readable reference: https://dpay.com.pk/llms-full.txt.
The Developers page generates a complete integration prompt for Claude, Cursor or Copilot: this reference, the webhook rules and your environment variables, with an optional test key embedded.