# 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" } } ``` | 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=`, 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. | ```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. ## Payment links 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=,v1=`), `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, ".")`, 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 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`.