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.comEvery path on this page is relative to it. - Method
-
POSTon 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 ordinaryapplication/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,timestampandsignature— 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
-
Sign the amount in exactly the form you send it — two
decimal places, always.
1999.90, never1999.9;500.00, never500. If the two differ by so much as a trailing zero the digests will not match, and the failure arrives asPGW_401_AUTH, which looks like a credential problem and is not. -
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_idPGW-M-000123
amount1999.99
currencyPKR
order_refORDER-4417
customer_emailbuyer@example.com
timestamp1757000000
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.
-
Create the payment. You get a
txn_idand acheckout_url. Nothing has been charged and the customer has not been contacted. -
Collect the payment method. Send the customer to
checkout_urland 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. -
The customer confirms on their handset. This is the part
neither of us controls. A wallet charge answers
202while it is pending, so treat that as “asked, not answered” and do not fulfil the order on it. - 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.
| Field | Required | Notes |
|---|---|---|
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.
| Field | Required | Notes |
|---|---|---|
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
statusand, 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.