PayFlow

API reference

One REST endpoint per action, JSON in and JSON out, and an HMAC signature on every request. There is no SDK to install and nothing to keep updated — if your language can POST JSON and compute an HMAC, you have everything you need.

This page covers taking a payment. For reading back what happened — reports, settlements, lookups and your running balance — see the reporting API, which uses the same credential.

You do not have to integrate at all to get paid. Every merchant can raise a payment by hand from payment links in the dashboard, which produces the same checkout page, the same fee and the same settlement as the endpoints below. This API is for when you want that to happen automatically.

The basics

Base URL
https://payflow.promoticsstudio.com Every path on this page is relative to it.
Method
POST on every endpoint, including the one that only reads a payment’s status. Parameters travel in the body, because a query string would leave them in access logs, browser history and proxy caches.
Body
JSON with Content-Type: application/json, or ordinary application/x-www-form-urlencoded. Both are read the same way, so use whichever your HTTP client makes easy.
Currency
PKR, uppercase. Anything else is refused as a validation error rather than corrected, because normalising it would change the text your signature was computed over.
Authentication
Three extra fields on every request — merchant_id, timestamp and signature — sent in the body alongside everything else. There is no authorization header and no bearer token to refresh.

Your API key is a reference, not a request field: it names a credential pair in your dashboard and in our audit trail, and it is never sent. Requests are identified by merchant_id and proved by signature. Your API secret is the HMAC key and never leaves your server.

Signing a request

Join the values the endpoint asks for with a pipe character, in the order given, then take the HMAC-SHA256 of that string keyed with your API secret and send the hex digest as signature. Nothing is hashed twice, nothing is base64-encoded, and the field order in your JSON body does not matter — only the order inside the signing string does.

Two rules that account for most first-day failures

  1. Sign the amount in exactly the form you send it — two decimal places, always. 1999.90, never 1999.9; 500.00, never 500. If the two differ by so much as a trailing zero the digests will not match, and the failure arrives as PGW_401_AUTH, which looks like a credential problem and is not.
  2. Sign the currency exactly as you send it: uppercase PKR. We do not fold case for you, precisely so a casing slip is reported as the validation error it is instead of resurfacing as a signature mismatch you cannot see the cause of.

The timestamp

Unix seconds, UTC. A request is accepted only while its timestamp is within 10 minutes either side of our clock, so a captured request cannot be replayed tomorrow — and a server whose clock has drifted will start failing with PGW_400_TIMESTAMP. If you see that code, check NTP before you check your code.

A worked example to test against

Known-good

These values are not live, but the digest below is computed by the same code that verifies your requests. Feed the same inputs to your own HMAC and you should get the same string back — if you do, your signing is correct and any later PGW_401_AUTH is about the values, not the algorithm.

Example secret
pgw_sk_example0000000000000000000000000000000000000000
Fields
merchant_id PGW-M-000123
amount 1999.99
currency PKR
order_ref ORDER-4417
customer_email buyer@example.com
timestamp 1757000000

Signing string

PGW-M-000123|1999.99|PKR|ORDER-4417|buyer@example.com|1757000000

signature

9f9c14f5283314151f29ca18518f52aac73a1713e713f9ef7d1d0f79a3044cf5

The same thing in code

$secret = 'pgw_sk_example0000000000000000000000000000000000000000';   // from your dashboard; server-side only

$fields = [
    'merchant_id'    => 'PGW-M-000123',
    'amount'         => number_format(1999.99, 2, '.', ''),   // "1999.99"
    'currency'       => 'PKR',
    'order_ref'      => 'ORDER-4417',
    'customer_email' => 'buyer@example.com',
    'timestamp'      => time(),
];

// The array is already in signing order, so imploding it is the signing string.
$fields['signature'] = hash_hmac('sha256', implode('|', $fields), $secret);

$curl = curl_init('https://payflow.promoticsstudio.com/api/v1/payments/');
curl_setopt_array($curl, [
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS     => json_encode($fields),
    CURLOPT_RETURNTRANSFER => true,
]);

$payment = json_decode((string) curl_exec($curl), true);
// -> ['status' => 'ok', 'txn_id' => '...', 'checkout_url' => '...', ...]

Imploding the array works only while it stays in the order the formula specifies. If that makes you nervous, build the string explicitly — every endpoint below prints its own.

Node

const signature = crypto.createHmac('sha256', secret)
    .update(parts.join('|')).digest('hex');

