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.

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

FieldTypeNotes
amountintegerPaisa. Or send amount_decimal.
amount_decimalstring or numberPKR with up to 2 decimals.
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.
expires_inintegerSeconds the customer has to pay, 120–86400. Default 900.
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":"ayesha@example.pk"},"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": "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

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.

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"?: ["payment.succeeded", …] }   → includes "secret" once
GET    /webhook_endpoints
DELETE /webhook_endpoints/:id

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

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

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.

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_account · duplicate · debit

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.

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.