# 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" } } ``` | 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=`, 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). | 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*. | ```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. | 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. ```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`. ## 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, "amount_paid": 500000, "fee": 6500, "net": 493500, "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 normally come from `158.220.98.167` 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, ".")`, 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 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](https://beta.dpay.com.pk/downloads/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](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](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 ``` | 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" }`. ```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`.