Simulation mode Every endpoint below is a sandbox. No real money moves, ever.
FauxPay
On this page 17 sections

FauxPay API

API reference

FauxPay is a payment gateway simulator. It has the shape of a real gateway: payment intents, a hosted checkout, signed webhooks, balance, settlement and payouts, and none of the consequences.

Base URL

https://fauxpay.chaad.my.id/api/v1

All endpoints are versioned under /api/v1. Requests and responses are JSON; send Accept: application/json.

Every response carries a marker

"mode": "simulation"

If you ever see a response without it, you are not talking to FauxPay. The checkout page carries a visible banner for the same reason: the end user must never mistake a sandbox payment for a real one.

MCP server for AI agents

Building with an AI coding assistant? Point it at FauxPay's MCP server and ask for what you need, for example "integrate FauxPay IDR and MYR into my site". The agent can look up every market's channels, the payer fields each one requires, the request and response of every endpoint, the validation rules, request signing and the webhook events, so the code it writes matches the API on the first try.

Server URL

https://fauxpay.chaad.my.id/mcp

Claude Code

claude mcp add --transport http fauxpay https://fauxpay.chaad.my.id/mcp

Cursor, Windsurf and other clients (mcp.json)

{
  "mcpServers": {
    "fauxpay": { "url": "https://fauxpay.chaad.my.id/mcp" }
  }
}

Read-only, and public

The MCP server is a reference. No tool creates an intent, calls the merchant API or needs an API key, so there is nothing to leak and nothing it can move. It reads the same sources as this page and the live API: the channel registry, the request validation rules, the signer and the webhook builder. Every answer carries "mode": "simulation".

Tool What it answers
plan_integration Start here. Everything needed to integrate FauxPay for one or more currencies (e.g. ["IDR","MYR"]): each market's channels with required payer fields, phone formats, bank lists and amount limits; the endpoints to call with their validation rules; how to sign requests and verify webhooks; and the webhook events to handle.
list_markets Every currency FauxPay supports, its decimal precision, what `amount: 1000` means in it, and the channel keys it offers.
get_channels The payment channels of one currency: what each returns (instruction kind, fields and detail rows), which payer fields it requires, its phone format, bank list and amount limits, and the validation rules for a payout destination on it.
list_endpoints Every endpoint of the FauxPay API (method, path, name). Use describe_endpoint for parameters, validation rules and a real response example.
describe_endpoint One endpoint in full: parameters, auth, success status, the exact validation rules for the two writes, and a real response example. Paths use the reference form, e.g. /api/v1/payment-intents/{intent}.
signing_guide How to authenticate API requests (X-Api-Key + HMAC-SHA256 X-Signature over the raw body or query string), how to verify X-FauxPay-Signature on webhooks, worked signature examples to test against, auth error codes and code snippets.
webhook_events Every webhook event FauxPay sends, the headers, the payload shape for intents and payouts, and the retry schedule.
list_scenarios Test scenarios: pass `scenario` when creating an intent or payout to force its outcome (success, failure, pending, delay, expiry) deterministically.
migration_guide How to port a FauxPay integration to a real gateway: auth, create payment, statuses, webhooks, idempotency, amounts and payouts mapped field by field, with the gateway's official sources. Omit `gateway` to list the available guides and their keys.

Changelog

What changed in the API, newest first. Entries marked Breaking can turn a request that used to succeed into a 422, or change a field your code reads.

2026-11-06

  • Webhook endpoints per event

    Dashboard → Settings now takes up to 10 extra endpoints, each subscribed to chosen events (or all). An event still goes to the record's callback_url, and also to every endpoint that listens for it; a URL never receives one event twice.

  • amount_paid

    Intents and payment_intent.* webhooks carry amount_paid: what actually arrived. It equals amount except under the new underpaid (90%) and overpaid (110%) scenarios, and the ledger and refunds follow it.

  • Returned payouts

    New scenario payout_returned and event payout.returned: a payout lands (payout.paid), then the bank sends it back. Its status stays paid; returned_at and return_reason are set, and a payout_return ledger credit restores the balance.

  • Paid after expiry

    New scenario late_payment and event payment_intent.late_payment: the intent expires, then the money arrives. The intent stays expired and returns late_payment {late_payment_id, amount, received_at}; a late_payment ledger credit holds the money, and POST …/refunds can return it.

  • Webhooks out of order

    New scenario payout_returned_out_of_order holds payout.paid back 10 seconds, so payout.returned arrives first. Read status and returned_at, never the arrival order.

  • scenario_delay

    POST /api/v1/payment-intents accepts scenario_delay (0 to 600 seconds) to set when a scenario's outcome lands. Only together with scenario.

  • Clearer ids, old names kept

    Transactions return transaction_id and ledger entries balance_transaction_id. reference on both, and payment_method on intents, are deprecated aliases and keep working.

2026-11-05

  • Refunds

    POST /api/v1/payment-intents/{intent}/refunds returns money from a successful intent, in full or in part, as its own rf_ record with a ledger debit and a refund.succeeded webhook. Your reference is single-use, so a replay cannot refund twice. GET lists them.

  • Writes carry only a signed body Breaking

    A POST with a query string is refused with 400 query_not_signed, and a POST with an empty body with 400 body_required. POST /api/v1/payouts/{payout}/cancel now takes {"payout_id": "po_..."} in its body.

  • Signature version 2

    Send X-Signature-Version: 2 and X-Timestamp to sign the timestamp, method, path, query and body. Captured requests stop working after the tolerance window. Version 1 still works.

  • GET signing documented correctly

    The signed query string is the normalised one: keys sorted, values RFC 3986 encoded. The reference, the dashboard and the MCP server used to say "in the order you write it", which the server never accepted.

  • Unknown list filters are a 422 Breaking

    Every /api/v1 list now refuses a query key it does not read, instead of ignoring it. New filters: currency on intents, order_id on payouts, intent_id and order_id on transactions, created_from and created_to, and per_page (1 to 100).

  • Every API error has error.code Breaking

    404, 405, 429 and 500 now use the {"error": {"code", "message"}} shape and never name an internal class. A 422 keeps errors.{field} and adds error.code: validation_failed, or a business code such as order_id_taken, insufficient_balance, amount_out_of_range, channel_not_payable, payout_not_cancellable.

  • Safe retries

    Sending the identical create request again returns the original intent or payout (200, Idempotent-Replayed: true). A different request under a used order_id is still a 422.

  • Webhook event_id and timestamped signature

    Every webhook carries event_id (the same on every retry and resend), created_at, X-FauxPay-Event-Id, and X-FauxPay-Signature-V2 over "{X-FauxPay-Timestamp}.{body}". Intent webhooks also carry metadata.

  • Webhook URLs must be public Breaking

    A callback_url or default webhook URL that points at a private, loopback or metadata address is refused, and redirects are no longer followed. A hosted install also requires https.

  • description, metadata and expires_in

    POST /api/v1/payment-intents accepts description, up to 20 metadata strings and expires_in (seconds). Intents return them, and amount_formatted.

  • Rate limits counted after authentication

    The per-key budget only counts requests that prove they are yours, and failed attempts are counted per IP, so knowing a public key no longer lets anyone lock you out.

  • Hosted checkout asks for payer details

    If the chosen channel needs a name, email, phone or bank that the intent was created without, the checkout asks the payer and enforces it at resolve.

  • Numbers no longer follow the row id Breaking

    Virtual account numbers, konbini codes and per-payer accounts are derived from the intent's public id, so they no longer reveal how many intents exist.

  • Smaller consistency fixes

    One timestamp format everywhere (2026-09-12T16:58:32Z); payouts also return payment_channel (channel is deprecated); payment_channel is up to 64 characters on payouts; a channel that cannot receive payouts has requirements.payout_destination null; new scenarios delayed_fail and duplicate_webhook.

2026-11-04

  • MCP server

    A read-only MCP server at /mcp lets an AI agent look up channels, required fields, endpoints, validation rules, signing and webhooks. See MCP server for AI agents above.

