Laos Payments Orchestration · Channel developer docs …
Developer / Channel API / v1

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.
The amount is yours.The customer pays exactly the amount in your sealed envelope. Nothing in the browser can change it.

How a payment works

Who does what, step by step (the same journey as the numbered list below the diagram):

How a payment works Swimlane of a payment across the customer, your channel server, the Payment Portal and the payment provider. The numbered steps are listed below the diagram. Customer (browser) Your channel server Payment Portal Payment provider Confirms the purchase1 Builds the order,seals the envelope2 Browser auto-poststhe envelope3 Checks HMAC, decrypts,records the order4 Picks a methodon the checkout5 Pays on the provider's page(card, QR or wallet)6 Asks the provider forthe real result7 Confirms statusand exact amount Verifies the webhook,fulfils the order8 Return page: checkshashValue, shows result9 auto-submit form POST /pay 303 → checkout chosen method notification query result signed webhook redirect to return_url
What you build The Portal and the customer The provider
  1. The customer confirms the purchase in your channel.
  2. Your server builds the order and seals it with the Portal's public key and your channel secret (envelope v1).
  3. The browser auto-posts the envelope to /pay (or your server calls /api/v1/channel/orders).
  4. The Portal verifies the HMAC, decrypts, checks the order and records it, then sends the browser to its checkout page.
  5. The checkout offers only the providers and methods whose limits fit the amount and currency; the customer picks one.
  6. The customer pays on the provider's hosted page (card, QR or wallet; OTP or PIN as the provider asks).
  7. 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.
  8. Your webhook receives the signed payment.paid: verify it and fulfil the order.
  9. The customer is sent back to your return_url with 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.

Data flow Data flows A to I between the customer browser, your channel server, the Payment Portal and the payment provider; each flow is described in the table below. Card and wallet credentials go only between the customer and the provider. Your channel server Customer browser Payment provider Payment Portal Orders & payments Webhook outbox Audit log A B C D E F G H I card number, wallet login, OTP or PIN: customer and provider only
Your channel server Payment Portal and its data stores Provider · dashed orange: card and wallet credentials, never through your channel or the Portal
FlowFrom → toDataProtection
AYour server → browserAuto-submitting form with channel, payload, hashValueOrder encrypted (AES-256-GCM, key wrapped with RSA-OAEP) and authenticated (HMAC-SHA256)
BBrowser → Portal /payThe same envelopeHMAC checked before decryption; ts, nonce, return_url and order-key checks
CPortal → browserCheckout page: the order, the methods eligible for its amount; session cookie qvp_sessionTLS; the amount comes only from the envelope
DBrowser ↔ providerCard number, wallet login or QR scan, OTP or PINEntered only on the provider's hosted page; your channel and the Portal stay out of PCI scope beyond SAQ A
EPortal → providerPayment request: amount, currency, reference; customer name, email or phone only when the provider requires themEach provider's own scheme and keys (for example 2C2P's signed JWT)
FProvider → PortalNotification, then the answer to the Portal's status query: status, exact amount, provider referenceThe notification only triggers the query; the payment is marked paid only when the query confirms the amount
GPortal → your serverWebhook: event_id, payment and order ids, merchant_order_id, provider, amount, statusX-QVPay-Signature (HMAC-SHA256 over timestamp and raw body); sent from a durable outbox with retries
HYour server → PortalPublic-key fetch; signed reads (payment, order history) and refund requestsKeys endpoint is public; every other call carries an HMAC-SHA256 request signature
IPortal → browser → your return_urlmerchant_order_id, payment_id, status, ts, hashValueHMAC-SHA256 signed; show the result, fulfil only on the webhook
Where the Portal keeps it.Orders, payment attempts and refunds in its database; webhooks in a durable outbox until your server answers 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

  1. 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).
  2. You receive a channel_id and a channel secret. The secret is shown once: store it in your server's secret store.
  3. Fetch the Portal's public key (step 1) at deploy time.
  4. Implement sealing, the redirect, the webhook and the return page. The code samples cover all four.
  5. 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 ….

EnvironmentWhat it isCredentials
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
ProductionReal 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

