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

  1. Create an account and name your business.
  2. Add a receiving account under Receiving accounts: NayaPay (recommended, verifies in seconds) or Meezan Bank (beta, alerts arrive in 3–5 minutes).
  3. Connect the Gmail inbox your bank alerts arrive in (Integrations).
  4. Create an API key (Developers). Test keys work on every plan; live keys need a paid plan.
  5. From your server, POST /payment_sessions and redirect the customer to checkout_url.
  6. Handle payment.succeeded on 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.

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

json
{ "error": { "type": "invalid_request_error", "code": "invalid_amount", "message": "Amount must be at least PKR 1.00" } }
StatuscodeMeaning
400invalid_request, invalid_amount, invalid_invoice, invalid_subscription, no_payment_methodsFix the request
401unauthorizedMissing, wrong or revoked key
402plan_limit_reached, plan_requiredThe merchant's plan blocks this; upgrade in Billing
404not_foundNo such object for this merchant
409session_closed, illegal_transition, invoice_paid, invoice_void, subscription_cancelledThe object is in a state that forbids this
429rate_limited60 requests then 1/second per key; honour retry_after
5xxinternal, billing_not_configuredRetry 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. amount must be a multiple of 100 paisa. 1838 (PKR 18.38) is refused; 1900 is accepted; 1838 with "rounding": "up" becomes 1900. No Pakistani bank sends paisa, so a session that asks for it can never be paid.
FieldTypeNotes
amountintegerPaisa, and a whole number of rupees (a multiple of 100). 1900 is PKR 19; 1838 is refused. Or send amount_decimal.
amount_decimalstring or numberPKR. "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.
descriptionstring, requiredShown to the customer at checkout.
order_idstringYour reference; echoed back.
customerobject{ "name", "email" }. An email builds a customer record on success.
payment_methodsstring[]Which of your receiving accounts may be used: nayapay, meezan. Defaults to all you have enabled.
success_url, cancel_urlurlWhere the customer goes afterwards. DPay appends dpay_session_id and dpay_status to success_url. See *What the customer sees*.
expires_inintegerSeconds 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.
metadataobjectString key/values echoed on the object and every webhook.
bash
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"}'
js
// 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:

json
{
  "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

http
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

text
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 · disputed

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

FromToEventWhen
createdpayment.createdPOST /payment_sessions
createdawaiting_paymentpayment.pendingthe customer picks a bank at checkout
awaiting_paymentcustomer_claimed_paidpayment.processingthe customer presses I have sent the payment
customer_claimed_paidverification_pendingpayment.processinga verification run starts
verification_pendingsucceededpayment.succeededone bank credit passes every required check
verification_pendingambiguouspayment.ambiguousmore than one credit could be this payment; held for review
ambiguoussucceeded / failedpayment.succeeded / payment.failedthe merchant resolves it
any open stateexpiredpayment.expiredthe window closes, whether or not the customer ever paid
expiredsucceededpayment.succeededa late alert is honoured by merchant review
any open statecancelled*(none)*POST /payment_sessions/:id/cancel; you asked, so you are not told
succeededrefunded / partially_refundedpayment.refundedthe merchant records a refund
succeededdisputedpayment.disputedthe merchant records a dispute

There is no `payment.cancelled` event. An abandoned checkout is reported as payment.expired. GET /webhook_events returns this catalogue.

A reusable link: every open creates a fresh payment session with its own reference.

http
POST /payment_links            { "title", "amount" | "amount_decimal", "description"?, "payment_methods"? }
GET  /payment_links
GET  /payment_links/:id
POST /payment_links/:id/deactivate

Object: { "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.

http
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/void

Object: { "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.

http
POST /subscriptions   { "customer": {"name","email"}, "description", "amount", "start_at"? }
GET  /subscriptions?status=active|paused|cancelled
GET  /subscriptions/:id                 # includes its invoices
POST /subscriptions/:id/cancel

Object: { "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.

http
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.

http
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

http
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 machine

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

json
{ "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).

js
// 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
# 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 // 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:

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

wrong_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

http
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.

Building with an AI assistant?

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.