2026-11-03

  • instructions narrows to the chosen channel Breaking

    When you pass payment_channel on POST /api/v1/payment-intents, instructions now holds only that channel (the same entry as instruction). Without payment_channel it still holds every channel, and the hosted checkout still offers them all.

  • Payer fields per channel Breaking

    New optional customer (name, name_kana, email, phone) and bank_code on create intent. A channel can require some of them: konbini needs a name and email, zengin a kana name and email, upi an email and phone, promptpay an email, and truemoney, easypaisa, jazzcash and ayapay the payer's phone in that market's format. With payment_channel set, a missing or mis-formatted field is a 422 naming it.

  • requirements on the channel list

    GET /api/v1/payment-channels returns requirements per channel: fields, phone_example, banks and payout_destination.

  • Online banking is a redirect Breaking

    fpx and netbanking instructions now have kind "redirect" with the chosen bank and the bank list, and no account_number, as on the real gateways.

  • details on every instruction

    Every instruction carries a details list of {label, value} rows for what one rail has and another does not: a Mandiri biller code, a Japanese bank and branch code, a VietQR transfer content, each konbini chain's numbers.

  • Realistic destination formats Breaking

    Virtual accounts follow each bank's shape (BCA 15 digits, BNI 16 from 988, Mandiri a 12-digit bill key with a biller code) and show the merchant's name as the holder. Wallet phone is set only for wallets that push an approval to the payer's phone.

  • Payout destinations are validated by rail Breaking

    POST /api/v1/payouts checks the rail's own routing: an IFSC on netbanking, a SWIFT bank_code on fpx, bank code, branch code, account type and a katakana name on zengin, an IBAN or Raast ID on raast, a 10-digit account on bca_transfer and scb_transfer, a phone in the market's format on wallets, and a TRC-20 or ERC-20 address on USDT.

  • Amount limits per channel

    Channels carry the per-transaction range their real gateway documents (for example konbini ¥120 to ¥300,000, MoMo 1,000 to 50,000,000 VND, UPI ₹1 to ₹1,00,000). Outside it is a 422 on payment_channel.

  • Payouts cannot double-spend

    Concurrent payouts for one merchant are now serialised, so two requests can no longer spend the same available balance.

  • Reference corrections

    Payout examples now show utr and order_id. Cancelling a resolved payout returns 422 (the reference said 409), and resolving an already terminal intent on the checkout returns 200 with the intent unchanged (the reference said 409).

Authentication

Two headers, on every merchant request. The public key says who you are; the signature proves the input was not altered in transit. There are no bearer tokens.

Header What it carries
X-Api-Key Your public key, e.g. pk_…
X-Signature HMAC-SHA256 of the signed payload, the raw body, or the query string on a GET, hex encoded, keyed with your secret

Sign the bytes you actually send. Serialise the payload once and reuse that exact string: re-encoding a parsed object changes key order and whitespace, and the signature will not match. A GET has no body, so it signs its query string instead (HMAC('') when there are no parameters), normalised: parameters sorted by key, values RFC 3986 encoded. ?status=pending&page=2 is signed as page=2&status=pending, not in the order you wrote it. A POST must carry a non-empty JSON body and no query string, or it is refused with 400.

Version 2: timestamped (recommended)

Version 1 signs only the input, so a captured request can be replayed. Send X-Signature-Version: 2 and X-Timestamp (Unix seconds) and sign the five lines below joined by \n. The path, query and time are bound, so the request cannot be redirected, and it is refused (stale_timestamp) more than 300 seconds from the server clock.

{X-Timestamp}
{METHOD}                e.g. POST
{path}                  e.g. /api/v1/payment-intents
{normalised query}      sorted, RFC 3986; empty on a POST
{raw body}              empty on a GET

The signing recipe

The same scheme secures inbound merchant requests and outbound webhooks, in both directions, so you implement it once.

<?php

$payload = json_encode([
    'amount'       => 150000,
    'currency'     => 'IDR',
    'order_id'     => 'ORDER-1029',
    'callback_url' => 'https://merchant.test/webhooks/fauxpay',
    'return_url'   => 'https://merchant.test/orders/1029/thank-you',
], JSON_UNESCAPED_SLASHES);

$response = Http::withHeaders([
    'X-Api-Key'   => config('services.fauxpay.public_key'),
    'X-Signature' => hash_hmac('sha256', $payload, config('services.fauxpay.secret')),
])->withBody($payload, 'application/json')
  ->post(config('services.fauxpay.url').'/api/v1/payment-intents');

$intent = $response->json('data');

return redirect($intent['checkout_url']);
# The same request with curl: note --data-binary, which sends the file
# byte for byte. A shell that re-quotes the body will break the signature.

BODY='{"amount":150000,"currency":"IDR","order_id":"ORDER-1029"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$FAUXPAY_SECRET" | awk '{print $2}')

curl -X POST "$FAUXPAY_URL/api/v1/payment-intents" \
  -H "X-Api-Key: $FAUXPAY_PUBLIC_KEY" \
  -H "X-Signature: $SIGNATURE" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  --data-binary "$BODY"
# A GET signs its query string, not an empty body.
# Write the parameters the same way you put them in the URL.

QUERY='currency=IDR'
SIGNATURE=$(printf '%s' "$QUERY" | openssl dgst -sha256 -hmac "$FAUXPAY_SECRET" | awk '{print $2}')

curl -s "$FAUXPAY_URL/api/v1/payment-channels?$QUERY" \
  -H "X-Api-Key: $FAUXPAY_PUBLIC_KEY" \
  -H "X-Signature: $SIGNATURE" \
  -H 'Accept: application/json'

Errors

There are two failure shapes, and they mean different things. Authentication and authorisation failures carry a stable error.code you can branch on. Request validation failures carry a field-keyed errors object, and every other failure, including a payout the balance cannot cover, arrives as one of these too.

Authentication and authorisation · 401, 403

{
  "mode": "simulation",
  "error": {
    "code": "invalid_signature",
    "message": "The request signature does not match the raw body and your secret key."
  }
}

Validation · 422

{
  "mode": "simulation",
  "message": "The amount field is required.",
  "errors": {
    "amount": [
      "The amount field is required."
    ]
  }
}
Status Shape Meaning
401 error.code missing_api_key: no X-Api-Key header.
401 error.code invalid_api_key: the key is unknown or has been revoked.
401 error.code missing_signature: no X-Signature header.
401 error.code invalid_signature: the signature does not match the signed payload and secret.
401 error.code stale_timestamp: a version 2 X-Timestamp is missing or outside the tolerance window.
400 error.code query_not_signed: a POST carries a query string. Send write input in the body.
400 error.code body_required: a POST has an empty body.
400 error.code unsupported_signature_version: X-Signature-Version is neither 1 nor 2.
403 error.code merchant_inactive: the merchant account is deactivated.
404 error.code The resource does not exist, or belongs to another merchant.
422 errors object The request was rejected. Includes an insufficient balance, a channel that cannot receive payouts, cancelling a payout that is already resolved, or a list filter outside its allowed values: an unknown currency, status, type or settlement state. A filter never silently returns the wrong rows.
429 message Rate limit exceeded. Every response, including this one, keeps the `mode` marker.

Every response, success or failure, carries "mode": "simulation", including the ones FauxPay never reaches your handler for: an unknown path, a wrong HTTP method, a rate limit and an unexpected server error all return the same marker, so a body you did not expect is still unambiguously a sandbox response and not a production one.

Pagination

Every list endpoint returns the same envelope: 25 items per page, newest first, with the full URLs to walk. Pass ?page=N.

{
  "mode": "simulation",
  "data": [ "…" ],
  "links": {
    "first": "https://fauxpay.test/api/v1/payouts?page=1",
    "last": "https://fauxpay.test/api/v1/payouts?page=4",
    "prev": null,
    "next": "https://fauxpay.test/api/v1/payouts?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "to": 25,
    "last_page": 4,
    "per_page": 25,
    "total": 88,
    "path": "https://fauxpay.test/api/v1/payouts",
    "links": [
      { "url": null, "label": "&laquo; Previous", "page": null, "active": false },
      { "url": "https://fauxpay.test/api/v1/payouts?page=1", "label": "1", "page": 1, "active": true },
      { "url": null, "label": "Next &raquo;", "page": null, "active": false }
    ]
  }
}

Use links.next for cursor-style walking; ignore the meta.links array, which is meant for rendering page buttons.

Rate limits

Limits are generous enough to develop against and low enough to notice. Exceeding one returns 429.

Scope Limit
Merchant API (per API key, counted after authentication) 600 requests / minute
Failed authentication (per IP) 30 attempts / minute
Public checkout (per IP) 300 requests / minute
Sign-in and registration (per IP) 10 requests / minute
MCP server (per IP) 120 requests / minute

Payment intents

A payment intent is one checkout attempt. Creating it returns a hosted checkout URL — hand that to the payer and you are done. Pass a `payment_channel` instead and the response carries the destination itself, so you can render your own payment page. Either way, resolving is not something your API key can do: the payer does that on our surface, and a FauxPay operator can do it for support.

POST /api/v1/payment-intents 201 Created

Create a payment intent