GET/api/v1/channel/keyspublic

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).

Live response from this Portal
Loading…

2. Build the order

The order is a JSON object. Build it on your server.

Order (plaintext, before sealing)
{
  "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"
}
FieldTypeRequiredRule
vintegeryesAlways 1.
channel_idstringyesYour channel id; must equal the channel form field you send with the envelope.
tsintegeryesUnix seconds when you seal. Refused if more than 5 minutes from the Portal's clock: keep your server on NTP.
noncestringyesAt least 16 random bytes. Never reuse one: a replayed nonce is refused.
merchant_order_idstringyesYour 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.
amountstringyesDecimal 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.
currencystringyesISO 4217 code: LAK, USD, THB… The methods offered depend on it (see payment options).
descriptionstringnoUp to 200 characters, shown at checkout and to the provider.
expires_atRFC 3339noWhen the order stops being payable; capped at 30 minutes after the Portal accepts it (also the default).
return_urlstringyesMust start with one of your registered return origins.
customerobjectnoname, email, phone; shown at checkout, sent to a provider only when the provider requires it.
metadataobjectnoYour 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.
localestringnoCheckout language: en (default) or lo (Lao). The payer can switch on the page.
Never put card data in the order.Card numbers are entered only on the provider's page (PCI DSS SAQ A).

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.

Sealing an envelope The order is encrypted with a random AES key, the key is wrapped with the Portal public key, the parts are joined into the base64url payload, and the payload is signed with the channel secret. Portal public key + kidGET /api/v1/channel/keys Random AES key K (32 B)and iv (12 B) Order JSON(step 2) wrapped = RSA-OAEP-SHA256(public key, K) ct = AES-256-GCM(K, iv,order; aad = header) payload = base64url_nopad(header ‖ wrapped ‖ iv ‖ ct) hashValue = hex(HMAC-SHA256(channel secret, payload)) Channel secret(from operations) POST /paychannel · payload · hashValue
Inputs Computed on your server Sent to the Portal
  1. Generate a random 32-byte AES key K and a random 12-byte iv.
  2. wrapped = RSA-OAEP(SHA-256, MGF1-SHA-256)(portal public key, K) — 256 bytes.
  3. header = 0x01 || len(kid) (1 byte) || kid
  4. ct = AES-256-GCM(K, iv, plaintext = order JSON, aad = header) — includes the 16-byte tag.
  5. payload = base64url_nopad(header || wrapped || iv || ct)
  6. hashValue = lowercase hex(HMAC-SHA256(channel secret, payload))
OAEP must use SHA-256 for both the hash and MGF1.Java's 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

