Payment Gateway Integration Guide
Everything a developer needs to accept payments with Paperless — from creating a merchant account to receiving a verified payment notification. A hosted-checkout gateway for Bangladesh, built around a single, well-documented REST API.
Paperless is a hosted payment gateway. You call one server-to-server endpoint to create a payment session, redirect your customer to a Paperless-hosted checkout page, and receive the result back via redirect and a server-to-server IPN. Paperless handles provider selection, the payment UI, and confirmation — you never touch card or wallet credentials.
Who should use this
Merchants and developers integrating online payments into a website, mobile app, or backend. Because the API is server-to-server and IP-restricted, you integrate it from your backend, not the browser.
Supported payment methods
Payments are collected on the hosted checkout page. bKash (Tokenized Checkout) is the live provider; additional mobile-wallet and card providers are surfaced on the same checkout page as they come online, with no change to your integration.
High-level flow
Create a session → redirect the customer → they pay on the hosted page → Paperless redirects them back and fires an IPN → you verify and fulfil. The full picture is in Payment Flow.
X-App-Secret in frontend code, mobile apps, or browser requests.Base URL
Paperless uses a single base URL. There is no separate sandbox host — you switch between test and live behaviour with the mode field in the request body (SANDBOX or LIVE). Your API credentials are issued per mode.
| Environment | Base URL | Selected by |
|---|---|---|
| Production | https://api.paperlessltd.com | "mode": "LIVE" |
| Sandbox | https://api.paperlessltd.com | "mode": "SANDBOX" |
All endpoints are versioned under the /api/v1 prefix, for example:
https://api.paperlessltd.com/api/v1/payments/initiateContent-Type: application/json on every request that has a body.Merchant Onboarding
Before you can call the API you need an approved merchant account and a set of API credentials.
Create a merchant account
Sign up on the merchant portal with your business details.
Complete KYC
Submit the required business and identity documents for verification.
Wait for approval
Our team reviews your submission. You’ll be notified once the account is approved.
Log in to the merchant panel
Access your dashboard to manage payments, refunds, and settings.
Generate API credentials
Create your App Key and App Secret for each mode (sandbox / live).
Configure the IP whitelist
Add the public IP(s) of the server that will call the API. Requests from other IPs are rejected.
403 FORBIDDEN.API Credentials
Server-to-server calls are authenticated with an App Key and an App Secret, generated in the merchant panel and issued separately for each mode. Dashboard read endpoints use a JWT from the auth service instead.
X-App-Key
Identifies your merchant account on server-to-server calls. Sent on every initiate request.
X-App-Secret
Proves the request is really from you. Treat it like a password — server-side only.
merchant_id
A UUID identifying your account. Returned on payment objects — you don’t send it; it’s resolved from your credentials.
Find and rotate these under Merchant Panel → Settings → API Credentials. Each mode has its own key pair, so a SANDBOX key never touches real money.
X-App-Secret must never appear in frontend applications, mobile app bundles, browser network calls, or version control. If it leaks, rotate it immediately from the panel.IP Whitelist
Every server-to-server request is checked against your merchant IP allowlist. This is a hard security boundary: even with a correct App Key and Secret, a request from an unlisted IP is rejected before it reaches the payment logic.
Why it’s required
It ensures that a leaked credential pair cannot be used from an attacker’s machine. Only traffic originating from your known server IPs is trusted.
How to add one
In Merchant Panel → Settings, add the public IPv4 address of the server that will call the API. Add every server that makes calls (app servers, workers, staging). Paperless honours the real client IP from X-Real-IP / X-Forwarded-For when you sit behind a reverse proxy.
What happens if the IP isn’t whitelisted
| Condition | Type | Description |
|---|---|---|
| No IPs configured | 403 | no whitelisted IPs configured — please add your server IP in the merchant panel |
| IP not in the list | 403 | request origin IP is not whitelisted for this merchant |
curl https://api.ipify.org to see the exact public IP Paperless will observe. Add that value to the allowlist.Authentication
Paperless uses different auth for different audiences:
| Used for | Mechanism | Endpoints |
|---|---|---|
| Server-to-server | App Key + Secret headers + mode in body + IP whitelist | POST /payments/initiate |
| Merchant dashboard | JWT Bearer from the auth service | GET /payments, /refunds, logs, timeline |
| Status lookup | Public — anyone with the payment UUID | GET /payments/{id} |
Server-to-server headers
Pass both credential headers on every initiate call. There is no request-body signature — the App Secret header is the proof of identity.
X-App-Key: app_key_live_9f3a2c8d4b7e1a06 X-App-Secret: app_secret_live_c1d4e7f0a3b6c9d2 Content-Type: application/json
Dashboard (JWT) header
Read endpoints expect a bearer token obtained by logging in to the auth service:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Payment Flow
The end-to-end lifecycle of a single payment, from your server to a verified result:
GET /payments/{id}.Initiate Payment
Creates a payment session and returns a payment_url. Redirect your customer there to complete the payment. Call this from your server only.
Headers
| Header | Type | Description | |
|---|---|---|---|
| X-App-Key | string | Required | Your merchant App Key. |
| X-App-Secret | string | Required | Your merchant App Secret — keep private. |
| Content-Type | string | Required | Must be application/json. |
Request body
| Field | Type | Description | |
|---|---|---|---|
| mode | string | Required | SANDBOX for testing, LIVE for real transactions. Also validated during authentication. |
| order_id | string | Required | Your order reference. Multiple payment attempts may share the same value. |
| total_amount | string | Required | Amount to charge as a positive decimal string, e.g. "1500.00". Not an integer. |
| currency | string | Required | ISO 4217 code. Only BDT is supported currently. |
| success_url | string | Required | Redirect URL after a successful payment. |
| fail_url | string | Required | Redirect URL after a failed payment. |
| cancel_url | string | Required | Redirect URL when the customer cancels. |
| customer_name | string | Optional | Customer’s full name. |
| customer_email | string | Optional | Customer’s email address. |
| customer_phone | string | Optional | Customer’s phone number, e.g. 01711000000. |
| ipn_url | string | Optional | Server-to-server POST notification URL, called on every terminal state. See IPN. |
Validation rules
modeis required and must be exactlySANDBOXorLIVE.total_amountmust be a positive decimal string, e.g."1500.00".currencymust beBDT.success_url,fail_url, andcancel_urlare all required and must be valid URLs.
Responses
{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"token": "chk_7f3a2c8d4b7e1a06c1d4e7f0",
"payment_url": "https://pay.paperlessltd.com/checkout/chk_7f3a2c8d4b7e1a06c1d4e7f0",
"status": "pending",
"expires_at": "2026-06-17T10:05:00Z"
}{
"code": "BAD_REQUEST",
"message": "mode is required in request body (SANDBOX or LIVE)",
"request_id": "req_a1b2c3d4e5f6"
}{
"code": "UNAUTHORIZED",
"message": "X-App-Key and X-App-Secret headers are required",
"request_id": "req_a1b2c3d4e5f6"
}{
"code": "FORBIDDEN",
"message": "request origin IP is not whitelisted for this merchant",
"request_id": "req_a1b2c3d4e5f6"
}Code samples
curl -X POST https://api.paperlessltd.com/api/v1/payments/initiate \ -H "X-App-Key: app_key_live_9f3a2c8d4b7e1a06" \ -H "X-App-Secret: app_secret_live_c1d4e7f0a3b6c9d2" \ -H "Content-Type: application/json" \ -d '{ "mode": "LIVE", "order_id": "ORD-2026-001", "total_amount": "1500.00", "currency": "BDT", "success_url": "https://yoursite.com/payment/success", "fail_url": "https://yoursite.com/payment/fail", "cancel_url": "https://yoursite.com/payment/cancel", "customer_name": "Rahim Uddin", "customer_email": "rahim@example.com", "customer_phone": "01711000000", "ipn_url": "https://yoursite.com/payment/ipn" }'
// Run this on your SERVER — never in the browser. const res = await fetch("https://api.paperlessltd.com/api/v1/payments/initiate", { method: "POST", headers: { "X-App-Key": process.env.PAPERLESS_APP_KEY, "X-App-Secret": process.env.PAPERLESS_APP_SECRET, "Content-Type": "application/json", }, body: JSON.stringify({ mode: "LIVE", order_id: "ORD-2026-001", total_amount: "1500.00", currency: "BDT", success_url: "https://yoursite.com/payment/success", fail_url: "https://yoursite.com/payment/fail", cancel_url: "https://yoursite.com/payment/cancel", ipn_url: "https://yoursite.com/payment/ipn", }), }); const data = await res.json(); // Redirect the customer to the hosted checkout: res.status === 201 && redirect(data.payment_url);
const axios = require("axios"); const { data } = await axios.post( "https://api.paperlessltd.com/api/v1/payments/initiate", { mode: "LIVE", order_id: "ORD-2026-001", total_amount: "1500.00", currency: "BDT", success_url: "https://yoursite.com/payment/success", fail_url: "https://yoursite.com/payment/fail", cancel_url: "https://yoursite.com/payment/cancel", ipn_url: "https://yoursite.com/payment/ipn", }, { headers: { "X-App-Key": process.env.PAPERLESS_APP_KEY, "X-App-Secret": process.env.PAPERLESS_APP_SECRET, }, } ); // data.payment_url → send the customer here
$ch = curl_init("https://api.paperlessltd.com/api/v1/payments/initiate"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "X-App-Key: " . getenv("PAPERLESS_APP_KEY"), "X-App-Secret: " . getenv("PAPERLESS_APP_SECRET"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "mode" => "LIVE", "order_id" => "ORD-2026-001", "total_amount" => "1500.00", "currency" => "BDT", "success_url" => "https://yoursite.com/payment/success", "fail_url" => "https://yoursite.com/payment/fail", "cancel_url" => "https://yoursite.com/payment/cancel", "ipn_url" => "https://yoursite.com/payment/ipn", ]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); // header("Location: " . $data["payment_url"]);
import os, requests resp = requests.post( "https://api.paperlessltd.com/api/v1/payments/initiate", headers={ "X-App-Key": os.environ["PAPERLESS_APP_KEY"], "X-App-Secret": os.environ["PAPERLESS_APP_SECRET"], }, json={ "mode": "LIVE", "order_id": "ORD-2026-001", "total_amount": "1500.00", "currency": "BDT", "success_url": "https://yoursite.com/payment/success", "fail_url": "https://yoursite.com/payment/fail", "cancel_url": "https://yoursite.com/payment/cancel", "ipn_url": "https://yoursite.com/payment/ipn", }, timeout=15, ) data = resp.json() # redirect(data["payment_url"])
payment_url. The session (and its token) expires at expires_at; if the customer waits too long they must restart.Redirect Flow
When the payment reaches a terminal state, Paperless redirects the customer’s browser back to one of the three URLs you supplied, with result parameters appended to the query string.
| URL | Called when |
|---|---|
| success_url | The payment completed successfully. |
| fail_url | The payment failed at the provider level. |
| cancel_url | The customer cancelled or abandoned the checkout. |
Returned query parameters
| Param | Type | Description |
|---|---|---|
| payment_id | string | The Paperless payment UUID. Present on all statuses. |
| transaction_id | string | The Paperless transaction reference. Present on all statuses. |
| order_id | string | Your original order reference. Present on all statuses. |
| status | string | One of success, failed, cancelled. Present on all statuses. |
| amount | string | Amount charged. Present on success only. |
| currency | string | Currency code. Present on success only. |
Example success redirect
https://yoursite.com/payment/success ?payment_id=550e8400-e29b-41d4-a716-446655440000 &transaction_id=TXN-20260617-A4F2 &order_id=ORD-2026-001 &status=success &amount=1500.00 ¤cy=BDT
IPN — Instant Payment Notification
If you supply an ipn_url when initiating, Paperless sends a server-to-server POST to that URL the moment the payment reaches a terminal state — even if the customer closed their browser before the redirect. This is the most reliable signal that a payment finished.
Why trust IPN over the redirect
The redirect depends on the customer’s browser completing the round-trip. The IPN is sent directly from Paperless to your server, so it arrives regardless of what the customer’s device does.
Request
A POST with Content-Type: application/json and the payload below. paid_at is present only when status is success.
{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"transaction_id": "TXN-20260617-A4F2",
"order_id": "ORD-2026-001",
"status": "success",
"amount": "1500.00",
"currency": "BDT",
"merchant_receivable": "1455.00",
"paid_at": "2026-06-17T10:05:00Z",
"timestamp": "2026-06-17T10:05:01Z"
}Verification & retry behaviour
The IPN is not cryptographically signed today, and it is delivered as a single attempt with a 5-second timeout (there is no automatic redelivery). Because of both facts, the IPN is a trigger, not proof: when you receive one, call GET /api/v1/payments/{payment_id} and trust that response.
200 OK status code. This confirms successful receipt of the notification and prevents our system from retrying the request.Recommended implementation
// POST /payment/ipn — called server-to-server by Paperless. app.post("/payment/ipn", express.json(), async (req, res) => { const { payment_id, status } = req.body; // 1) Respond fast so Paperless doesn't time out (5s limit). res.sendStatus(200); // 2) Idempotency — skip if we already finalized this payment. if (await alreadyProcessed(payment_id)) return; // 3) NEVER trust the IPN body alone — re-fetch the source of truth. const verify = await fetch( `https://api.paperlessltd.com/api/v1/payments/${payment_id}` ).then((r) => r.json()); if (verify.status === "success") { await fulfilOrder(verify.order_id, verify.total_amount); } });
// POST /payment/ipn.php $body = json_decode(file_get_contents("php://input"), true); http_response_code(200); // ack immediately $paymentId = $body["payment_id"]; if (already_processed($paymentId)) return; // Re-fetch the authoritative status before fulfilling. $verify = json_decode(file_get_contents( "https://api.paperlessltd.com/api/v1/payments/" . $paymentId ), true); if ($verify["status"] === "success") { fulfil_order($verify["order_id"], $verify["total_amount"]); }
Verify Payment
The source of truth for any payment is its detail endpoint. It is public — anyone with the payment UUID can read the latest status, so it’s safe to call from your backend (or even a status page) without credentials.
Returns status, the fee breakdown, provider details, customer info, and timestamps.
curl https://api.paperlessltd.com/api/v1/payments/550e8400-e29b-41d4-a716-446655440000{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"order_id": "ORD-2026-001",
"transaction_id": "TXN-20260617-A4F2",
"status": "success",
"mode": "LIVE",
"currency": "BDT",
"total_amount": "1500.00",
"actual_paid_amount": "1500.00",
"gateway_fee": "30.00",
"vat_amount": "15.00",
"total_fee": "45.00",
"merchant_receivable": "1455.00",
"provider": "bkash",
"provider_trx_id": "BKH8736210000",
"customer_name": "Rahim Uddin",
"customer_email": "rahim@example.com",
"customer_phone": "01711000000",
"paid_at": "2026-06-17T10:05:00Z",
"created_at": "2026-06-17T10:00:00Z",
"updated_at": "2026-06-17T10:05:00Z"
}Payment statuses
status is success. Cross-check total_amount and currency against your order before releasing goods.Refund Flow
Refunds follow a review workflow. A merchant requests the refund from the panel; an admin issues it. Issuing is admin-only — merchant credentials can read refunds but cannot create them.
Refund statuses
A refund starts pending while the provider processes it, then settles to succeeded or failed (with a failure_reason).
Issue a refund Admin
{id} is the payment UUID. Full or partial amounts are supported.
| Field | Type | Description | |
|---|---|---|---|
| amount | string | Required | Amount to refund as a positive decimal string. Must not exceed the paid amount. |
| reason | string | Required | Reason for the refund — stored for audit and shown in the panel. |
| Status | Meaning |
|---|---|
| 200 | Refund accepted / succeeded. |
| 400 | Invalid amount or the payment is not refundable. |
| 409 | A refund is already in progress for this payment. |
| 422 | The provider does not support refunds. |
| 502 | The provider failed to process the refund. |
List refunds JWT
Paginated, newest first. Filter with ?payment_id= or ?status=.
curl https://api.paperlessltd.com/api/v1/refunds \
-H "Authorization: Bearer <JWT>"{
"data": [
{
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": "500.0000",
"reason": "customer request",
"status": "succeeded",
"provider_refund_trx_id": "RFND12345",
"created_at": "2026-06-30T10:15:00Z"
}
],
"total": 1,
"page": 1,
"limit": 20,
"total_pages": 1
}To read one refund by its refund id, call GET /api/v1/refunds/{id} (same object shape).
Error Handling
Every error returns a consistent JSON body. code is a stable machine-readable string, message is human-readable, and request_id identifies the request in our logs — include it when contacting support.
{
"code": "FORBIDDEN",
"message": "no whitelisted IPs configured — please add your server IP in the merchant panel",
"request_id": "req_a1b2c3d4e5f6"
}| HTTP | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Missing or malformed fields — e.g. no mode, invalid amount, bad JSON. |
| 401 | UNAUTHORIZED | Missing/invalid X-App-Key + X-App-Secret, or an invalid/expired JWT on dashboard routes. |
| 403 | FORBIDDEN | Credentials are valid but the request-origin IP is not whitelisted (or no IPs are configured). |
| 404 | NOT_FOUND | The payment, refund, or route does not exist. |
| 409 | CONFLICT | The resource is in a conflicting state — e.g. a refund is already in progress, or the payment was already processed. |
| 422 | UNPROCESSABLE | The provider does not support this operation (e.g. a refund it cannot process). |
| 429 | RATE_LIMITED | Too many requests from your IP. Slow down and retry with backoff. |
| 500 | INTERNAL_SERVER_ERROR | Unexpected server error. Retry later; contact support with the request_id if it persists. |
| 502 | PROVIDER_ERROR | The upstream provider (e.g. bKash) returned an error. Safe to retry. |
| 503 | SERVICE_UNAVAILABLE | A dependency (merchant service) is temporarily unavailable. Retry shortly. |
Webhook / IPN Best Practices
Re-verify before acting
Since the IPN is unsigned, always call GET /payments/{id} and trust that response, not the POST body.
Prevent duplicate processing
Key your fulfilment on payment_id (or order_id) and make it idempotent — a repeated notification must not double-ship or double-credit.
Store transaction IDs
Persist payment_id and transaction_id so you can reconcile against your orders and our dashboard.
Respond quickly
Acknowledge with 200 within the 5-second window, then do the heavy work asynchronously.
Handle missed deliveries
There is no automatic retry, so run a periodic reconciliation job that verifies any payment still marked pending on your side.
Security Best Practices
- Never expose the App Secret. Keep it in server-side environment variables, never in client code or git.
- Always use HTTPS for your success / fail / cancel / IPN URLs.
- Verify every payment with
GET /payments/{id}before fulfilling — never trust the redirect or raw IPN. - Keep your IP allowlist tight. List only the servers that actually call the API, and remove stale entries.
- Make all API calls server-side. The browser must never see your credentials.
- Rotate credentials periodically, and immediately if you suspect a leak.
- Validate amounts and currency from the verified payment against your own order before releasing goods.
Testing
Test end-to-end without moving real money by sending "mode": "SANDBOX" with your sandbox App Key / Secret. Everything else — the base URL, endpoints, and payload shape — is identical to live.
Generate sandbox credentials
Create a sandbox App Key / Secret pair in the merchant panel.
Whitelist your test server IP
The IP check applies in sandbox too — add your dev/staging server IP.
Initiate with mode: SANDBOX
Redirect to the returned payment_url and complete the flow on the sandbox checkout.
Exercise every outcome
Drive a payment to success, failure, and cancel, and confirm your redirect + IPN handlers behave for each.
SDK Examples
There is no proprietary SDK to install — the API is plain HTTPS + JSON, so any HTTP client works. The code samples above cover the core initiate call; the cards below point at the idiomatic client for each stack.
Laravel (Http facade)
$res = Http::withHeaders([ 'X-App-Key' => config('services.paperless.key'), 'X-App-Secret' => config('services.paperless.secret'), ])->post('https://api.paperlessltd.com/api/v1/payments/initiate', [ 'mode' => 'LIVE', 'order_id' => $order->id, 'total_amount' => number_format($order->total, 2, '.', ''), 'currency' => 'BDT', 'success_url' => route('pay.success'), 'fail_url' => route('pay.fail'), 'cancel_url' => route('pay.cancel'), 'ipn_url' => route('pay.ipn'), ]); return redirect($res->json('payment_url'));
FAQ
Why am I getting 401 Unauthorized?
Either the X-App-Key / X-App-Secret headers are missing, or the pair is invalid for the mode you sent. Both headers are required on every /payments/initiate call, and the credentials are mode-specific — a LIVE key will not authenticate a SANDBOX request.
On dashboard routes (/payments, /refunds), a 401 means the Authorization: Bearer JWT is missing or expired.
Why is my IP blocked (403 Forbidden)?
Server-to-server calls only work from a whitelisted IP. A 403 means your request’s origin IP is not in the merchant’s allowlist — or no IPs are configured at all. Add your production server’s public IP in the merchant panel. Requests from a browser or an unlisted IP are rejected.
The redirect said success but my order still shows unpaid — why?
The redirect is a browser event and can be tampered with or interrupted. Never fulfil an order from the redirect alone. Confirm the final state server-side with GET /api/v1/payments/{id} (or wait for the IPN) before marking the order paid.
How do refunds work?
A merchant requests a refund from the merchant panel. An admin reviews and issues it via POST /api/v1/admin/payments/{id}/refund. You then track the outcome — pending → succeeded or failed — through GET /api/v1/refunds. See the Refund Flow.
Does the IPN include a signature I can verify?
The IPN is a plain JSON POST and is not cryptographically signed today. Treat it as a notification, not proof. The authoritative check is to call GET /api/v1/payments/{payment_id} and read status from the response before fulfilling. See IPN best practices.
What amount format does the API expect?
Amounts are positive decimal strings — "1500.00", not the integer 150000. The only supported currency is BDT.
API Reference
Every endpoint in v1, with the authentication each one requires. Paths are relative to https://api.paperlessltd.com.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/payments/initiate | Create a payment session and get a checkout URL. | App Key + Secret |
| GET | /api/v1/payments/{id} | Get full details / status of a single payment. | Public |
| GET | /api/v1/payments | List your payments (paginated, filterable). | JWT Bearer |
| GET | /api/v1/payments/{id}/logs | Status-change history for a payment. | JWT Bearer |
| GET | /api/v1/payments/{id}/timeline | Customer journey / funnel for a payment. | JWT Bearer |
| GET | /api/v1/refunds | List refunds (merchant sees own, admin sees all). | JWT Bearer |
| GET | /api/v1/refunds/{id} | Get a single refund’s details. | JWT Bearer |
| POST | /api/v1/admin/payments/{id}/refund | Issue a refund on a payment (admin only). | JWT Bearer (admin) |
/swagger/index.html on the API host, and the OpenAPI document powers this guide.