NewVerification layer for wallet payments. DPay can now apply a small adjustment to the amount wallet payers are asked to send, so same-amount payments verify automatically instead of waiting for manual review. Recommended — enable it in Settings. amount_paid reports what was received.Read more

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

  1. Create an account and name your business.
  2. Verify your identity (Settings → Identity: the owner's CNIC). No payment can be taken until it is approved, because the money passes through DPay.
  3. 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.
  4. 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.
  5. From your server, POST /payment_sessions and redirect the customer to checkout_url, or show the 1Bill ID yourself with POST /payment_sessions/:id/instructions.
  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.

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:

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_subscriptionFix the request
401unauthorizedMissing, wrong or revoked key
402monthly_cap_reachedThe business has taken its monthly limit through DPay (PKR 3 lakh unless DPay raised it). Ask DPay to raise it
403payments_not_enabledThe 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
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
503unavailable, platform_capacity1Bill is down for a few minutes, or DPay has reached this month's capacity. Retry later
5xxinternalRetry 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. amount must be a multiple of 100 paisa and at least 10000. 1838 (PKR 18.38) is refused; 1900 with "rounding": "up" still is (under PKR 100).
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.
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). 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.
metadataobjectString key/values echoed on the object and every webhook.
outletstringMulti-business: the outlet this payment is for, by its out_… id or your own code. See *Outlets*.
bash
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"}'
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",
  "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

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

instructions 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

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

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

FromToEventWhen
—createdpayment.createdPOST /payment_sessions
createdawaiting_paymentpayment.pendingat once: the checkout is live
awaiting_paymentsucceededpayment.succeededNayaPay reports the order's 1Bill ID or QR paid
any open stateexpiredpayment.expiredthe window closes, whether or not the customer ever paid
expiredsucceededpayment.succeededa 1Bill ID paid after the window still counts
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. 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 amountFee per order
Rs 100 – 499Rs 7
Rs 500 – 999Rs 12
Rs 1,000 – 2,499Rs 22
Rs 2,500 – 4,999Rs 39
Rs 5,000 – 9,999Rs 65
Rs 10,000 – 24,999Rs 119
Rs 25,000 and aboveRs 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.

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

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, "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).

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 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 outlet when 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 carry outlet.
  • 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 has outlet and outlet_code columns.
  • 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

http
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
FieldTypeNotes
namestring, required2–80 characters, unique among active outlets. Shown in the dashboard and on reports.
codestringYour own id for the outlet: letters, digits, . - _ :, up to 64 characters, unique on the account. Send null in a PATCH to remove it.
activebooleanPATCH only. An archived outlet keeps its history and takes no new payments.

Object: { "id": "out_…", "object": "outlet", "name", "code", "active", "created_at", "archived_at" }.

bash
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

bash
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

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

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.