An `order_id` is single-use per merchant, across deposits and withdrawals. Retrying the identical request (after a timeout, say) returns the original intent with `200` and `Idempotent-Replayed: true`; a different request under an id you already used is a `422` (`order_id_taken`). Your own **request rules** (dashboard → Request Rules) apply per currency: a disabled currency returns `422` naming `currency`, an amount below your minimum `422` naming `amount`, and going over your per-minute limit `429`. Without `payment_channel`, **every channel this currency offers gets its destination issued up front**, keyed by channel key in `instructions` — so you can render your own payment page, with all methods and their formats, straight from this response. Pass `payment_channel` and `instructions` carries only that channel, which is also the single `instruction`. A channel may require payer fields, as the real gateway would (Stripe konbini wants a name and an email, Omise TrueMoney the payer's phone): with `payment_channel` set, a missing or mis-formatted one is a `422` naming the field. Each channel lists what it needs under `requirements` on `GET /api/v1/payment-channels`.

Field Type Required Notes
amount integer Yes Amount in the smallest unit of the currency — `1000` is `Rp1,000` in IDR but `₹10.00` in INR. See Currencies & amounts.
currency string Yes An active currency code, e.g. `IDR`.
order_id string Yes Your own order id. Single-use per merchant across deposits and withdrawals.
payment_channel string No A channel key from `GET /api/v1/payment-channels` for this currency. Marks that channel as the chosen `instruction` and narrows `instructions` to it. Omit it to receive every channel.
customer.name string Per channel The payer's name. Required by `konbini` and `zengin`.
customer.name_kana string Per channel The payer's name in katakana. Required by `zengin`, where the bank matches the sender name.
customer.email string Per channel Required by `konbini`, `zengin`, `upi` and `promptpay`.
customer.phone string Per channel In the rail's own format (`requirements.phone_example` on the channel list, e.g. `0812345678` in Thailand, `9876543210` in India). Required by push wallets — `truemoney`, `easypaisa`, `jazzcash`, `ayapay` — which send the approval to it.
bank_code string No For an online-banking redirect (`fpx`, `netbanking`): a `code` from the channel's `requirements.banks`. Needs `payment_channel`.
description string No Up to 255 characters, echoed back on the intent.
metadata object No Up to 20 string values for your own reconciliation (e.g. `{"cart_id": "C-88"}`). Echoed on the intent and on its webhooks; FauxPay never reads it.
expires_in integer No Seconds until the intent expires, 60 to 604800. Defaults to the sandbox TTL.
callback_url string No Where to send webhooks. Falls back to your default webhook URL.
return_url string No Where to send the payer once the checkout finishes.
scenario string No Force a deterministic outcome, e.g. `always_fail`.
scenario_delay integer No Seconds (0 to 600) before the scenario's forced outcome lands, in place of its own delay. Only with `scenario`; without one it is a `422`.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "description": null,
    "metadata": { "cart_id": "C-88" },
    "amount": 150000,
    "amount_formatted": "Rp150,000",
    "amount_paid": null,
    "currency": "IDR",
    "status": "pending",
    "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDuebxr4FDEHHHRYEEDYNs7CSKvxYWQ1jSzAbnBV",
    "scenario": null,
    "scenario_delay": null,
    "late_payment": null,
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": {
      "kind": "transfer",
      "label": "Virtual account number",
      "account_number": "390080000000003",
      "account_holder": "Toko Sandbox",
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32Z",
      "details": []
    },
    "instructions": {
      "bca_va": { "kind": "transfer", "account_number": "390080000000003", "account_holder": "Toko Sandbox", "…": "…" }
    },
    "expires_at": "2026-09-12T17:28:32Z",
    "resolved_at": null,
    "created_at": "2026-09-12T16:58:32Z"
  }
}
GET /api/v1/payment-intents/{intent} 200 OK

Retrieve a payment intent

Read one intent by its `pi_…` id. An intent belonging to another merchant returns `404`, never their data. `instructions` carries the destination for every channel the currency offers until one is chosen, then only the chosen one — issued once at creation, so a page that reloads redraws the same numbers rather than minting new ones. `instruction` is the one chosen, if any.

Field Type Required Notes
intent path Yes The `pi_…` intent id.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "description": null,
    "metadata": { "cart_id": "C-88" },
    "amount": 150000,
    "amount_formatted": "Rp150,000",
    "amount_paid": null,
    "currency": "IDR",
    "status": "success",
    "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDuebxr4FDEHHHRYEEDYNs7CSKvxYWQ1jSzAbnBV",
    "scenario": null,
    "scenario_delay": null,
    "late_payment": null,
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": {
      "kind": "transfer",
      "label": "Virtual account number",
      "account_number": "390080000000003",
      "account_holder": "Toko Sandbox",
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32Z"
    },
    "instructions": { "bca_va": { "…": "…" } },
    "expires_at": "2026-09-12T17:28:32Z",
    "resolved_at": "2026-09-12T16:58:32Z",
    "created_at": "2026-09-12T16:58:32Z"
  }
}
GET /api/v1/payment-intents 200 OK

List payment intents

Newest first, 25 per page. Filter with `status` and `order_id`. `status` is checked against the status enum, so a value outside it — a typo like `sucess` — returns `422` rather than a misleading empty page. `order_id` is your own identifier, so any string is legal and a miss is a real "no such order". Each entry is the same object as the retrieve endpoint, `instruction` and `instructions` included.

Field Type Required Notes
status query string No One of `pending`, `processing`, `success`, `failed`, `expired`.
order_id query string No Exact match on your order id.
currency query string No An existing currency code, e.g. `IDR`; an unknown one returns `422`.
created_from query string No ISO 8601 date or time; only records created at or after it.
created_to query string No ISO 8601 date or time; only records created at or before it.
per_page query string No 1 to 100, default 25. Any query key a list does not list here is a `422`, never silently ignored.
page query integer No Page number, 25 per page.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "order_id": "ORDER-1029",
      "description": null,
      "metadata": { "cart_id": "C-88" },
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "amount_paid": null,
      "currency": "IDR",
      "status": "success",
      "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDu…",
      "scenario": null,
      "scenario_delay": null,
      "late_payment": null,
      "payment_method": "bca_va",
      "payment_channel": "bca_va",
      "instruction": { "kind": "transfer", "account_number": "390080000000003", "…": "…" },
      "instructions": { "bca_va": { "…": "…" } },
      "expires_at": "2026-09-12T17:28:32Z",
      "resolved_at": "2026-09-12T16:58:32Z",
      "created_at": "2026-09-12T16:58:32Z"
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=4", "prev": null, "next": "…?page=2" },
  "meta": { "current_page": 1, "from": 1, "to": 25, "last_page": 4, "per_page": 25, "total": 88 }
}
POST /api/v1/payment-intents/{intent}/refunds 201 Created

Refund a payment intent

Returns money from a `success` intent, in full or in part. The intent keeps its status; each refund is its own `rf_…` record with its own ledger debit (effective immediately) and a `refund.succeeded` webhook. Several partial refunds may add up to the intent amount, never more (`refund_exceeds_remaining`). `reference` is your own refund id and is single-use: retrying the identical request returns the same refund (`200`, `Idempotent-Replayed: true`), so a replayed request cannot refund twice.

Field Type Required Notes
intent path Yes The `pi_…` intent id.
reference string Yes Your refund id, up to 128 characters, single-use per merchant.
amount integer No Minor units to refund. Defaults to everything not yet refunded.
reason string No Up to 255 characters, echoed back.

Example response

{
  "mode": "simulation",
  "data": {
    "refund_id": "rf_01M2B9A4X7Q3D0V5H1K8N6C2TR",
    "reference": "RF-1029-1",
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "amount": 50000,
    "amount_formatted": "Rp50,000",
    "currency": "IDR",
    "status": "succeeded",
    "reason": "Item out of stock",
    "created_at": "2026-09-12T17:10:04Z"
  }
}
GET /api/v1/payment-intents/{intent}/refunds 200 OK

List refunds of an intent

Every refund on one intent, newest first.

Field Type Required Notes
intent path Yes The `pi_…` intent id.
per_page query string No 1 to 100, default 25.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "refund_id": "rf_01M2B9A4X7Q3D0V5H1K8N6C2TR",
      "reference": "RF-1029-1",
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "amount": 50000,
      "amount_formatted": "Rp50,000",
      "currency": "IDR",
      "status": "succeeded",
      "reason": "Item out of stock",
      "created_at": "2026-09-12T17:10:04Z"
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "from": 1, "to": 1, "last_page": 1, "per_page": 25, "total": 1 }
}

Checkout (public)

These endpoints are scoped to the opaque token in the checkout URL. They require no API key and expose no merchant secret — that is what lets you hand the URL to a browser. All three return the same checkout object.

GET /api/v1/checkout/{token} 200 OK

Load a checkout session

Returns everything the checkout page needs, including the payment channels available for this intent's currency. `channels` is shown truncated here; every entry carries `key`, `name`, `kind`, `kind_label`, `panel`, `provider`, `logo_text`, `description`, `instructions` (the how-to steps), `instruction` (the destination already issued for that channel), `supports_payout` and `limits`. Because every channel carries its own destination, the page can render all methods at once instead of making the payer pick before seeing anything.

