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.
Whole rupees only. Pakistani bank and wallet transfers do not carry paisa, so a session for PKR 18.38 can never be matched by an alert and would sit on "Verifying" until it expires. An amount that is not a multiple of 100 paisa is refused with invalid_request: *amount must be a whole number of rupees (a multiple of 100 paisa); Pakistani bank transfers do not carry paisa*. To have DPay round for you, send rounding: "up" or rounding: "nearest"; the session then carries the rounded amount. Rounding is never applied silently. Matching compares whole rupees, so an alert that differs only in paisa is the same transfer.
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
Whole rupees.amountmust be a multiple of 100 paisa.1838(PKR 18.38) is refused;1900is accepted;1838with"rounding": "up"becomes1900. No Pakistani bank sends paisa, so a session that asks for it can never be paid.
| Field | Type | Notes |
|---|---|---|
amount | integer | Paisa, and a whole number of rupees (a multiple of 100). 1900 is PKR 19; 1838 is refused. Or send amount_decimal. |
amount_decimal | string or number | PKR. "19" or 19.00; "18.38" is refused for the same reason. |
rounding | "up" or "nearest" | Opt in to have paisa rounded to whole rupees server-side. 1838 with up becomes 1900, with nearest becomes 1800. Without it, paisa is an error. |
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. DPay appends dpay_session_id and dpay_status to success_url. See *What the customer sees*. |
expires_in | integer | Seconds the customer has to pay, 120–86400 (15 minutes by default, 24 hours at most). When it ends the session becomes expired and payment.expired fires. |
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":"[email protected]"},"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": "[email protected]" },
"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.
Every transition and the event it emits:
| From | To | Event | When |
|---|---|---|---|
| — | created | payment.created | POST /payment_sessions |
created | awaiting_payment | payment.pending | the customer picks a bank at checkout |
awaiting_payment | customer_claimed_paid | payment.processing | the customer presses I have sent the payment |
customer_claimed_paid | verification_pending | payment.processing | a verification run starts |
verification_pending | succeeded | payment.succeeded | one bank credit passes every required check |
verification_pending | ambiguous | payment.ambiguous | more than one credit could be this payment; held for review |
ambiguous | succeeded / failed | payment.succeeded / payment.failed | the merchant resolves it |
| any open state | expired | payment.expired | the window closes, whether or not the customer ever paid |
expired | succeeded | payment.succeeded | a late alert is honoured by merchant review |
| any open state | cancelled | *(none)* | POST /payment_sessions/:id/cancel; you asked, so you are not told |
succeeded | refunded / partially_refunded | payment.refunded | the merchant records a refund |
succeeded | disputed | payment.disputed | the merchant records a dispute |
There is no `payment.cancelled` event. An abandoned checkout is reported as payment.expired. GET /webhook_events returns this catalogue.
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"?, "replace"? } → 201 with "secret", once
GET /webhook_endpoints
GET /webhook_endpoints/:id
PATCH /webhook_endpoints/:id { "url"?, "events"?, "enabled"?, "description"? }
POST /webhook_endpoints/:id/rotate_secret → new "secret", once
DELETE /webhook_endpoints/:id
GET /webhook_events every event type, when it fires, and the state machineOmit events (or send ["*"]) to receive everything. Store the returned secret as DPAY_WEBHOOK_SECRET; it is never shown again.
Registration is idempotent per URL. URLs are normalised (host case, default port, trailing slash) and one endpoint exists per URL per merchant. Posting a URL that already exists returns 409 with the existing endpoint (and no secret) — so a redeploy that re-registers does not leave a second endpoint whose deliveries are signed with a secret you threw away. To rotate that endpoint instead, send "replace": true: the events and description are updated, the secret is rotated, and the new secret is returned once. If you have lost a secret, POST /webhook_endpoints/:id/rotate_secret returns a new one; the old one stops verifying immediately.
An unknown event name is refused with the full list of valid ones. There is no payment.cancelled; an abandoned checkout arrives as payment.expired.
test.ping is sent by Send test event on the Developers page. It is signed like every other event and delivered whether or not the endpoint subscribes to it, so it proves the URL and the secret together.
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. Deliveries come from 135.181.161.16 with user-agent DPay-Webhooks/1.0; allow that address if you filter, but trust the signature, not the IP. 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 300 seconds old in either direction. Your server's clock must be right for that check to pass — an NTP-synced clock is a requirement, not a nicety. Use the raw request bytes: re-serialising JSON breaks the signature, so read the body before any framework parses it ($request->getContent() / php://input, req.text() or express.raw, request.body in Django, request.raw_post in Rails).
// 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. The amount is shown in whole rupees ("PKR 1,900", never "PKR 1,900.00").
`success_url`. After verification the page offers a *Continue* button to your success_url with two parameters appended: dpay_session_id and dpay_status. Treat the arrival as a hint to look the session up, not as proof: a customer can open that URL at any point, including while payment is still verifying or after it expired, so your page must read the order's real state and handle *not yet confirmed*.
Lifetime. A session lives for expires_in seconds (15 minutes by default, 24 hours at most). When that ends the session becomes expired and payment.expired fires — for a customer who never paid as much as for one who was slow. A late alert can still be honoured by merchant review, which fires payment.succeeded then.
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_amount_paisa · wrong_account · duplicate · debitwrong_amount_paisa sends what a bank actually would: whole rupees, for a session that asked for paisa. It reproduces the failure that used to strand customers on "Verifying", and shows that matching now treats it as the same transfer.
The sandbox and production share one validation path — the same createSession, the same matcher, the same webhook code — so what the sandbox refuses, production refuses, and what it matches, production matches.
A 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.
Health, status and your own configuration
GET https://api.dpay.com.pk/v1/health # no key. 200 when the API, bank-alert ingest and webhook delivery are fine; 503 with Retry-After when not GET https://api.dpay.com.pk/v1/me # with a key. Says which merchant and mode it opens; creates nothing
A human-readable status page is at https://dpay.com.pk/status. Every 429 and 503 carries a Retry-After header, in seconds; honour it. Every merchant response carries x-response-time.
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.