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
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.
# 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.
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-intents201 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`.
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.
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.
POST
/api/v1/payment-intents/{intent}/refunds201 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.
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.
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.
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.
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-channels200 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/balance200 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.
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).
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`.
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.
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.
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.
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.
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
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:
Resource
status
Webhook event
Means
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.
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.
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
FauxPay
Midtrans
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)
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.
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
FauxPay
Xendit
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
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.
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.