Field Type Required Notes
token path Yes The token from `checkout_url`.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "amount": 150000,
    "currency": {
      "code": "IDR",
      "name": "Indonesian Rupiah",
      "symbol": "Rp",
      "decimal_precision": 0,
      "formatted": "Rp150,000"
    },
    "status": "pending",
    "status_label": "Pending",
    "badge": "pending",
    "reference": null,
    "merchant": { "name": "Nusantara Coffee Co." },
    "payment_method": null,
    "payment_channel": null,
    "instruction": null,
    "channels": [
      {
        "key": "bca_va",
        "name": "BCA Virtual Account",
        "kind": "virtual_account",
        "kind_label": "Virtual account",
        "panel": "transfer",
        "provider": "BCA",
        "logo_text": "BCA",
        "description": "Pay from any BCA channel: myBCA, m-BCA, or an ATM.",
        "instructions": ["Open your mobile banking app", "Choose Transfer, then Virtual Account", "…"],
        "instruction": { "kind": "transfer", "account_number": "390080000000003", "account_holder": "Toko Sandbox", "…": "…" },
        "supports_payout": false,
        "limits": { "min_amount": null, "max_amount": null }
      }
    ],
    "return_url": "https://merchant.test/orders/1029/thank-you",
    "scenario": null,
    "expires_at": "2026-09-12T17:28:32Z",
    "is_expired": false
  }
}
POST /api/v1/checkout/{token}/channel 200 OK

Select a payment channel

Marks the chosen channel on the intent and returns its destination: a virtual account number, a QR payload, a wallet deep link, a counter code. The destination was already issued when the intent was created (see `instructions` on the load response), so calling this again returns the same one — it never mints a new account number.

Field Type Required Notes
token path Yes The checkout token.
payment_channel body string Yes A channel key offered for this currency.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "status": "pending",
    "status_label": "Pending",
    "badge": "pending",
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": {
      "kind": "transfer",
      "label": "Virtual account number",
      "account_number": "390080000000003",
      "account_holder": "Toko Sandbox",
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32Z"
    },
    "channels": ["… the same list as above …"],
    "expires_at": "2026-09-12T17:28:32Z",
    "is_expired": false
  }
}
POST /api/v1/checkout/{token}/resolve 200 OK

Resolve the intent

Records the payer's outcome. Success and failure write a transaction and a ledger entry. A `scenario` on the intent overrides whatever you send here. Resolving an intent that is already terminal returns `200` with the intent unchanged rather than writing a second transaction.

Field Type Required Notes
token path Yes The checkout token.
outcome body string Yes `success`, `failed`, or `pending`.
payment_channel body string No Select a channel as part of resolving, for a one-call flow.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "status": "success",
    "status_label": "Success",
    "badge": "success",
    "reference": "txn_01M2B8WW0C4Y4WZY1NGYTHDAQJ",
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": { "kind": "transfer", "account_number": "390080000000003", "…": "…" },
    "channels": ["… the same list as above …"],
    "expires_at": "2026-09-12T17:28:32Z",
    "is_expired": false
  }
}

Payment channels

Which payment methods a market offers. A channel's `kind` decides how the checkout page renders it; `panel` names the component that does the rendering.

GET /api/v1/payment-channels 200 OK

List channels for a currency

Call this before creating an intent if you want to show your own payer-facing channel picker. Every `key` it returns is accepted as `payment_channel` when you create an intent. `limits.min_amount` and `limits.max_amount` come from the currency-to-channel mapping and are `null` when the market sets no bound. A bound is enforced: creating an intent, choosing a channel on the hosted checkout, or resolving one outside its band is rejected with a `422` naming `payment_channel`. Both ends are inclusive, and the band is per currency — the same channel can take different amounts in two markets. `requirements` is what the channel needs: `fields` (payer fields a create request must carry), `phone_example` (the rail's phone format), `banks` (codes for `bank_code`) and `payout_destination` (the destination keys a payout on this rail must send).

Field Type Required Notes
currency query string Yes An active currency code.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "key": "qris",
      "name": "QRIS",
      "kind": "qr_code",
      "kind_label": "QR code",
      "panel": "qr",
      "provider": "QRIS",
      "logo_text": "QRIS",
      "description": "Scan with any Indonesian wallet or mobile banking app.",
      "instructions": ["Open any app that supports QRIS", "Scan the code above", "Check the merchant name and amount, then confirm"],
      "supports_payout": false,
      "requirements": { "fields": [], "phone_example": null, "banks": [], "payout_destination": null },
      "limits": { "min_amount": 1000, "max_amount": 10000000 }
    },
    {
      "key": "gopay",
      "name": "GoPay",
      "kind": "ewallet",
      "kind_label": "E-wallet",
      "panel": "wallet",
      "provider": "GoPay",
      "logo_text": "GOPAY",
      "description": "Redirect to the GoPay app to approve the payment.",
      "instructions": ["Tap the button to open GoPay", "Review the payment details", "Approve with your PIN or biometrics", "Come back here: the page updates itself"],
      "supports_payout": true,
      "requirements": { "fields": [], "phone_example": "081234567890", "banks": [], "payout_destination": ["account_name", "account_number"] },
      "limits": { "min_amount": null, "max_amount": 50000000 }
    }
  ]
}

Money & payouts

Captured money lands in pending first and becomes available when the settlement sweep runs. Payouts spend only the available balance, and the debit is held the moment a payout is accepted.

GET /api/v1/balance 200 OK

Retrieve your balance

One entry per currency, and only for currencies you have touched. Balances are derived from the ledger, so they can never disagree with the entries behind them.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "currency": "IDR",
      "available": 300000,
      "pending": 0,
      "total": 300000,
      "available_formatted": "Rp300,000",
      "pending_formatted": "Rp0",
      "total_formatted": "Rp300,000"
    }
  ]
}
GET /api/v1/balance-transactions 200 OK

List ledger entries

Every movement behind your balance, newest first. Amounts are signed: a credit is positive, a debit negative. Every filter names a closed set, so a value outside it returns `422` rather than the wrong rows or an empty page. An operator can also correct a balance by hand; those entries appear here as `adjustment`, effective immediately and always carrying the description behind the correction.

Field Type Required Notes
currency query string No An existing currency code, e.g. `IDR`.
type query string No `deposit`, `payout`, `payout_reversal`, `payout_return`, `late_payment`, `refund`, `adjustment`, or `fee` (reserved: FauxPay charges no fees, so it never writes one).
settlement query string No `pending` or `settled`.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "balance_transaction_id": "bt_01M2B8WW0J39D04E1N28GWWFD6",
      "reference": "bt_01M2B8WW0J39D04E1N28GWWFD6",
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "currency": "IDR",
      "type": "deposit",
      "type_label": "Deposit",
      "description": "Deposit for order ORDER-1029",
      "is_settled": false,
      "available_at": "2026-09-12T16:59:32Z",
      "settled_at": null,
      "created_at": "2026-09-12T16:58:32Z"
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "from": 1, "to": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
GET /api/v1/settlements 200 OK

List settlements

One batch per merchant and currency, produced each time the sweep moves pending funds into available.

Field Type Required Notes
currency query string No An existing currency code, e.g. `IDR`; an unknown one returns `422`.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "settlement_id": "stl_01M2B8ZE0GMW68WJKB3N6B4EPX",
      "amount": 300000,
      "amount_formatted": "Rp300,000",
      "currency": "IDR",
      "entry_count": 2,
      "settled_at": "2026-09-12T16:59:56Z"
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "from": 1, "to": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
POST /api/v1/payouts 201 Created

Create a payout

Retrying the identical request returns the original payout (`200`, `Idempotent-Replayed: true`) and holds the balance once; a different request under a used `order_id` is a `422`. Your per-currency request rules apply here too (disabled currency or amount below your minimum: `422`; over your per-minute limit: `429`). Debits the available balance immediately, so the same balance cannot be paid out twice. The channel must belong to the currency and be able to receive money — collect-only channels like QRIS and virtual accounts are rejected. With no scenario the payout stays `processing` — pending — until the merchant dashboard or the operator resolves it; that is what makes it a payout still in flight rather than a fabricated success. `always_success` pays it out, `always_fail` writes a reversing credit, `always_pending` leaves it `processing` so you can cancel it, and `delayed_success` returns `processing` and pays it out a few seconds later (it needs a queue worker).

