Channel API
For teams that build a sales channel (an e-commerce site, a mobile app, a call-centre tool) and send customers to the Portal to pay.
Overview
The Payment Portal is a payment orchestrator. Your channel sends it the order inside an encrypted, signed envelope; the Portal shows the customer the payment methods that fit the amount, hands the customer to the chosen provider's own hosted page, verifies the result with the provider server-to-server, and tells your server with a signed webhook.
What you build
- Seal an order on your server and redirect the customer.
- Receive and verify webhooks; fulfil on
payment.paid. - Optionally: read payments and order history, request refunds.
What you never touch
- Card numbers or wallet credentials: they are entered only on the provider's page.
- Provider APIs and their keys (2C2P, BCEL OnePay, U-Money): the Portal speaks to each provider in its own scheme.
How a payment works
Who does what, step by step (the same journey as the numbered list below the diagram):
- The customer confirms the purchase in your channel.
- Your server builds the order and seals it with the Portal's public key and your channel secret (envelope v1).
- The browser auto-posts the envelope to
/pay(or your server calls/api/v1/channel/orders). - The Portal verifies the HMAC, decrypts, checks the order and records it, then sends the browser to its checkout page.
- The checkout offers only the providers and methods whose limits fit the amount and currency; the customer picks one.
- The customer pays on the provider's hosted page (card, QR or wallet; OTP or PIN as the provider asks).
- The provider's notification only triggers a status query: the Portal marks the payment paid only after the provider's server confirms it, with the exact amount.
- Your webhook receives the signed
payment.paid: verify it and fulfil the order. - The customer is sent back to your
return_urlwith a signed status: verify it and show the result.
Data flow
What data moves between the four parties, how each flow is protected, and where the Portal keeps it. The letters match the table below.
| Flow | From → to | Data | Protection |
|---|---|---|---|
| A | Your server → browser | Auto-submitting form with channel, payload, hashValue | Order encrypted (AES-256-GCM, key wrapped with RSA-OAEP) and authenticated (HMAC-SHA256) |
| B | Browser → Portal /pay | The same envelope | HMAC checked before decryption; ts, nonce, return_url and order-key checks |
| C | Portal → browser | Checkout page: the order, the methods eligible for its amount; session cookie qvp_session | TLS; the amount comes only from the envelope |
| D | Browser ↔ provider | Card number, wallet login or QR scan, OTP or PIN | Entered only on the provider's hosted page; your channel and the Portal stay out of PCI scope beyond SAQ A |
| E | Portal → provider | Payment request: amount, currency, reference; customer name, email or phone only when the provider requires them | Each provider's own scheme and keys (for example 2C2P's signed JWT) |
| F | Provider → Portal | Notification, then the answer to the Portal's status query: status, exact amount, provider reference | The notification only triggers the query; the payment is marked paid only when the query confirms the amount |
| G | Portal → your server | Webhook: event_id, payment and order ids, merchant_order_id, provider, amount, status | X-QVPay-Signature (HMAC-SHA256 over timestamp and raw body); sent from a durable outbox with retries |
| H | Your server → Portal | Public-key fetch; signed reads (payment, order history) and refund requests | Keys endpoint is public; every other call carries an HMAC-SHA256 request signature |
| I | Portal → browser → your return_url | merchant_order_id, payment_id, status, ts, hashValue | HMAC-SHA256 signed; show the result, fulfil only on the webhook |
2xx; the reason for every refused envelope in the audit log. It never stores card or wallet credentials, because it never receives them.Onboarding checklist
- Ask Lao Airlines (QV) operations to register your channel. Give them a channel name, the exact origins of your return pages (for example
https://shop.example) and your webhook URL (https). - You receive a
channel_idand a channel secret. The secret is shown once: store it in your server's secret store. - Fetch the Portal's public key (step 1) at deploy time.
- Implement sealing, the redirect, the webhook and the return page. The code samples cover all four.
- Run the test plan against the sandbox, then ask for production credentials. Each environment has its own channel id, secret and keys.
Environments & base URL
Every path in these docs is relative to the Portal's base URL. This page is served by the Portal at …, whose environment is ….
| Environment | What it is | Credentials |
|---|---|---|
Sandbox (development) | The providers run in sandbox mode, plus the Portal's own sandbox providers (LAOSPAY_SIM, QRWALLET_SIM) for end-to-end tests. No real money. | Sandbox channel id, secret and Portal key |
| Production | Real providers and real money. | Separate channel id, secret and Portal key; never shared with the sandbox |
Check which Portal you are talking to with GET /api/v1/status (field environment).
1. Get the Portal public key
Returns every active RSA-2048 public key with its kid. Use the first key. Fetch it on deploy or cache it for up to an hour (the response says Cache-Control: max-age=3600).
Loading…2. Build the order
The order is a JSON object. Build it on your server.
{
"v": 1,
"channel_id": "<your channel_id>",
"ts": 1791523200,
"nonce": "<16+ random bytes, base64url>",
"merchant_order_id": "QV-2026-000123",
"amount": "150.00",
"currency": "USD",
"description": "Vientiane → Bangkok, 1 adult",
"expires_at": "2026-10-07T10:30:00Z",
"return_url": "https://shop.example/payment/return",
"customer": { "name": "Somchai P.", "email": "somchai@example.com", "phone": "+85620xxxxxxx" },
"metadata": { "pnr": "ABC123" },
"locale": "lo"
}| Field | Type | Required | Rule |
|---|---|---|---|
v | integer | yes | Always 1. |
channel_id | string | yes | Your channel id; must equal the channel form field you send with the envelope. |
ts | integer | yes | Unix seconds when you seal. Refused if more than 5 minutes from the Portal's clock: keep your server on NTP. |
nonce | string | yes | At least 16 random bytes. Never reuse one: a replayed nonce is refused. |
merchant_order_id | string | yes | Your order key, unique per channel. While an order with this id is open, sending it again (new nonce, same amount and currency) returns the same order and checkout. A different amount while open → order_conflict; once paid → already_paid; after it expired unpaid, a new order takes the id. |
amount | string | yes | Decimal string in the currency's major unit, with the currency's own decimals (for example "150.00" USD, "1500000" LAK). A string, never a float. |
currency | string | yes | ISO 4217 code: LAK, USD, THB… The methods offered depend on it (see payment options). |
description | string | no | Up to 200 characters, shown at checkout and to the provider. |
expires_at | RFC 3339 | no | When the order stops being payable; capped at 30 minutes after the Portal accepts it (also the default). |
return_url | string | yes | Must start with one of your registered return origins. |
customer | object | no | name, email, phone; shown at checkout, sent to a provider only when the provider requires it. |
metadata | object | no | Your own references (PNR, ticket numbers): up to 10 keys of 1–40 characters A-Z a-z 0-9 _ . -, string values up to 200 characters. Returned in webhooks, the order history and the order report; never shown to the payer or sent to a provider; stored encrypted. A card number is refused. |
locale | string | no | Checkout language: en (default) or lo (Lao). The payer can switch on the page. |
3. Seal the envelope
Envelope v1 is hybrid encryption plus an HMAC over the ciphertext (encrypt-then-MAC), so the Portal can refuse a forged envelope before any RSA work.
- Generate a random 32-byte AES key
Kand a random 12-byteiv. wrapped = RSA-OAEP(SHA-256, MGF1-SHA-256)(portal public key, K)— 256 bytes.header = 0x01 || len(kid) (1 byte) || kidct = AES-256-GCM(K, iv, plaintext = order JSON, aad = header)— includes the 16-byte tag.payload = base64url_nopad(header || wrapped || iv || ct)hashValue = lowercase hex(HMAC-SHA256(channel secret, payload))
OAEPWithSHA-256AndMGF1Padding uses MGF1-SHA-1 unless you pass an OAEPParameterSpec, and PHP's openssl_public_encrypt OAEP is SHA-1 only. The samples below handle both.4. Send the customer to pay
Render an auto-submitting form. POST keeps the envelope out of browser history and access logs; GET /pay?channel=…&payload=…&hashValue=… is accepted for channels that can only redirect.
| Form field | Value |
|---|---|
channel | Your channel_id |
payload | The sealed envelope |
hashValue | The HMAC from step 3 |
<form id="qvpay" method="post" action="…/pay">
<input type="hidden" name="channel" value="<channel_id>">
<input type="hidden" name="payload" value="<payload>">
<input type="hidden" name="hashValue" value="<hashValue>">
<noscript><button>Continue to payment</button></noscript>
</form>
<script>document.getElementById("qvpay").submit();</script>| Answer | Meaning |
|---|---|
303 → /checkout.html?order=… | Accepted; the customer is on the checkout page. A session cookie (qvp_session) is set for the checkout. |
400 page | Invalid envelope (any check failed; the reason is only in the Portal's audit log). |
409 page | The order is already paid, or open at another amount. |
429 | Too many requests from this IP (see rate limits). |
503 page | Envelopes are not configured on this Portal. |
4b. Create the order server-side
The same checks as /pay, for a channel server that wants the checkout URL back as JSON (for example a mobile app that opens it in a browser tab).
curl -X POST "…/api/v1/channel/orders" \
-H "Content-Type: application/json" \
-d '{"channel":"<channel_id>","payload":"<payload>","hashValue":"<hashValue>"}'{
"order_id": "36d4955c-48e2-4ef5-b595-cc2d481c743f",
"merchant_order_id": "QV-2026-000123",
"amount": "150.00",
"currency": "USD",
"expires_at": "2026-10-07T10:30:00Z",
"replayed": false,
"state": "OPEN",
"checkout_url": "…/checkout.html?order=36d4955c-48e2-4ef5-b595-cc2d481c743f"
}Errors: 400 invalid_envelope, 409 already_paid, 409 order_conflict, 503 envelope_unavailable.
5. The customer comes back
When the payment is final, the checkout's "Back to the shop" sends the browser to your return_url with these query parameters:
| Parameter | Value |
|---|---|
merchant_order_id | As you sent it |
payment_id | The Portal's payment id |
status | PAID, DECLINED, EXPIRED or FAILED |
ts | Unix seconds |
hashValue | hex(HMAC-SHA256(secret, merchant_order_id + "." + payment_id + "." + status + "." + ts)) |
payment.paid webhook (or after reading the payment).Webhook events
The Portal POSTs JSON to your webhook URL from a durable outbox.
type | When | What to do |
|---|---|---|
payment.paid | The provider confirmed the payment server-to-server, with the exact amount. | Fulfil the order. |
payment.declined | The provider declined the attempt; no money was taken. | Show it; the customer may try again while the order is open. |
payment.expired | The attempt expired without payment. | Show it. |
refund.completed | The provider confirmed a refund. | Record it; refunded_amount is the running total. |
refund.rejected | Lao Airlines staff rejected a refund you asked for. | Nothing was paid back; you may ask again with a new refund_reference. |
refund.failed | The provider refused a refund you asked for. | Nothing was paid back; operations follow it up. |
{
"event_id": "4a1d0d2e-6a43-4a1b-9a53-1f3f0f7c9b11",
"type": "payment.paid",
"payment_id": "a1cb64e1-4a2f-46e7-8593-6aa8ddfe6cfe",
"order_id": "36d4955c-48e2-4ef5-b595-cc2d481c743f",
"channel_id": "<your channel_id>",
"merchant_order_id": "QV-2026-000123",
"provider": "2C2P",
"provider_ref": "<provider reference>",
"amount": "150.00",
"currency": "USD",
"status": "PAID",
"paid_at": "2026-10-07T10:12:31Z"
}| Field | Present on | Meaning |
|---|---|---|
event_id | all | Unique per event; dedupe on it |
payment_id, order_id, merchant_order_id | all | Which payment attempt, Portal order and your order |
provider, provider_ref | all | Who took the payment and their reference |
amount, currency | all | The order amount (what you are paid) |
paid_at | payment.paid | When the provider confirmed it |
occurred_at | declined, expired, refund | When it happened |
pay_amount, pay_currency | when the payer paid in another currency | What the payer was charged |
refund_id, refund_amount, refunded_amount | refund.* | The refund, its amount, and the total refunded so far |
refund_status, refund_reference | refund.* | CONFIRMED, REJECTED or FAILED; your own reference when you gave one |
metadata | orders with metadata | The order's metadata as you sent it |
Verifying a webhook
| Header | Value |
|---|---|
X-QVPay-Timestamp | Unix seconds |
X-QVPay-Signature | hex(HMAC-SHA256(channel secret, timestamp + "." + raw body)) |
X-QVPay-Event-Id | Same as event_id |
Compute the HMAC over the raw request body exactly as received (before any JSON parsing), compare in constant time, and refuse timestamps more than 5 minutes old.
Retries & deduplication
- Answer
2xxonce you have recorded the event. Do the slow work afterwards. - A timeout or
5xxis retried with exponential backoff; retries are normal, so the sameevent_idcan arrive more than once. Store processed event ids and ignore repeats. - A
4xxanswer stops the retries and opens an exception for Lao Airlines operations, so answer4xxonly for a bad signature. - If you missed events, read the order history: it is the source of truth.
Payment after the order expired
If a provider confirms a payment after the order's expires_at, the money is real but the order may no longer be fulfillable. You get no payment.paid; operations get a LATE_PAYMENT exception and a full refund is proposed automatically. After a person approves it you get refund.completed.
Signed channel requests
Server-to-server calls to /api/v1/channel/* carry three headers:
| Header | Value |
|---|---|
X-QVPay-Channel | Your channel_id |
X-QVPay-Timestamp | Unix seconds (within 5 minutes) |
X-QVPay-Signature | GET: hex(HMAC-SHA256(secret, ts + ".GET " + path))POST: hex(HMAC-SHA256(secret, ts + ".POST " + path + "\n" + body)) |
path is the URL path without the query string, for example /api/v1/channel/orders/QV-2026-000123. The one exception is the order report, which signs the query too: ts + ".GET " + path + "?" + query. A missing or wrong signature gets 401 unauthorized.
Read a payment
Returns one of your payments (404 not_found for another channel's). Fields include id, order_id, merchant_order_id, amount, currency, provider, method, status, provider_ref, verified_at, created_at, and for another currency pay_amount, pay_currency, fx_rate.
Order history
Everything that happened under one of your order keys: each Portal order, each payment attempt and each refund. Use it to answer "was I charged?" and to recover missed webhooks.
{
"merchant_order_id": "QV-2026-000123",
"paid": true,
"paid_amount": "150.00",
"refunded_amount": "150.00",
"orders": [{
"order_id": "36d4955c-…", "amount": "150.00", "currency": "USD", "state": "PAID",
"metadata": { "pnr": "ABC123" }, "created_at": "…", "expires_at": "…",
"attempts": [{
"payment_id": "a1cb64e1-…", "provider": "LAOSPAY_SIM", "method": "QR",
"amount": "150.00", "currency": "USD", "status": "VERIFIED", "result": "PAID",
"created_at": "…", "verified_at": "…",
"refunds": [{ "id": "…", "amount": "150.00", "status": "CONFIRMED", "refund_reference": "RF-2026-0001", "created_at": "…" }]
}]
}]
}Refund requests
Ask for refunds of up to 500 of your orders (for example after a cancelled flight, or one passenger of a booking). Each item becomes a refund proposal; Lao Airlines staff approve or reject each one (maker ≠ checker) before it is sent to the provider. You then get refund.completed, refund.rejected or refund.failed.
merchant_order_ids: a full refund of what is still refundable on each order.refunds: one item per refund.amountis a decimal string in the order currency (leave it out for the full remaining amount).refund_referenceis your own id for the refund (up to 64 characters, unique per channel) and is required for a partial amount: sending the same item again returns the refund already made ("replayed": true), never a second one.kindisINVOLUNTARY(default) orCUSTOMER.- Refunds of a payment never add up to more than was paid; refunds still waiting for a decision count too.
{
"reason": "Flight QV635 cancelled",
"merchant_order_ids": ["QV-2026-000123", "QV-2026-000124"],
"refunds": [{ "merchant_order_id": "QV-2026-000125", "amount": "40.00", "refund_reference": "RF-2026-0001", "kind": "CUSTOMER" }]
}{
"batch_id": "…",
"status": "PROPOSED",
"items": [
{ "merchant_order_id": "QV-2026-000123", "refund_id": "…", "amount": "150.00 USD" },
{ "merchant_order_id": "QV-2026-000124", "error": "no paid attempt for this order" },
{ "merchant_order_id": "QV-2026-000125", "refund_reference": "RF-2026-0001", "refund_id": "…", "amount": "40.00 USD" }
]
}Refunds always go back to the payer's original card or account, in the order's currency.
Cancel an order
When the booking behind an open order is released before the customer pays, cancel the order so it can no longer be paid. Body (optional): { "reason": "seat hold released" }.
| Answer | Meaning |
|---|---|
200 | {"state":"CANCELLED","cancelled_at","pending_attempts","replayed"}; replayed: true when it was already cancelled. |
404 not_found | No order with this merchant_order_id. |
409 already_paid | It is paid: ask for a refund instead. |
409 order_not_open | It already expired unpaid. |
The checkout then shows the order as cancelled. pending_attempts > 0 means the customer had already opened a provider's page; if they still finish paying there, it is handled like a late payment (no payment.paid, automatic full refund). The same merchant_order_id can then be used for a new order.
Order report
Your orders created in a range, oldest first, with what was paid and refunded, and totals per currency over the whole range: for your daily reconciliation. The signature covers the path and the query exactly as sent.
| Parameter | Value |
|---|---|
from, to | RFC 3339 times, or YYYY-MM-DD dates in Lao time (UTC+7); a date as to includes that whole day. At most 31 days. |
state | OPEN, PAID, CANCELLED or EXPIRED (optional) |
limit, cursor | Page size 1–500 (default 100); pass next_cursor back as cursor for the next page. |
| Rate | At most 6 calls a minute per channel; beyond that 429 with Retry-After. |
{
"from": "2026-10-06T17:00:00Z", "to": "2026-10-07T17:00:00Z",
"orders": [{
"order_id": "36d4955c-…", "merchant_order_id": "QV-2026-000123", "amount": "150.00", "currency": "USD",
"state": "PAID", "metadata": { "pnr": "ABC123" }, "created_at": "…",
"payment_id": "a1cb64e1-…", "provider": "2C2P", "method": "CARD", "paid_at": "…",
"refunded_amount": "25.00", "refund_pending_amount": "10.00"
}],
"next_cursor": "…",
"totals": [{ "currency": "USD", "orders": 3, "paid_orders": 1, "paid_amount": "150.00", "refunded_amount": "25.00", "net_amount": "125.00" }]
}Payment options
The providers and methods the Portal can route right now: a provider counts only when it is configured and healthy, a method only when an enabled limit exists. Show them if you let customers pick a preferred method. Amounts are in minor units.
| Provider | Method | Currency | Min | Max |
|---|---|---|---|---|
| Loading live options… | ||||
Paying in another currency
You are always paid in the order's currency. When Lao Airlines enables it, a payer may pay in another currency at a provider's own rate; the checkout handles this.
Indicative rates to show before booking: policy, pay_currencies, and rates[] with currency, provider, rate (1 from = rate), per_unit, best, quoted_at, expires_at. Cached 10 minutes per provider. A payment always uses the quote made for its order at checkout.
Webhooks for such payments carry pay_amount and pay_currency. Refunds go back at the same rate.
Statuses
Order state
| State | Meaning |
|---|---|
OPEN | Payable until expires_at |
PAID | A payment was verified |
EXPIRED | Not paid in time; the merchant_order_id can be used again |
Payment status (status) and attempt result (result)
| status | result | Return status | Meaning |
|---|---|---|---|
CREATED | PENDING | — | Recorded, not yet at the provider |
PENDING_PROVIDER | PENDING | — | The customer is on the provider's page |
VERIFYING | PENDING | — | The Portal is confirming with the provider |
VERIFIED | PAID | PAID | Paid and confirmed: fulfil |
DECLINED | DECLINED | DECLINED | Declined; no money taken |
EXPIRED | EXPIRED | EXPIRED | Not completed in time; no money taken |
VERIFY_FAILED | FAILED | FAILED | The provider's answer did not match (for example the amount); staff check it |
Refund status
PROPOSED → APPROVED → SUBMITTED → CONFIRMED; or REJECTED (by the checker) / FAILED (by the provider).
Error codes
JSON errors have the form {"error": "<code>", "message": "<text>"}. Branch on error; message is for people and may change.
| HTTP | error | Where | What to do |
|---|---|---|---|
| 400 | invalid_envelope | /pay, channel orders | Any envelope check failed (signature, key id, decryption, ts, nonce, channel_id, return_url). Check against the FAQ; ask operations for the audit reason. |
| 400 | invalid_request | all | Malformed JSON or fields; fix the request. |
| 401 | unauthorized | signed requests | Signature, channel id or timestamp wrong. |
| 404 | not_found, order_not_found | reads | Unknown id, or not yours. |
| 409 | already_paid | /pay, channel orders | This merchant_order_id is paid; do not charge again. |
| 409 | order_conflict | /pay, channel orders | Open with another amount or currency; wait for it to expire or use a new id. |
| 409 | order_expired | checkout | Start again from your channel. |
| 409 | idempotency_conflict, in_progress | checkout | Handled by the checkout page. |
| 409 | fx_quote_invalid | checkout | The exchange-rate quote expired; the checkout fetches a new one. |
| 422 | method_unavailable, amount_rejected, fx_unavailable | checkout | The method does not fit the amount, or a changed amount was refused. |
| 428 | challenge_required | checkout | Bot challenge; solved by the checkout page (see below). |
| 429 | rate_limited | public endpoints | Wait Retry-After seconds. |
| 502 | provider_unavailable | checkout | The provider did not answer; the checkout offers the other methods. |
| 503 | envelope_unavailable | keys, /pay | The Portal has no envelope key configured; contact operations. |
| 500 | internal | all | Retry later; if it persists, contact operations with the time and your merchant_order_id. |
Limits & timeouts
| What | Value |
|---|---|
| Order lifetime | At most 30 minutes after it is accepted (default); set less with expires_at |
Clock skew (ts, request and webhook timestamps) | ± 5 minutes |
Envelope payload size | At most 16 KB |
description | At most 200 characters |
| Refund request | At most 500 orders per call |
| Amounts per provider and method | See the live payment options |
Rate limits & bot challenge
The payment endpoints resist card-testing and enumeration (Annex A C-12). Normal customers never notice them.
| Rule | Limit |
|---|---|
| Orders and payment starts per IP | 10 / minute |
| Payment starts per order | 5 / minute |
| Payment starts per checkout session | 6 / minute |
| Option and order lookups per IP | 30 / minute |
| Different orders per IP | 10 per 10 minutes |
| Declines per order | 5 per hour |
A throttled request gets 429 rate_limited with Retry-After. After a throttle, that IP, session or order must solve a short proof-of-work challenge (428 challenge_required) for each payment start for 10 minutes; the Portal's own checkout page does this in about a second, with no third-party script. Servers that create orders through /api/v1/channel/orders share the per-IP limit, so spread bulk work over time.
Key & secret rotation
- Portal key: when a new key appears in
/api/v1/channel/keys, start using the first key; the old one keeps working until every channel has switched. Re-fetch on deploy and at least hourly. - Channel secret: operations can rotate it and give you the new one. What you sign (envelopes, signed requests) is accepted with the old secret too during the grace period they set (up to 7 days). What the Portal signs (webhooks, return redirects) uses the new secret at once, so install it before the rotation or verify with either secret during the grace period.
Code samples
Complete, dependency-light helpers for each language. Each file seals envelopes, verifies webhooks and returns, and signs requests, and runs from the command line for quick tests. They are tested against the Portal's own envelope and signature code on every build.
Sandbox
The sandbox Portal adds two sandbox payment providers with their own payer accounts and operator consoles, so you can run a whole payment without real money:
| Provider | Methods | Payer account page |
|---|---|---|
LAOSPAY_SIM | Card, QR, wallet | /sim/account |
QRWALLET_SIM | QR, wallet | /qrsim/account |
- Open a payer account on the provider's account page (name, login, password).
- Fund it: ask operations for a cash-in (as the provider's staff would record), or link an account at the sandbox bank (/simbank/) on the account page and top up from it.
- Send an order from your channel, choose the sandbox provider at checkout and pay on its page with the account's password. Where the page shows an EMVCo QR, you can instead use Scan to pay on the account page (camera, or paste the QR content), as a wallet app would.
- You receive
payment.paid; the order history shows the attempt. Operations can also play declines, expiries, refunds and chargebacks.
Wallets have a KYC level, as e-money rules require: Unverified (new accounts), Basic (ID document seen) or Full (verified identity). Where the provider's operators have set limits for a level (balance, per payment, per day, per month), a payment over them is refused on the provider's page with the limit it broke. If a large test order is refused this way, ask operations to raise the account's KYC level; it is not an error in your integration.
Go-live test plan
Run these against the sandbox and keep the results for the go-live review.
| # | Test | Expected |
|---|---|---|
| 1 | Seal and send a valid order | Checkout shows the order and its amount |
| 2 | Change one character of payload or hashValue | 400 invalid_envelope |
| 3 | Send the same envelope twice | Second one refused (nonce reused) |
| 4 | Seal with ts 10 minutes old | 400 invalid_envelope |
| 5 | return_url on an unregistered origin | 400 invalid_envelope |
| 6 | Same merchant_order_id, new nonce, same amount, while open | Same order ("replayed": true) |
| 7 | Same merchant_order_id, different amount, while open | 409 order_conflict |
| 8 | Pay; then send the paid merchant_order_id again | payment.paid once; then 409 already_paid |
| 9 | Webhook with a wrong signature / old timestamp | Your endpoint refuses it |
| 10 | Same webhook delivered twice | Fulfilled once (dedupe on event_id) |
| 11 | Return URL with a changed status | Your return page refuses it |
| 12 | Decline and expiry at the sandbox provider | payment.declined / payment.expired; order still payable while open |
| 13 | Refund request for a paid order | 202 proposal; after approval refund.completed |
FAQ & troubleshooting
Every envelope gets 400 invalid_envelope
- Is
hashValuecomputed over the base64url payload text, with the channel secret, as lowercase hex? - Is the payload base64url without padding (
-and_, no=)? - OAEP with SHA-256 for both hash and MGF1? (Java and PHP defaults are SHA-1.)
- Is the GCM tag appended after the ciphertext, and the header used as AAD?
- Is
kidthe one returned with the key you used, and the key from this environment? - Is your server clock within 5 minutes? Is the nonce new? Does
return_urlstart with a registered origin, and doeschannel_idmatch the form field?
Webhook signatures never match
Compute over the raw body bytes, before your framework parses JSON (for example Expressexpress.raw(), PHP file_get_contents('php://input')). The message is timestamp + "." + body.The customer paid but I received no webhook
Check that your endpoint answers2xx quickly and is reachable over https. Read the order history; if the order expired before payment, see late payments.