POST/paybrowser

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 fieldValue
channelYour channel_id
payloadThe sealed envelope
hashValueThe HMAC from step 3
HTML your server renders
<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>
AnswerMeaning
303 → /checkout.html?order=…Accepted; the customer is on the checkout page. A session cookie (qvp_session) is set for the checkout.
400 pageInvalid envelope (any check failed; the reason is only in the Portal's audit log).
409 pageThe order is already paid, or open at another amount.
429Too many requests from this IP (see rate limits).
503 pageEnvelopes are not configured on this Portal.

4b. Create the order server-side

POST/api/v1/channel/ordersserver

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
curl -X POST "…/api/v1/channel/orders" \
  -H "Content-Type: application/json" \
  -d '{"channel":"<channel_id>","payload":"<payload>","hashValue":"<hashValue>"}'
201 Created (200 with "replayed": true for an open order sent again)
{
  "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:

ParameterValue
merchant_order_idAs you sent it
payment_idThe Portal's payment id
statusPAID, DECLINED, EXPIRED or FAILED
tsUnix seconds
hashValuehex(HMAC-SHA256(secret, merchant_order_id + "." + payment_id + "." + status + "." + ts))
Show, don't fulfil.The return tells you what to show the customer. Issue the ticket or ship only on the payment.paid webhook (or after reading the payment).

Webhook events

The Portal POSTs JSON to your webhook URL from a durable outbox.

typeWhenWhat to do
payment.paidThe provider confirmed the payment server-to-server, with the exact amount.Fulfil the order.
payment.declinedThe provider declined the attempt; no money was taken.Show it; the customer may try again while the order is open.
payment.expiredThe attempt expired without payment.Show it.
refund.completedThe provider confirmed a refund.Record it; refunded_amount is the running total.
refund.rejectedLao Airlines staff rejected a refund you asked for.Nothing was paid back; you may ask again with a new refund_reference.
refund.failedThe provider refused a refund you asked for.Nothing was paid back; operations follow it up.
payment.paid body
{
  "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"
}
FieldPresent onMeaning
event_idallUnique per event; dedupe on it
payment_id, order_id, merchant_order_idallWhich payment attempt, Portal order and your order
provider, provider_refallWho took the payment and their reference
amount, currencyallThe order amount (what you are paid)
paid_atpayment.paidWhen the provider confirmed it
occurred_atdeclined, expired, refundWhen it happened
pay_amount, pay_currencywhen the payer paid in another currencyWhat the payer was charged
refund_id, refund_amount, refunded_amountrefund.*The refund, its amount, and the total refunded so far
refund_status, refund_referencerefund.*CONFIRMED, REJECTED or FAILED; your own reference when you gave one
metadataorders with metadataThe order's metadata as you sent it

Verifying a webhook

HeaderValue
X-QVPay-TimestampUnix seconds
X-QVPay-Signaturehex(HMAC-SHA256(channel secret, timestamp + "." + raw body))
X-QVPay-Event-IdSame 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 2xx once you have recorded the event. Do the slow work afterwards.
  • A timeout or 5xx is retried with exponential backoff; retries are normal, so the same event_id can arrive more than once. Store processed event ids and ignore repeats.
  • A 4xx answer stops the retries and opens an exception for Lao Airlines operations, so answer 4xx only 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:

HeaderValue
X-QVPay-ChannelYour channel_id
X-QVPay-TimestampUnix seconds (within 5 minutes)
X-QVPay-SignatureGET: 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

GET/api/v1/channel/payments/{payment_id}signed

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

GET/api/v1/channel/orders/{merchant_order_id}signed

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.

200 OK
{
  "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

POST/api/v1/channel/refund-requestssigned

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. amount is a decimal string in the order currency (leave it out for the full remaining amount). refund_reference is 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. kind is INVOLUNTARY (default) or CUSTOMER.
  • Refunds of a payment never add up to more than was paid; refunds still waiting for a decision count too.
Request body
{
  "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" }]
}
202 Accepted
{
  "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

POST/api/v1/channel/orders/{merchant_order_id}/cancelsigned

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" }.

AnswerMeaning
200{"state":"CANCELLED","cancelled_at","pending_attempts","replayed"}; replayed: true when it was already cancelled.
404 not_foundNo order with this merchant_order_id.
409 already_paidIt is paid: ask for a refund instead.
409 order_not_openIt 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

GET/api/v1/channel/orders?from=…&to=…signed

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.

ParameterValue
from, toRFC 3339 times, or YYYY-MM-DD dates in Lao time (UTC+7); a date as to includes that whole day. At most 31 days.
stateOPEN, PAID, CANCELLED or EXPIRED (optional)
limit, cursorPage size 1–500 (default 100); pass next_cursor back as cursor for the next page.
RateAt most 6 calls a minute per channel; beyond that 429 with Retry-After.
200 OK
{
  "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

GET/api/v1/payment-optionspublic

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.

ProviderMethodCurrencyMinMax
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.

GET/api/v1/fx/rates?from=LAKpublic

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

StateMeaning
OPENPayable until expires_at
PAIDA payment was verified
EXPIREDNot paid in time; the merchant_order_id can be used again

Payment status (status) and attempt result (result)

statusresultReturn statusMeaning
CREATEDPENDING—Recorded, not yet at the provider
PENDING_PROVIDERPENDING—The customer is on the provider's page
VERIFYINGPENDING—The Portal is confirming with the provider
VERIFIEDPAIDPAIDPaid and confirmed: fulfil
DECLINEDDECLINEDDECLINEDDeclined; no money taken
EXPIREDEXPIREDEXPIREDNot completed in time; no money taken
VERIFY_FAILEDFAILEDFAILEDThe 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.

HTTPerrorWhereWhat to do
400invalid_envelope/pay, channel ordersAny envelope check failed (signature, key id, decryption, ts, nonce, channel_id, return_url). Check against the FAQ; ask operations for the audit reason.
400invalid_requestallMalformed JSON or fields; fix the request.
401unauthorizedsigned requestsSignature, channel id or timestamp wrong.
404not_found, order_not_foundreadsUnknown id, or not yours.
409already_paid/pay, channel ordersThis merchant_order_id is paid; do not charge again.
409order_conflict/pay, channel ordersOpen with another amount or currency; wait for it to expire or use a new id.
409order_expiredcheckoutStart again from your channel.
409idempotency_conflict, in_progresscheckoutHandled by the checkout page.
409fx_quote_invalidcheckoutThe exchange-rate quote expired; the checkout fetches a new one.
422method_unavailable, amount_rejected, fx_unavailablecheckoutThe method does not fit the amount, or a changed amount was refused.
428challenge_requiredcheckoutBot challenge; solved by the checkout page (see below).
429rate_limitedpublic endpointsWait Retry-After seconds.
502provider_unavailablecheckoutThe provider did not answer; the checkout offers the other methods.
503envelope_unavailablekeys, /payThe Portal has no envelope key configured; contact operations.
500internalallRetry later; if it persists, contact operations with the time and your merchant_order_id.

Limits & timeouts

WhatValue
Order lifetimeAt most 30 minutes after it is accepted (default); set less with expires_at
Clock skew (ts, request and webhook timestamps)± 5 minutes
Envelope payload sizeAt most 16 KB
descriptionAt most 200 characters
Refund requestAt most 500 orders per call
Amounts per provider and methodSee 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.

RuleLimit
Orders and payment starts per IP10 / minute
Payment starts per order5 / minute
Payment starts per checkout session6 / minute
Option and order lookups per IP30 / minute
Different orders per IP10 per 10 minutes
Declines per order5 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:

ProviderMethodsPayer account page
LAOSPAY_SIMCard, QR, wallet/sim/account
QRWALLET_SIMQR, wallet/qrsim/account
  1. Open a payer account on the provider's account page (name, login, password).
  2. 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.
  3. 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.
  4. 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.

#TestExpected
1Seal and send a valid orderCheckout shows the order and its amount
2Change one character of payload or hashValue400 invalid_envelope
3Send the same envelope twiceSecond one refused (nonce reused)
4Seal with ts 10 minutes old400 invalid_envelope
5return_url on an unregistered origin400 invalid_envelope
6Same merchant_order_id, new nonce, same amount, while openSame order ("replayed": true)
7Same merchant_order_id, different amount, while open409 order_conflict
8Pay; then send the paid merchant_order_id againpayment.paid once; then 409 already_paid
9Webhook with a wrong signature / old timestampYour endpoint refuses it
10Same webhook delivered twiceFulfilled once (dedupe on event_id)
11Return URL with a changed statusYour return page refuses it
12Decline and expiry at the sandbox providerpayment.declined / payment.expired; order still payable while open
13Refund request for a paid order202 proposal; after approval refund.completed

FAQ & troubleshooting

Every envelope gets 400 invalid_envelope
  • Is hashValue computed 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 kid the 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_url start with a registered origin, and does channel_id match the form field?
Compare with the samples; operations can read the exact reason in the Portal's audit log.
Webhook signatures never match Compute over the raw body bytes, before your framework parses JSON (for example Express express.raw(), PHP file_get_contents('php://input')). The message is timestamp + "." + body.
The customer paid but I received no webhook Check that your endpoint answers 2xx quickly and is reachable over https. Read the order history; if the order expired before payment, see late payments.
A method I expected is not offered at checkout Methods depend on the amount and currency limits and on provider health. Check payment options.
The customer sees "please wait while we check your browser" The IP or session was throttled recently and the checkout is solving the bot challenge; it continues by itself.