Field Type Required Notes
amount integer Yes Payout amount in minor units.
currency string Yes An active currency code.
payment_channel string Yes A channel with `supports_payout` for this currency.
order_id string Yes Your own order id. Single-use per merchant across deposits and withdrawals.
destination.account_name string Yes Recipient name.
destination.account_number string Yes In the rail's format: a 10-digit BCA account, a wallet phone in that market's format, a `PK…` IBAN on `raast`, a 7-digit account on `zengin`, a TRC-20 (`T…`) or ERC-20 (`0x…`) address.
destination.* string Per channel The rail's own routing, listed in `requirements.payout_destination`: `ifsc` on `netbanking`; `bank_code` (SWIFT) on `fpx`; `bank_code`, `branch_code`, `account_type` (`futsu`/`toza`) and a katakana `account_name` on `zengin`.
callback_url string No Where to send payout webhooks.
scenario string No `always_fail`, `always_pending`, …

Example response

{
  "mode": "simulation",
  "data": {
    "payout_id": "po_01M2B8ZE8NHA0EMK0ABEG033BQ",
    "reference": "py_01M2B8ZE8NHA0EMK0ABEG033BR",
    "order_id": "WD-2041",
    "amount": 100000,
    "amount_formatted": "Rp100,000",
    "currency": "IDR",
    "payment_channel": "bca_transfer",
    "channel": "bca_transfer",
    "channel_name": "BCA Bank Transfer",
    "destination": {
      "account_name": "Budi Santoso",
      "account_number": "1234567890"
    },
    "utr": null,
    "status": "paid",
    "failure_reason": null,
    "scenario": "always_success",
    "created_at": "2026-09-12T16:59:56Z",
    "resolved_at": "2026-09-12T16:59:56Z",
    "returned_at": null,
    "return_reason": null
  }
}
GET /api/v1/payouts/{payout} 200 OK

Retrieve a payout

Read one payout by its `po_…` id. `failure_reason` is populated only on a failed payout, and it is the same text the payer would have seen.

Field Type Required Notes
payout path Yes The `po_…` payout id.

Example response

{
  "mode": "simulation",
  "data": {
    "payout_id": "po_01M2B8ZE8NHA0EMK0ABEG033BQ",
    "reference": "py_01M2B8ZE8NHA0EMK0ABEG033BR",
    "order_id": "WD-2041",
    "amount": 100000,
    "amount_formatted": "Rp100,000",
    "currency": "IDR",
    "payment_channel": "bca_transfer",
    "channel": "bca_transfer",
    "channel_name": "BCA Bank Transfer",
    "destination": { "account_name": "Budi Santoso", "account_number": "1234567890" },
    "utr": null,
    "status": "paid",
    "failure_reason": null,
    "scenario": null,
    "created_at": "2026-09-12T16:59:56Z",
    "resolved_at": "2026-09-12T16:59:56Z",
    "returned_at": null,
    "return_reason": null
  }
}
GET /api/v1/payouts 200 OK

List payouts

Newest first. Each entry is the same object as the retrieve endpoint.

Field Type Required Notes
status query string No `processing`, `paid`, `failed`, `cancelled`; anything else returns `422`.
currency query string No An existing currency code, e.g. `IDR`; an unknown one returns `422`.
order_id query string No Exact match on your order id: the way to find a payout again after a timed-out create.
created_from query string No ISO 8601 date or time; only records created at or after it.
created_to query string No ISO 8601 date or time; only records created at or before it.
per_page query string No 1 to 100, default 25. Any query key a list does not list here is a `422`, never silently ignored.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "payout_id": "po_01M2B8ZEEM16505ERJGCQYWX79",
      "reference": "py_01M2B8ZEEM16505ERJGCQYWX7A",
      "order_id": "WD-2042",
      "amount": 50000,
      "amount_formatted": "Rp50,000",
      "currency": "IDR",
      "payment_channel": "gopay",
      "channel": "gopay",
      "channel_name": "GoPay",
      "destination": { "account_name": "Siti Rahma", "account_number": "081234567890" },
      "utr": null,
      "status": "cancelled",
      "failure_reason": null,
      "scenario": "always_pending",
      "created_at": "2026-09-12T16:59:56Z",
      "resolved_at": "2026-09-12T16:59:56Z",
      "returned_at": null,
      "return_reason": null
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "from": 1, "to": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
POST /api/v1/payouts/{payout}/cancel 200 OK

Cancel a payout

Only while the payout is still `processing`; anything terminal returns `422` (`errors.payout`). The held debit is returned as a reversal entry, and the payout keeps its original record rather than being deleted.

Field Type Required Notes
payout path Yes The `po_…` payout id.
payout_id string Yes The same `po_…` id, in the JSON body. A write must carry a signed body, so an empty request cannot cancel anything.

Example response

{
  "mode": "simulation",
  "data": {
    "payout_id": "po_01M2B8ZEEM16505ERJGCQYWX79",
    "reference": "py_01M2B8ZEEM16505ERJGCQYWX7A",
    "order_id": "WD-2042",
    "amount": 50000,
    "amount_formatted": "Rp50,000",
    "currency": "IDR",
    "payment_channel": "gopay",
    "channel": "gopay",
    "channel_name": "GoPay",
    "destination": { "account_name": "Siti Rahma", "account_number": "081234567890" },
    "utr": null,
    "status": "cancelled",
    "failure_reason": null,
    "scenario": "always_pending",
    "created_at": "2026-09-12T16:59:56Z",
    "resolved_at": "2026-09-12T16:59:56Z",
    "returned_at": null,
    "return_reason": null
  }
}

Reference data

Read-only data you can render directly.

GET /api/v1/transactions 200 OK

List transactions

The immutable terminal record written when an intent resolves to success or failure. Nothing here is ever updated.

Field Type Required Notes
status query string No `success` or `failed`; anything else returns `422`.
currency query string No An existing currency code, e.g. `IDR`; an unknown one returns `422`.
intent_id query string No The `pi_…` intent the transaction belongs to.
order_id query string No Your order id on that intent.
created_from query string No ISO 8601 date or time; only records created at or after it.
created_to query string No ISO 8601 date or time; only records created at or before it.
per_page query string No 1 to 100, default 25. Any query key a list does not list here is a `422`, never silently ignored.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "transaction_id": "txn_01M2B8WW0C4Y4WZY1NGYTHDAQJ",
      "reference": "txn_01M2B8WW0C4Y4WZY1NGYTHDAQJ",
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "order_id": "ORDER-1029",
      "amount": 150000,
      "amount_formatted": "Rp150,000",
      "currency": "IDR",
      "status": "success",
      "paid_at": "2026-09-12T16:58:32Z",
      "created_at": "2026-09-12T16:58:32Z"
    }
  ],
  "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "from": 1, "to": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
GET /api/v1/currencies 200 OK

List currencies

Supported currencies with the decimal precision to use when converting to minor units. This is the field that decides how a market's amounts read, and the same integer reads differently in each: `1000` is `Rp1,000` in IDR, whose smallest unit is the rupiah, but `₹10.00` in INR, whose smallest unit is the paise. The Currencies & amounts section below works the same example through every market. This example is generated from the table the endpoint reads, so it cannot drift from the response.

Example response

{
    "mode": "simulation",
    "data": [
        {
            "code": "IDR",
            "name": "Indonesian Rupiah",
            "symbol": "Rp",
            "symbol_position": "prefix",
            "decimal_precision": 0,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "INR",
            "name": "Indian Rupee",
            "symbol": "₹",
            "symbol_position": "prefix",
            "decimal_precision": 2,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "MYR",
            "name": "Malaysian Ringgit",
            "symbol": "RM",
            "symbol_position": "prefix",
            "decimal_precision": 2,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "VND",
            "name": "Vietnamese Dong",
            "symbol": "₫",
            "symbol_position": "prefix",
            "decimal_precision": 0,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "MMK",
            "name": "Myanmar Kyat",
            "symbol": "K",
            "symbol_position": "prefix",
            "decimal_precision": 0,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "THB",
            "name": "Thai Baht",
            "symbol": "฿",
            "symbol_position": "prefix",
            "decimal_precision": 2,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "JPY",
            "name": "Japanese Yen",
            "symbol": "¥",
            "symbol_position": "prefix",
            "decimal_precision": 0,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "USDT",
            "name": "Tether (simulated)",
            "symbol": "₮",
            "symbol_position": "prefix",
            "decimal_precision": 6,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "PHP",
            "name": "Philippine Peso",
            "symbol": "₱",
            "symbol_position": "prefix",
            "decimal_precision": 2,
            "grouping": "western",
            "is_active": true
        },
        {
            "code": "PKR",
            "name": "Pakistani Rupee",
            "symbol": "₨",
            "symbol_position": "prefix",
            "decimal_precision": 2,
            "grouping": "western",
            "is_active": true
        }
    ]
}
GET /api/v1/health 200 OK

Health check

Public. No API key and no signature required, so you can use it as a connectivity probe before you have signing working.

Example response

{
  "mode": "simulation",
  "status": "ok",
  "service": "FauxPay",
  "time": "2026-09-12T16:58:08+00:00",
  "currencies": ["IDR", "INR", "MYR", "VND", "MMK", "THB", "JPY", "USDT", "PHP", "PKR"]
}