Python

signature = hmac.new(secret.encode(), '|'.join(parts).encode(),
                     hashlib.sha256).hexdigest()

The payment flow

Creating a payment and charging it are deliberately two calls. The first reserves the amount and your order reference and hands back an id; the second is where the customer’s wallet gets involved. That split is what lets you keep one integration whether the customer pays on our checkout page or on yours.

  1. Create the payment. You get a txn_id and a checkout_url. Nothing has been charged and the customer has not been contacted.
  2. Collect the payment method. Send the customer to checkout_url and we handle it — wallet choice, mobile number, confirmation screen, retries, and the outcome page. Or collect those details on your own page and call one of the three charge endpoints yourself.
  3. The customer confirms on their handset. This is the part neither of us controls. A wallet charge answers 202 while it is pending, so treat that as “asked, not answered” and do not fulfil the order on it.
  4. Take the outcome. We POST it to your IPN URL if you have set one, and you can ask for it at any time with the status endpoint. Both read the same ledger, so they cannot disagree; the webhook is just faster than polling.

A payment session lasts 25 minutes from creation. After that the charge endpoints answer PGW_410_EXPIRED and you create a new payment — so create one when the customer reaches checkout, not when they fill their basket.

Endpoints

merchant_id, timestamp and signature are required on every request below and are not repeated in the field tables. Only the endpoint-specific fields are listed.

Create a payment

POST

/api/v1/payments/

Registers the payment and returns the hosted checkout link to send your customer to. Nothing is charged yet.

FieldRequiredNotes
amount Yes Two decimal places, from 1.00 to 999999999.99. More than two decimals is rejected rather than rounded, because rounding would disagree with what you signed.
currency Yes Uppercase PKR.
order_ref Yes Your own reference, up to 100 characters, unique within your account. Sending one twice answers PGW_409_DUPLICATE instead of creating a second payment — which makes a retry after a timeout safe.
customer_email Yes Up to 255 characters, and must parse as an email address. It is where the receipt goes.
return_url No Absolute http(s) URL to send the customer back to, overriding the default on your account. It must be on a domain registered to you, or the call is refused with PGW_403_DOMAIN.

Signing string

merchant_id|amount|currency|order_ref|customer_email|timestamp

return_url is deliberately outside the formula — it is the domain allow-list, not the signature, that decides where a customer may be sent.

201 Created

{
  "status": "ok",
  "txn_id": "PGW-20250904-9f3a2c1b",
  "checkout_url": "https://payflow.promoticsstudio.com/pay/PGW-20250904-9f3a2c1b",
  "order_ref": "ORDER-4417",
  "amount": "1999.99",
  "currency": "PKR",
  "expires_at": "2025-09-04T20:58:20+05:00"
}

Store txn_id against your order now. It is what the charge endpoints take, what appears on your statement, and what every webhook about this payment will carry.

Charge a JazzCash wallet

POST

/api/v1/payments/jazzcash-charge/

Pushes a confirmation prompt to the customer’s handset. Only for your own checkout — if you use ours, it calls this for you.

FieldRequiredNotes
txn_id Yes The id you got from create. It must still be unspent and unexpired: a second charge on the same payment answers PGW_409_STATE.
mobile_number Yes The customer’s wallet number. Sign it exactly as you send it — 03001234567 and +923001234567 both work, and we normalise after the signature is checked, not before.

Signing string

merchant_id|txn_id|mobile_number|timestamp

202 Accepted — the prompt is on the handset

{
  "status": "ok",
  "transaction": {
    "txn_id": "PGW-20250904-9f3a2c1b",
    "order_ref": "ORDER-4417",
    "amount": "1999.99",
    "currency": "PKR",
    "gateway": "jazzcash",
    "status": "awaiting_confirmation",
    "status_code": 101,
    "status_description": "Awaiting customer confirmation on their wallet.",
    "fee_amount": null,
    "net_amount": null,
    "customer_email": "buyer@example.com",
    "customer_mobile": "0300****567",
    "created_at": "2025-09-04T20:33:20+05:00",
    "updated_at": "2025-09-04T20:33:24+05:00",
    "completed_at": null,
    "settlement_status": "unsettled"
  }
}

Read the HTTP status, not just the body. 202 means we asked and are waiting — the customer still has to approve it, and fee_amount and net_amount are null until they do. 200 means the money arrived and the payment is completed. A decline comes back as 400 with PGW_400_DECLINED, and an expired session as 410. Do not release goods on a 202.

