DPay API
DPay is payment infrastructure for Pakistan. You integrate once; each order gets its own 1Bill ID and QR, which your customer pays from any Pakistani bank or wallet app. The order is marked paid the moment it is paid, and DPay sends you a signed payment.succeeded webhook. Payments are collected into Downlabs' NayaPay Business account on your behalf and paid out to you every morning at 06:00 PKT, weekends included, less a flat fee per order (see *Payouts and fees*).
Base URL https://beta.dpay.com.pk/api/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.
- Verify your identity (Settings → Identity: the owner's CNIC). No payment can be taken until it is approved, because the money passes through DPay.
- Under DPay Pay, accept the terms and add a payout account (a NayaPay number or any bank IBAN). Payments switch on as soon as your identity is approved.
- Create an API key (Developers). Test and live keys both create real checkouts: there is no fake money, and the smallest order is PKR 100.
- From your server,
POST /payment_sessionsand redirect the customer tocheckout_url, or show the 1Bill ID yourself withPOST /payment_sessions/:id/instructions. - 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.
Send a User-Agent that names your application (MyShop/1.2 (+https://myshop.pk)). The API sits behind Cloudflare, and a request from a hosting provider's address with a generic or empty user agent can be mistaken for a bot and answered with an HTML 403 before it reaches DPay. A named client is never challenged.
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. NayaPay's 1Bill and QR payments carry no paisa, so 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)*. To have DPay round for you, send rounding: "up" or rounding: "nearest"; the session then carries the rounded amount. Rounding is never applied silently. The smallest order is PKR 100 (10000), NayaPay's minimum.
Amount, fee, net
The customer always pays exactly amount. fee is DPay's fee for the order (rupees per order, never a percentage: see *Payouts and fees*), and net is what reaches you in the morning payout. payable_amount equals amount and stays for integrations written before DPay Pay. amount_paid is null until the session has succeeded.
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 | Fix the request |
| 401 | unauthorized | Missing, wrong or revoked key |
| 402 | monthly_cap_reached | The business has taken its monthly limit through DPay (PKR 3 lakh unless DPay raised it). Ask DPay to raise it |
| 403 | payments_not_enabled | The business has not finished setting up (terms, payout account), its identity is not approved yet, it is on the waitlist, or DPay paused it. The message says which |
| 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 |
| 503 | unavailable, platform_capacity | 1Bill is down for a few minutes, or DPay has reached this month's capacity. Retry later |
| 5xx | internal | 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 order: an amount, a customer, a hosted page with its own 1Bill ID and QR, and a lifecycle that ends in succeeded, expired or cancelled.
Create
POST /payment_sessions
Whole rupees, PKR 100 or more.amountmust be a multiple of 100 paisa and at least10000.1838(PKR 18.38) is refused;1900with"rounding": "up"still is (under PKR 100).
| 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. |
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). The order's one 1Bill ID lasts exactly as long. When it ends the session becomes expired and payment.expired fires, and the order never gets a new 1Bill ID: the customer starts a new order. |
metadata | object | String key/values echoed on the object and every webhook. |
outlet | string | Multi-business: the outlet this payment is for, by its out_… id or your own code. See *Outlets*. |
curl https://beta.dpay.com.pk/api/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"}'// 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",
"payable_amount": 500000, "payable_amount_decimal": "5000.00",
"amount_paid": null, "amount_paid_decimal": null, "fee": 6500, "net": 493500,
"payment_method": "dpay_pay", "currency": "PKR",
"description": "Premium Product", "order_id": "ORD-1042",
"customer": { "name": null, "email": "ayesha@example.pk" },
"status": "awaiting_payment", "reference": "DPAY-8F4K29",
"checkout_url": "https://beta.dpay.com.pk/pay/dpay_ps_x7k2…",
"success_url": "https://yourstore.pk/thanks", "cancel_url": null,
"paid_at": null, "transaction_id": null, "expires_at": "2026-09-09T11:30:00.000Z",
"outlet": null, "metadata": {}, "livemode": false,
"created_at": "2026-09-09T11:00:00.000Z", "updated_at": "2026-09-09T11:00:00.000Z"
}Once paid, status is succeeded, paid_at says when and transaction_id is NayaPay's id for the payment.
Retrieve, list, cancel, show the 1Bill ID
GET /payment_sessions/:id
GET /payment_sessions?status=succeeded&outlet=rest-101&limit=20&starting_after=dpay_ps_…
POST /payment_sessions/:id/cancel
POST /payment_sessions/:id/instructions # this order's 1Bill ID and QR, to show in your own page (makes them on the first call)
POST /payment_sessions/:id/refund # { amount | amount_decimal?, note? } — record a refund you made; omit the amount for the rest
POST /payment_sessions/:id/dispute # { note? } opens a dispute; { outcome: "upheld" | "refunded", note? } settles itinstructions answers { "pay": { "status": "due", "onebill": "1001 2345 …", "qr": "000201…", "iban": "PK…", "expires_at": "…" }, "how_to_pay": "…" }. A pay.status of creating means the 1Bill ID is a second away: call again. On an expired order instructions answers 409 session_closed. Draw qr as a QR code if you show one.
A refund is money you return from your own account once you have been paid out; recording it moves the session to partially_refunded or refunded, fires payment.refunded, and shows on the object as amount_refunded and refund. A dispute moves it to disputed and fires payment.disputed; settling it returns to succeeded (upheld) or ends in refunded. Both are also on the payment's page in the dashboard.
Payment states
created → awaiting_payment ──── succeeded (the 1Bill ID or QR was paid)
└─── expired (window elapsed; a 1Bill ID paid late still succeeds)
also: cancelled · refunded · partially_refunded · disputedOnly status: "succeeded", read from the API or from a signature-verified webhook, means the order is paid. A redirect to success_url proves nothing; the customer can type that URL.
| From | To | Event | When |
|---|---|---|---|
| — | created | payment.created | POST /payment_sessions |
created | awaiting_payment | payment.pending | at once: the checkout is live |
awaiting_payment | succeeded | payment.succeeded | NayaPay reports the order's 1Bill ID or QR paid |
| any open state | expired | payment.expired | the window closes, whether or not the customer ever paid |
expired | succeeded | payment.succeeded | a 1Bill ID paid after the window still counts |
| 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. Sessions from before DPay Pay may still show the older states customer_claimed_paid, verification_pending and ambiguous.
Payouts and fees
Customers pay into Downlabs' NayaPay Business account on your behalf. NayaPay credits each day's payments to it at about 02:00 that night, and at 06:00 PKT every day, weekends included, DPay sends you one payout for everything paid before midnight, less its fees. Today's sales arrive tomorrow at 06:00.
Fees are rupees per order, never a percentage:
| Order amount | Fee per order |
|---|---|
| Rs 100 – 499 | Rs 7 |
| Rs 500 – 999 | Rs 12 |
| Rs 1,000 – 2,499 | Rs 22 |
| Rs 2,500 – 4,999 | Rs 39 |
| Rs 5,000 – 9,999 | Rs 65 |
| Rs 10,000 – 24,999 | Rs 119 |
| Rs 25,000 and above | Rs 199 |
A one-time setup fee of PKR 1,999 is taken from your first payouts; it is never asked for up front. A new business's first payout comes 3 days after it goes live; less than PKR 500 waits for the next morning; a changed payout account holds payouts for 24 hours. A business takes up to PKR 3 lakh a month through DPay unless DPay raises it.
GET /balance # what goes out at the next 06:00 run, and what was paid today (tomorrow's payout) GET /payouts?limit=20&starting_after=set_… GET /payouts/:id
A payout: { "id": "set_…", "object": "payout", "status": "ready" | "sent", "period": "2026-11-12", "orders": 14, "gross": 2450000, "fees": 64600, "setup_fee": 0, "amount": 2385400, "currency": "PKR", "destination": { "method": "bank", "title": "…", "last4": "6702", "bank": "Meezan Bank" }, "reference": "NP-88214077", "sent_at": "…" }. When it leaves, DPay sends payout.sent with the payout as data.object.
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, "amount_paid": 500000, "fee": 6500, "net": 493500, "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 normally come from 135.181.161.16 with user-agent DPay-Webhooks/1.0; during a failover they come from DPay's backup server, whose address changes, so verify the signature rather than filtering by 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.succeeded, payment.expired, payment.refunded, payment.disputed, payout.sent, invoice.created, invoice.paid, subscription.created, subscription.updated, subscription.cancelled. (payment.processing, payment.ambiguous and payment.failed belong to the bank-transfer flow DPay used before 11 Nov 2026 and are no longer sent.)
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 sees the amount, your name (and brand, if you set one) and this order's 1Bill ID and QR, valid until the order expires (15 minutes by default). An expired order is closed for good, like a card checkout: it never gets a new 1Bill ID. They pay it from any bank or wallet app (Bill payment → 1Bill → Invoice/Voucher → paste the ID, or scan the QR in the NayaPay app), and the page turns to *Paid* by itself within seconds. They choose nothing and type nothing into DPay.
`success_url`. Once paid, 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, so your page must read the order's real state and handle *not yet paid*.
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 1Bill ID paid after that still marks the order paid and fires payment.succeeded.
Demo accounts
DPay's own demo accounts (for app reviewers evaluating an integration) can mark an order paid without money: POST /payment_sessions/:id/simulate. Every other business gets 403 sandbox_unavailable: a real order is paid only by a real payment. Demo orders are never paid out.
A local webhook receiver that verifies signatures lives at https://beta.dpay.com.pk/api/webhooks/echo.
WooCommerce
There is an official plugin, so a WordPress shop needs no code at all: dpay-for-woocommerce.zip. Upload it under Plugins → Add New → Upload, activate, paste an API key under WooCommerce → Settings → Payments → DPay, and save: the plugin connects, registers its webhook and adds “Pay with any bank app (1Bill)” to both the classic and the block checkout. Orders are marked paid by webhook, when the customer returns, and by a five-minute sync as a safety net. Setup guide: https://beta.dpay.com.pk/docs/woocommerce.
Shopify
There is an official app for Shopify too: the customer picks DPay's manual payment method at checkout, the app gives the order a DPay checkout the moment it exists, shows the order's 1Bill ID on the thank-you and order-status pages, and marks the order paid the moment it is paid. Setup guide: https://beta.dpay.com.pk/docs/shopify.
Outlets
Multi-business. One DPay account can collect for many restaurants, branches or shops: a restaurant group, a chain, or a platform such as a POS or food-ordering app that takes payments for the businesses on it. Each of them is an outlet. The money still goes straight into your own receiving accounts and DPay still never holds it; the outlet is the label every payment carries, so you can see, export and settle each outlet's takings on its own.
What an outlet gives you:
- Every payment labelled. Send
outletwhen you create a payment session, by DPay's id (out_…) or by your own code for it, such as your restaurant id. The session, its webhooks and the API object all carryoutlet. - Filter by outlet.
GET /payment_sessions?outlet=…lists one outlet's payments; the dashboard's Payments page and its CSV export filter the same way, and the CSV hasoutletandoutlet_codecolumns. - A daily report per outlet. Under Outlets in the dashboard, download any day's takings as a PDF, for the whole business split by outlet or for one outlet alone: collected, paid, still open, needing review, by receiving account, and every paid payment. Built for end-of-day settlement with each outlet.
Every business may run up to 10 outlets; a custom arrangement can allow more (ask under *Plans and limits*). Creating one past that fails with 402 plan_limit_reached.
Create, list, update
POST /outlets { "name", "code"? } → 201 outlet
GET /outlets every outlet, archived ones too
GET /outlets/:id # :id is the out_… id or your code
PATCH /outlets/:id { "name"?, "code"?, "active"? } # active: false archives, true restores| Field | Type | Notes |
|---|---|---|
name | string, required | 2–80 characters, unique among active outlets. Shown in the dashboard and on reports. |
code | string | Your own id for the outlet: letters, digits, . - _ :, up to 64 characters, unique on the account. Send null in a PATCH to remove it. |
active | boolean | PATCH only. An archived outlet keeps its history and takes no new payments. |
Object: { "id": "out_…", "object": "outlet", "name", "code", "active", "created_at", "archived_at" }.
curl https://beta.dpay.com.pk/api/v1/outlets \
-H "Authorization: Bearer dpay_test_sk_…" \
-H "Content-Type: application/json" \
-d '{"name":"Gulberg branch","code":"rest-101"}'Payments for an outlet
curl https://beta.dpay.com.pk/api/v1/payment_sessions \
-H "Authorization: Bearer dpay_test_sk_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ORD-1042" \
-d '{"amount_decimal":"1250","description":"Table 4 · Order 1042","order_id":"ORD-1042","outlet":"rest-101"}'The response, and every webhook about that payment, carries "outlet": "out_…" (DPay's id, whichever you sent). A payment without an outlet has "outlet": null. An unknown or archived outlet is refused with 400 invalid_request rather than taken unlabelled, so a sale never lands in nobody's column.
Settling with outlets at night: list the day's succeeded payments with ?outlet=… (or read outlet off each payment.succeeded webhook as it arrives), add up amount_paid, and pay each outlet its share. The daily report PDF shows the same totals.
Plans and limits
There is no monthly plan and no payment-count limit: DPay keeps its fee per order from your payouts (*Payouts and fees*), and every business has every feature, including Checkout customization (logo, accent colour, trust line, support email on the hosted checkout). A business takes up to PKR 3 lakh a month through DPay unless DPay raises it (402 monthly_cap_reached). A platform with many outlets can ask for a custom arrangement at https://beta.dpay.com.pk/contact?plan=custom. Machine-readable reference: https://beta.dpay.com.pk/llms-full.txt.
Health, status and your own configuration
GET https://beta.dpay.com.pk/api/v1/health # no key. 200 when the API, the 1Bill bridge and webhook delivery are fine; 503 with Retry-After when not GET https://beta.dpay.com.pk/api/v1/me # with a key. Says which merchant and mode it opens; creates nothing
A human-readable status page is at https://beta.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.