Bring your own checkout

The hosted checkout is optional. Name the channel when you create the intent and FauxPay hands back the destination instead of a redirect: so the payer never leaves your domain, and you decide what the page around it looks like.

1 · Offer the channels

GET /api/v1/payment-channels?currency=IDR returns every channel this market offers, in its own display order. Render them however you like: the key is what you send back.

2 · Name one at creation

POST /api/v1/payment-intents with payment_channel set. The response carries instruction: the account number, QR payload, deep link or payment code to put in front of the payer.

3 · Wait for the webhook

Nothing on your page can mark an intent paid. Fulfil the order from payment_intent.succeeded, or poll the intent while the payer is still on screen.

GET /api/v1/payment-channels?currency=IDR

  → [ { "key": "qris", ... }, { "key": "gopay", ... }, { "key": "bca_va", ... } ]

POST /api/v1/payment-intents

  {
    "amount": 150000,
    "currency": "IDR",
    "order_id": "ORDER-1029",
    "payment_channel": "bca_va"
  }

201 Created

  {
    "mode": "simulation",
    "data": {
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "status": "pending",
      "payment_channel": "bca_va",
      "instruction": {
        "kind": "transfer",
        "label": "Virtual account number",
        "account_number": "88080000003",
        "account_holder": "BCA",
        "amount": 150000,
        "amount_formatted": "Rp150,000",
        "unique_code": null
      }
    }
  }

What instruction.kind tells you to render

One switcher over six values covers every market. Switch on the kind, never on the currency: the same component renders a Vietnamese wallet and an Indian one. Every instruction also carries details: a list of {label, value} rows for what one rail has and another does not: a Japanese branch code, a Mandiri biller code, a VietQR transfer content, each konbini chain's numbers. Render the list as is.

kind Show the payer Fields to read
transfer Account details to copy, or a QR built from them account_number, account_holder, bank_name, unique_code, total_amount
qr A QR code qr_string, payee_handle, acquirer
wallet A button that opens the wallet app or, when phone is set, a note that the approval was pushed to that phone app_name, deep_link, phone, reference
redirect A button to the payer's online banking (FPX, net banking); no account number app_name, bank_code, bank_name, banks, reference
counter A payment code to take to a shop payment_code, outlet
crypto An address and its network address, network, qr_string, confirmation_note

Every destination is obviously fake. A QR payload starts with FAUXPAY-SANDBOX| and a crypto address with TSimulated, so scanning one in a real payment app resolves to nothing rather than to someone's account. Do not dress them up as real, and do not ask the payer for card details: no FauxPay channel ever needs them.

Resending an open intent switches its destination. Posting the same order_id again with a different payment_channel returns the same intent with a new instruction: which is what makes a payer changing their mind before paying work. Omit the field and an already-issued instruction is left alone.

Webhooks

FauxPay POSTs a signed payload to your callback_url when an intent or payout reaches a terminal state. Fulfil orders from this, never from the browser redirect: the redirect is a convenience for the customer, not proof of payment.

Events

  • payment_intent.succeeded
  • payment_intent.failed
  • payment_intent.expired
  • payout.paid
  • payout.failed
  • payout.cancelled
  • payout.returned
  • payment_intent.late_payment
  • refund.succeeded
  • webhook.ping

An intent event is payment_intent. plus its status, with success renamed to succeeded; a payout event is payout. plus its status. Only terminal states are ever sent, an intent or payout still in flight produces nothing, so this list is the complete set. Treat an event you do not recognise as a no-op rather than an error.

Delivery

  • Retries at 1m, 5m, 30m, 2h: four attempts, then given up.
  • Any non-2xx, or an unreachable endpoint, counts as a failure.
  • Every attempt is logged with its response code and latency.
  • You can replay a delivery from the dashboard; it re-sends the recorded payload under the same signature.
  • Every retry and resend carries the same event_id: dedupe on it, not on the intent id.
  • X-FauxPay-Signature-V2 also signs X-FauxPay-Timestamp, so you can refuse a delivery replayed later.
POST {callback_url}
X-FauxPay-Signature: <hmac-sha256 of the raw body, keyed with your secret>
X-FauxPay-Signature-V2: <hmac-sha256 of "{X-FauxPay-Timestamp}.{raw body}">
X-FauxPay-Timestamp: 1767225600
X-FauxPay-Event-Id: evt_01M2B8WVQ8KVKEVWM6YWG65FV9
X-FauxPay-Event: payment_intent.succeeded
X-FauxPay-Attempt: 1
User-Agent: FauxPay-Webhooks/1.0 (simulation)
Content-Type: application/json
{
  "event_id": "evt_01M2B8WVQ8KVKEVWM6YWG65FV9",
  "event": "payment_intent.succeeded",
  "created_at": "2026-09-12T16:58:32Z",
  "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
  "order_id": "ORDER-1029",
  "status": "success",
  "amount": 150000,
  "currency": "IDR",
  "paid_at": "2026-09-12T16:58:32Z",
  "mode": "simulation"
}

A payout webhook carries payout_id, reference, channel, destination and failure_reason instead of the intent fields.

Verify before you fulfil

Compare in constant time, against the raw body, not against a re-serialised version of it, and never with ===.

<?php

$rawBody   = $request->getContent();
$signature = (string) $request->header('X-FauxPay-Signature');

$expected = hash_hmac('sha256', $rawBody, config('services.fauxpay.secret'));

if (! hash_equals($expected, $signature)) {
    abort(403, 'Invalid signature');
}

$event = json_decode($rawBody, true);

// Retries and resends repeat the same event_id: handle each one once.
if ($event['event'] === 'payment_intent.succeeded' && ! alreadyHandled($event['event_id'])) {
    // Safe to fulfil, keyed by $event['order_id'].
}

Status and event names

Intents and payouts name the same moments differently, so map them once:

ResourcestatusWebhook eventMeans
Intent pending none Created, waiting for the payer
Intent processing none Resolved with a delay; the outcome is on its way
Intent success payment_intent.succeeded Paid; credited to the pending balance
Intent failed payment_intent.failed Not paid; no ledger entry
Intent expired payment_intent.expired Not paid before the TTL
Payout processing none Accepted, funds held, in flight
Payout paid payout.paid Landed; the hold stays a debit
Payout failed payout.failed Rejected; the hold is reversed
Payout cancelled payout.cancelled Cancelled before landing; the hold is reversed
Intent expired (paid late) payment_intent.late_payment Money arrived after expiry; the intent stays expired, the late payment is credited and refundable
Payout paid (returned) payout.returned Landed, then sent back by the bank; status stays paid, a payout_return credit gives the money back

A payout has two ids: payout_id (po_…) identifies it in this API, and reference (py_…) is the reference FauxPay hands to the simulated rail, the one a bank statement would show. Use payout_id to read it back, and utr where the rail issues one.

Currencies & amounts

Amounts are always integers in the currency's smallest unit: never decimals, never floats. The unit is the currency's own, so the same integer does not mean the same money twice: 150000 in IDR is Rp150,000, because the rupiah has no minor unit, but the rupee has 100 paise, so 1000 in INR is ₹10.00 and you send 100000 for ₹1,000. The same holds for JPY, which takes 4800 for ¥4,800, and USDT, which takes six decimals. The last column below works that one integer, 1000, through every market, so the trap of copying an amount between two of them is visible before it is written: it is a real gateway convention, not a FauxPay quirk. FauxPay never converts between currencies.

Two more of a market's habits are data rather than house style: where the symbol sits, and how the thousands group. The dong and the kyat are written after the amount: 1,000 ₫, never ₫1,000, and so is a crypto amount. And 15000000 reads as ₹1,50,000.00 in India, grouped by lakh, but Rp15,000,000 in Indonesia, grouped by thousand. Both come from the currency row, and both are in the table below.

Code Currency Symbol Decimals Grouping 1000 reads as
IDR Indonesian Rupiah Rp 0 western Rp1,000
INR Indian Rupee ₹ 2 western ₹10.00
MYR Malaysian Ringgit RM 2 western RM10.00
VND Vietnamese Dong ₫ 0 western ₫1,000
MMK Myanmar Kyat K 0 western K1,000
THB Thai Baht ฿ 2 western ฿10.00
JPY Japanese Yen ¥ 0 western ¥1,000
USDT Tether (simulated) ₮ 6 western ₮0.001000
PHP Philippine Peso ₱ 2 western ₱10.00
PKR Pakistani Rupee ₨ 2 western ₨10.00

Payment channels by kind

Which channels a market offers is data. What the checkout page does with one is decided by its kind: seven values that do not grow with the number of countries.

virtual_account transfer panel

bca_va, bni_va, mandiri_va

qr_code qr panel