The full number is never returned. customer_mobile is masked on every read path, because a payment record that can be exported does not need to carry a customer’s phone number in the clear.

Charge an EasyPaisa wallet

POST

/api/v1/payments/easypaisa-charge/

The same call against the other wallet. The signing string is identical.

Signing string

merchant_id|txn_id|mobile_number|timestamp

Same fields, same signing string, same responses as the JazzCash charge — only the path and the gateway value in the response differ. If you support both wallets, write one function and pass the path in.

Generate a QR code

POST

/api/v1/payments/qr-charge/

Returns a payload to render as a QR code. No mobile number, because the customer scans with whichever app they already have open.

Signing string

merchant_id|txn_id|timestamp

202 Accepted

{
  "status": "ok",
  "transaction": { ... "gateway": "qr", "status": "awaiting_confirmation" ... },
  "qr_string": "00020101021226...5204000053037865802PK6304A1B2"
}

Render qr_string as a QR code with any library you like; it is an opaque string and its contents are not part of this contract. A freshly generated code is by definition unpaid, so this call is always a 202 — poll the status endpoint or wait for the webhook to learn when it has been scanned and approved.

Check a payment

POST

/api/v1/payments/status/

The authoritative answer for one payment, or for up to 50 in a single call.

One payment

Send whichever reference you kept: txn_id, order_ref, or the neutral alias reference. Only one of them, and the signing string contains that value whichever field carried it.

Signing string

merchant_id|reference|timestamp

Substitute the reference you actually sent — the field name is not part of the string, only its value.

200 OK

{
  "status": "ok",
  "transaction": {
    "txn_id": "PGW-20250904-9f3a2c1b",
    "status": "completed",
    "status_code": 200,
    "amount": "1999.99",
    "fee_amount": "200.00",
    "net_amount": "1799.99",
    "settlement_status": "unsettled"
    ...
  }
}

The same transaction object the charge endpoints return, with every field listed there. fee_amount and net_amount are filled in once the payment completes; net_amount is what reaches your bank.

Up to 50 at once

Send a references array instead. Useful for a nightly reconciliation run, where one signed request replaces hundreds.

Request

{
  "merchant_id": "PGW-M-000123",
  "references": ["PGW-20250904-9f3a2c1b","ORDER-4402","ORDER-9001"],
  "timestamp": 1757000000,
  "signature": "..."
}

Signing string

PGW-M-000123|ORDER-4402,ORDER-9001,PGW-20250904-9f3a2c1b|1757000000

The references are sorted as text and joined with commas — no spaces — before signing, so we both derive the same string no matter what order you sent them in. Blanks are dropped and duplicates collapsed first. A one-element array signs identically to a single lookup.

200 OK

{
  "status": "ok",
  "count": 1,
  "transactions": [ { ... } ],
  "not_found": ["ORDER-9001"]
}

A reference we cannot place is listed in not_found rather than failing the request — one bad row in a batch of 50 should not cost you the other 49.

A single lookup on a payment that is still in flight re-checks it before answering. A batch answers from our ledger, which the inbound notifications and our own polling keep current, so it can trail a single lookup by a few seconds. Either way, once a payment reaches a final status you can stop asking — with one exception, which is that a completed payment may later become refunded.

Webhooks

Set an IPN URL in your dashboard and we will POST the outcome of every payment to it as JSON, signed with the same secret you sign requests with. It is the same information the status endpoint gives you, delivered instead of asked for — and it is the only way to hear about a payment the customer completes minutes after leaving your site. The URL must be https and publicly reachable; we do not follow redirects.

What we POST

{
  "merchant_id": "PGW-M-000123",
  "event_id": "evt_2f9c41a7be03d5816c74e9a0b2",
  "txn_id": "PGW-20250904-9f3a2c1b",
  "order_ref": "ORDER-4417",
  "status": "completed",
  "status_code": 200,
  "amount": "1999.99",
  "currency": "PKR",
  "gateway": "jazzcash",
  "fee_amount": "200.00",
  "net_amount": "1799.99",
  "message": "The payment was completed and funds were received.",
  "timestamp": 1757000000,
  "signature": "..."
}

Signing string we used

merchant_id|txn_id|status|amount|currency|order_ref|timestamp

