# 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" } } ``` | 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` > **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. | 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. | ```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. 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. ```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=,v1=`), `DPay-Event-Id`, `DPay-Event-Type`, `Idempotency-Key` (same as the event id). Respond `2xx` within 10 seconds. Deliveries come from `158.220.98.167` 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, ".")`, 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 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`.