promptpay, upi, vietqr, qrph, duitnow_qr, qris

bank_transfer transfer panel

fpx, zengin, instapay, scb_transfer, raast, netbanking, bca_transfer

crypto crypto panel

usdt_trc20, usdt_erc20

ewallet wallet panel

easypaisa, wavepay, gcash, momo, paytm, zalopay, touchngo, truemoney, maya, kbzpay, paypay, jazzcash, grabpay, phonepe, ayapay, gopay, dana, shopeepay

over_the_counter counter panel

konbini, seven_eleven_ph

Test scenarios

Pass a scenario when you create an intent and the outcome is decided before your test runs. Without one, the payer picks on the checkout page.

Running headless, in CI. An intent's scenario applies when the intent is resolved, and nothing under the signed merchant API resolves one (that is the payer's job on a real gateway too). A test does what the payer would: take the token from checkout_url and call POST /api/v1/checkout/{token}/resolve (no signature) with {"outcome": "success"}. With a scenario set, the scenario wins whatever outcome you send. A payout applies its scenario at creation.

Key Forced outcome Delay Use it for
always_success success 0s Intent resolves as success immediately, no delay.
always_fail failed 0s Intent resolves as failed immediately — useful for QA of failure paths.
always_pending pending 0s Intent stays pending until it expires or is resolved manually.
delayed_success success 5s Success after a 5 second delay — useful for polling tests.
expired expired 0s Intent expires without producing a terminal transaction.

Scenarios apply to payouts too, mapped onto payout states: success → paid, failed and expired → failed, pending → left processing. With no scenario at all a payout stays processing, never auto-paid, until it is resolved in the backoffice or released by the stale-payout sweep.

Ready to make a real request?

Create a sandbox account and you will have a key pair in under a minute.

Moving to a real gateway

No real gateway uses FauxPay's field names, statuses or signature scheme, so moving is a port, not a configuration change. What carries over is the shape: create a payment, send the payer to a checkout, fulfil from a verified webhook, pay out against a held balance. These tables map each piece, from each gateway's own documentation as of 2026-11. FauxPay is not affiliated with any of them; anything marked unverified could not be confirmed in their docs.

Midtrans

Snap (hosted page) or Core API (direct charge) for collection, Midtrans Payouts (formerly Iris) for disbursement. Indonesia, IDR.

Authentication

FauxPayMidtrans
Credential X-Api-Key (public key) plus a per-request signature Server Key only, sent as HTTP Basic auth
Header X-Signature: HMAC-SHA256 of raw body (or normalised query on GET) Authorization: Basic base64(server_key + ":"), empty password
Replay protection Optional v2: X-Signature-Version: 2 + X-Timestamp None beyond TLS and the secret key
Environments One host, every response marked "mode": "simulation" api.sandbox.midtrans.com / app.sandbox.midtrans.com vs production hosts (production host names unverified)

Create a payment

FauxPayMidtrans
Endpoint POST /api/v1/payment-intents Snap: POST /snap/v1/transactions; Core API: POST /v2/charge
Merchant order id order_id transaction_details.order_id (max 50 chars, A-Z a-z 0-9 - _ ~ .)
Amount amount + currency transaction_details.gross_amount (integer); IDR implied
Method selection payment_channel (optional) Snap: enabled_payments array; Core API: payment_type (required)
Expiry Fixed TTL, expires_at in the response Snap: expiry object (unit, duration, optional start_time)
Hosted page checkout_url Snap response: token (for snap.js) and redirect_url
Callback URL callback_url per request, else merchant default Payment Notification URL in dashboard (Settings > Configuration)
Per-request URL override n/a Extra HTTP headers on the charge request (header names unverified)
Return URL return_url Finish / Unfinish / Error redirect URLs in dashboard, or Snap callbacks.finish

Statuses

FauxPayMidtrans
Field status transaction_status (plus fraud_status on some methods)
Waiting pending, processing pending (card: authorize before capture)
Paid success settlement, or capture for cards (with fraud_status accept)
Failed failed deny, failure, cancel
Expired expired expire
After payment none (no refunds in FauxPay) refund, partial_refund
Fetch status GET /api/v1/payment-intents/{intent} GET /v2/{order_id}/status (keyed by your order id)

Webhooks

FauxPayMidtrans
Where the proof is Header X-FauxPay-Signature Body field signature_key (no signature header)
Algorithm HMAC-SHA256(raw body, secret key) SHA512(order_id + status_code + gross_amount + server_key), not HMAC
Event name event: payment_intent.succeeded / .failed / .expired No event field; read transaction_status
Which events Terminal states only Every status change, including pending
Ordering One webhook per terminal transition Can arrive out of order (e.g. settlement before pending)
Success response Any 2xx HTTP 200, within 5 s recommended, failed after 15 s
Retries Up to 4 attempts (1 min, 5 min, 30 min) Up to 5 retries, about 2, 10, 30, 90, 210 min; count depends on status code

Idempotency and retries

FauxPayMidtrans
Order id reuse 422 errors.order_id, single-use across deposits and payouts Duplicate order_id is refused (Core API returns 406)
Retry header none Idempotency-Key on POST (max 46 chars, remembered 5 minutes)

Amounts

FauxPayMidtrans
Unit Integer minor units per currencies row; IDR has 0 decimals Integer gross_amount in whole rupiah
In webhooks Integer amount, same as the request String with decimals, e.g. "10000.00", and that exact string is hashed

Payouts

FauxPayMidtrans
Product POST /api/v1/payouts on the same key Midtrans Payouts (formerly Iris), separate API and credentials
Approval none; accepted payout holds funds immediately Maker/Creator creates, Approver approves (two roles)
Endpoints create, get, list, /cancel POST /api/v1/payouts then POST /api/v1/payouts/approve (unverified: iris-docs unreachable)
Destination destination.account_name, destination.account_number beneficiary_name, beneficiary_account, beneficiary_bank, beneficiary_email
Amount Integer minor units amount as a string (per the official Go SDK)
Statuses processing, paid, failed, cancelled Includes completed and failed; full list unverified
Webhook verification X-FauxPay-Signature, same scheme as payments Unverified (Payouts API docs could not be fetched)

When porting

  • The notification signature is a plain SHA512 digest over concatenated strings, not an HMAC. Use the gross_amount string exactly as received (with .00), not your stored integer.
  • Midtrans recommends confirming a notification with GET /v2/{order_id}/status before fulfilling, and polling it when a notification is late or out of order.
  • A paid card transaction is capture, not settlement. Treat both as paid only when fraud_status is accept (or absent for methods that omit it).
  • FauxPay sends no webhook for pending; Midtrans does. Make the handler ignore non-terminal statuses or move state only forward.
  • The notification URL is account-wide in the dashboard, so the per-intent callback_url habit does not carry over without the override headers.
  • Both IDR conventions agree: FauxPay IDR has precision 0, so amount: 150000 maps to gross_amount: 150000 unchanged.

Sources: https://docs.midtrans.com/docs/snap-snap-integration-guide, https://docs.midtrans.com/docs/snap-advanced-feature, https://docs.midtrans.com/reference/charge-transactions-1, https://docs.midtrans.com/docs/https-notification-webhooks, https://docs.midtrans.com/docs/get-status-api-requests, https://docs.midtrans.com/reference/api-headers, https://docs.midtrans.com/docs/disbursement-overview, https://pkg.go.dev/github.com/midtrans/midtrans-go/iris

Xendit

Payments API v3 (payment requests, or Payment Sessions for a hosted page, which replace legacy Invoices) and Payouts v3. ID, PH, VN, TH, SG, MY and more.

Authentication

FauxPayXendit
Credential X-Api-Key plus a per-request signature Secret API key only, sent as HTTP Basic auth
Header X-Signature: HMAC-SHA256 of raw body / normalised query Authorization: Basic base64(secret_key + ":"), empty password (format unverified, from Xendit quick setup)
API version Path versioned, /api/v1 api-version header: 2024-11-11 for payment requests, 2025-09-01 for payouts v3
Key permissions One key does everything Per-key permissions; payouts need MONEY-OUT

Create a payment

FauxPayXendit
Endpoint (direct) POST /api/v1/payment-intents POST /v3/payment_requests
Endpoint (hosted page) checkout_url on the same intent POST /sessions with mode: PAYMENT_LINK, returns payment_link_url
Merchant order id order_id reference_id (1 to 255 chars)
Flow type implied type: PAY (payment request) or session_type: PAY (session)
Amount amount + currency request_amount (request) or amount (session) + currency
Market derived from currency country (ISO 3166 alpha-2), required on sessions
Method selection payment_channel channel_code + channel_properties (shape depends on the channel)
Capture n/a capture_method: AUTOMATIC (default) or MANUAL
Callback URL callback_url per request Webhook URL per product in dashboard webhook settings
Response id intent_id (pi_...) payment_request_id (e.g. pr-<uuid>)