Note the order: status is third, which is not where it sits in any request formula. Recompute this from the fields in the body, compare it against signature with a constant-time comparison, and reject the delivery if it does not match. An unverified webhook is an instruction from a stranger.

Acknowledging a webhook

Easy to get wrong

A 200 with an empty body does not count. We only treat a delivery as acknowledged if the response body is JSON that echoes our txn_id and reports the same final status we sent. Anything else — a redirect, an HTML error page, a {"ok":true} — is read as “not received” and we try again.

What to reply

HTTP/1.1 200 OK
Content-Type: application/json

{"txn_id": "PGW-20250904-9f3a2c1b", "status": "completed"}
Retries
Up to 3 attempts per status: the first immediately, then roughly 5 and 15 minutes later. After that we stop, the delivery is marked unacknowledged in your webhook log, and the status endpoint remains the way to find out.
Duplicates
Retries of the same event carry the same event_id. Store it and ignore an id you have already processed — that is what makes it safe for us to retry and for you to reply slowly.
Ordering
Do not assume it. Trust status and, if you need to be certain, confirm with a status lookup before shipping. A webhook tells you something happened; the status endpoint is the record of what is true now.

Statuses

Every payment is in exactly one of these. The words and the numbers are ours, and they are the same in an API response, a webhook, your dashboard and your statement.

Status Code Lifecycle Meaning
created 100 In flight Payment created, not yet charged.
awaiting_confirmation 101 In flight Awaiting customer confirmation on their wallet.
completed 200 Final The payment was completed and funds were received.
failed 400 Final The payment attempt failed.
expired 102 Final The payment session expired before completion.
cancelled 103 Final The payment was cancelled.
refunded 106 Final The amount was returned to the customer.
abandoned 401 Final The customer left the payment unfinished.

In flight means keep listening. Final means stop — the one move out of a final status is completed becoming refunded, which arrives as its own webhook. Only completed payments are settled and paid out to you.

Errors

Any request that does not succeed answers with the HTTP status from the table below and this body. There is no other error shape, and this list is closed — if you switch on code, you have covered everything we can send you.

{
  "status": "error",
  "code": "PGW_400_VALIDATION",
  "message": "Required fields missing or invalid."
}
Code HTTP Message
PGW_400_VALIDATION 400 Required fields missing or invalid.
PGW_400_TIMESTAMP 400 Request timestamp is invalid or expired.
PGW_400_DECLINED 400 Payment could not be completed. Please try again.
PGW_400_RANGE 400 The requested date range is invalid or exceeds the maximum window.
PGW_401_AUTH 401 Invalid merchant credentials or signature.
PGW_403_INACTIVE 403 This merchant account is not active.
PGW_403_DOMAIN 403 Request origin domain is not authorized for this merchant.
PGW_404_NOT_FOUND 404 Transaction not found.
PGW_404_ENDPOINT 404 No such endpoint. Check the path and HTTP method.
PGW_405_METHOD 405 This endpoint does not accept that HTTP method.
PGW_409_DUPLICATE 409 This order reference has already been used.
PGW_409_STATE 409 This payment is not in a chargeable state.
PGW_409_IN_PROGRESS 409 A request for this payment is already being processed. Please wait a moment.
PGW_409_IDEMPOTENCY 409 This Idempotency-Key was already used for a different request.
PGW_410_EXPIRED 410 This payment session has expired. Please create a new one.
PGW_429_RATE_LIMIT 429 Too many requests. Please slow down and try again shortly.
PGW_500_INTERNAL 500 Something went wrong on our end. Please try again shortly.
PGW_503_UNAVAILABLE 503 Payment service is temporarily unavailable.

The three that get misread

PGW_401_AUTH
Almost always a signing string that differs from ours by one character, not a bad secret. Check the amount’s trailing zero first, then the field order, then whether you signed the values you actually sent.
PGW_400_TIMESTAMP
Your clock, not your code — the request arrived more than 10 minutes away from ours. It can also mean a request sat in a retry queue too long before being sent.
PGW_409_STATE
The payment exists but is not chargeable: it has already been charged, or it is past its session. Create a new payment rather than retrying this one.

Ready to build

Create an account, and your keys are on the API keys page of your dashboard the moment you ask for them. You can build and test your signing against the worked example above straight away — verification only has to be finished before a real payment will go through.

Stuck on a signature you are sure is right? Send us the signing string you built, the timestamp, and the txn_id — never your secret — at support@payflow.com, and we will tell you which character differs.