Statuses

FauxPayXendit
Values are lowercase UPPERCASE
Waiting pending, processing ACCEPTING_PAYMENTS, REQUIRES_ACTION, AUTHORIZED
Paid success SUCCEEDED
Failed failed FAILED
Expired / cancelled expired EXPIRED, CANCELED (one L)
Nested payment object none Separate enum: PENDING, AUTHORIZED, SUCCEEDED, FAILED, EXPIRED, CANCELED
Session statuses n/a ACTIVE, COMPLETED, EXPIRED, CANCELED

Webhooks

FauxPayXendit
Verification header X-FauxPay-Signature x-callback-token
Scheme HMAC-SHA256 of raw body, per-request value Static token compared to the one in dashboard, no body hash
Payment events payment_intent.succeeded / .failed / .expired payment.capture, payment.failure, payment.authorization, payment_request.expiry
Session events n/a payment_session.completed, payment_session.expired
Dedup key intent_id payment_id / capture_id (no event-id header documented)
Success response Any 2xx Any 2xx, return before heavy processing
Retries Up to 4 attempts (1 min, 5 min, 30 min) Up to 6 retries: 15 min, 1 h, 3 h, 6 h, 12 h, 24 h after the original

Idempotency and retries

FauxPayXendit
Order id reuse 422 errors.order_id reference_id must be unique only for CARDS on payment requests
Payment retry header none Not documented on payment requests (unverified)
Payout retry header none (order_id is the guard) idempotency-key (max 100): same body returns the first payout, other body is DUPLICATE_ERROR

Amounts

FauxPayXendit
Payment unit Integer minor units per currencies row request_amount is a number; unit not stated on the page (unverified), IDR examples are whole rupiah
Payout unit Integer minor units Integer minor units, stated: IDR 10 is 10, PHP 10.00 is 1000

Payouts

FauxPayXendit
Endpoint POST /api/v1/payouts POST /v3/payouts
Required fields amount, currency, payment_channel, order_id, destination reference_id, recipient, payout_details, source_of_fund, purpose_code
Amount field amount payout_details.source_amount or destination_amount (one of them)
In flight processing ACCEPTED, REQUESTED, PENDING_COMPLIANCE_ASSESSMENT
Done paid SUCCEEDED (can later become REVERSED)
Not paid failed, cancelled FAILED, CANCELLED (two Ls), COMPLIANCE_REJECTED
Failure detail failure_reason (free text) failure_code, e.g. INSUFFICIENT_BALANCE, INVALID_DESTINATION
Events payout.paid / .failed / .cancelled v3_payout.succeeded / .failed / .reversed / .rejected / .pending_compliance

When porting

  • The callback token is a shared static value, not a signature over the body. Compare it in constant time, and re-fetch the payment request or payout by id if the body drives money movement.
  • Spelling differs inside Xendit: payments use CANCELED, payouts use CANCELLED. Map each enum separately.
  • A succeeded payout can be REVERSED later. FauxPay paid is final, so a handler ported as-is will never undo a reversed payout.
  • FauxPay has no compliance hold; Xendit can park a payout in PENDING_COMPLIANCE_ASSESSMENT for review.
  • Payouts v3 needs recipient identity (type, relationship, address) and a purpose code that FauxPay never asks for.
  • Legacy Invoices are superseded by Payment Sessions; Xendit publishes a migration guide for that move.

Sources: https://docs.xendit.co/apidocs/quick-setup, https://docs.xendit.co/apidocs/create-payment-request, https://docs.xendit.co/apidocs/create-session, https://docs.xendit.co/docs/migrate-to-payment-session, https://docs.xendit.co/docs/payments-api-webhooks, https://docs.xendit.co/apidocs/payment-webhook-notification, https://docs.xendit.co/docs/handling-webhooks, https://docs.xendit.co/apidocs/webhook-behavior, https://docs.xendit.co/apidocs/create-payout-v3, https://docs.xendit.co/apidocs/payout-v3-webhook, https://docs.xendit.co/docs/payout-status-lifecycle

Razorpay

Orders + Payments (Standard Checkout or Payment Links) for collection, RazorpayX Payouts for disbursement. India, INR.

Authentication

FauxPayRazorpay
Credential X-Api-Key (public) + secret used to sign key_id + key_secret, both sent
Header X-Signature: HMAC-SHA256 of raw body / normalised query Authorization: Basic base64(key_id + ":" + key_secret)
Test vs live One host, "mode": "simulation" Separate test and live keys on the same host

Create a payment

FauxPayRazorpay
Endpoint POST /api/v1/payment-intents POST /v1/orders, then Checkout opens with the order_id
Hosted page checkout_url Payment Links: POST /v1/payment_links, returns short_url
Merchant order id order_id receipt on orders, reference_id on payment links (max 40 chars each)
Amount amount + currency amount (integer, smallest sub-unit) + currency
Extra data customer.* notes (up to 15 key-value pairs)
Method selection payment_channel Chosen by the payer in Checkout
Response id intent_id (pi_...) Order id (e.g. order_...), payment id (e.g. pay_...)

Statuses

FauxPayRazorpay
Objects One intent An order and one or more payments, each with its own status
Order n/a created, attempted, paid
Waiting pending, processing Payment created, authorized
Paid success Payment captured (order becomes paid)
Failed failed Payment failed
Expired expired No expired payment state; uncaptured authorized payments are auto-refunded
Browser proof none; trust the webhook razorpay_signature = HMAC-SHA256(order_id + "|" + razorpay_payment_id, key_secret)

Webhooks

FauxPayRazorpay
Header X-FauxPay-Signature X-Razorpay-Signature
Algorithm HMAC-SHA256(raw body, API secret key) HMAC-SHA256(raw body, webhook secret)
Secret The secret of the key that created the intent Set per webhook in dashboard; need not be the API key secret
Payment events payment_intent.succeeded / .failed / .expired payment.authorized, payment.captured, payment.failed, order.paid
Dedup key intent_id / payout_id x-razorpay-event-id header (at-least-once delivery)
Ordering One webhook per terminal transition Can arrive out of order
Success response Any 2xx 2xx within 5 s, otherwise treated as failed
Retries Up to 4 attempts (1 min, 5 min, 30 min) Exponential backoff for 24 h, then the webhook is disabled

Idempotency and retries

FauxPayRazorpay
Order id reuse 422 errors.order_id Duplicate receipt is rejected
Payout retry header none X-Payout-Idempotency (treat as required)

Amounts

FauxPayRazorpay
Unit Integer minor units; INR has paise, 1000 is ₹10.00 Integer paise; 29900 is ₹299.00
Minimum Per channel band, if any 100 (₹1.00) on orders and payouts

Payouts

FauxPayRazorpay
Endpoint POST /api/v1/payouts POST /v1/payouts (RazorpayX)
Destination Inline destination.* (e.g. ifsc on netbanking) fund_account_id, created beforehand from a contact
Source account Merchant balance, implied account_number: your RazorpayX account, not the payee
Rail payment_channel mode: NEFT, RTGS, IMPS (uppercase)
Extra fields order_id purpose (required), reference_id (max 40), queue_if_low_balance
In flight processing queued, pending, processing
Done paid processed (can later become reversed)
Not paid failed, cancelled failed, cancelled, rejected, reversed
Bank reference utr utr
Events payout.paid / .failed / .cancelled payout.processed, payout.reversed, payout.failed, payout.queued, ...

When porting

  • Amounts port unchanged: FauxPay INR minor units are paise, the same unit Razorpay takes.
  • Verify with the webhook secret you set in the dashboard, over the raw body. The API key secret only signs the Checkout handler payload (order_id|payment_id).
  • One FauxPay intent becomes an order plus payments. Fulfil on payment.captured or order.paid, not on payment.authorized, unless you capture manually.
  • Enable auto-capture in the dashboard or call the capture API; an authorized payment that is never captured is refunded automatically.
  • Payouts need a contact and a fund account first. FauxPay takes the destination inline, so this is an extra setup step per payee.
  • For payouts, payout.processed and payout.reversed are final; Razorpay says to ignore later webhooks for that payout. FauxPay has no reversal state.

Sources: https://razorpay.com/docs/api/orders/create/, https://razorpay.com/docs/payments/payment-gateway/web-integration/standard/build-integration/, https://razorpay.com/docs/api/payments/payment-links/create-standard/, https://razorpay.com/docs/webhooks/validate-test/, https://razorpay.com/docs/webhooks/best-practices/, https://razorpay.com/docs/webhooks/faqs/, https://razorpay.com/docs/webhooks/payments/, https://razorpay.com/docs/webhooks/payouts/, https://razorpay.com/docs/api/x/payouts/create/bank-account/