Simulation mode Every endpoint below is a sandbox. No real money moves, ever.
FauxPay
On this page 14 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.

Authentication

Two headers, on every merchant request. The public key says who you are; the signature proves the body 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 raw request body, 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. GET requests sign an empty body.

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"

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 raw body and secret.
403 error.code merchant_inactive — the merchant account is deactivated.
404 error.code The resource does not exist, or belongs to another merchant.
409 error.code The intent or payout is already in a terminal state.
422 errors object The request was rejected. Includes an insufficient balance, or a channel that cannot receive payouts.
429 Rate limit exceeded.

Every response, success or failure, carries "mode": "simulation".

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) 120 requests / minute
Public checkout (per IP) 60 requests / minute
Sign-in and registration (per IP) 10 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 the intent is only ever resolved by the payer, never by your API key.

POST /api/v1/payment-intents 201 Created

Create a payment intent

Creating an intent with an `order_id` that already has an open intent returns that existing intent instead of a duplicate — the check and the insert share one transaction and a row lock. Pass `payment_channel` to have the destination issued immediately and returned as `instruction`; leave it out and the payer chooses on the hosted page instead.

Field Type Required Notes
amount integer Yes Amount in the smallest unit of the currency.
currency string Yes An active currency code, e.g. `IDR`.
order_id string Yes Your own order identifier. Reusable once the intent reaches a terminal state.
payment_channel string No A channel key from `GET /api/v1/payment-channels` for this currency. Issued in the response as `instruction`. Resending an open intent with a different key switches the destination; omitting it leaves an already-issued one untouched.
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`.

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "amount": 150000,
    "currency": "IDR",
    "status": "pending",
    "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDuebxr4FDEHHHRYEEDYNs7CSKvxYWQ1jSzAbnBV",
    "scenario": null,
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": {
      "kind": "transfer",
      "label": "Virtual account number",
      "account_number": "88080000003",
      "account_holder": "BCA",
      "amount": 150000,
      "amount_formatted": "Rp1,500.00",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32.000000Z"
    },
    "expires_at": "2026-09-12T17:28:32.000000Z",
    "resolved_at": null,
    "created_at": "2026-09-12T16:58:32.000000Z"
  }
}
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. Once a channel has been chosen the response carries `instruction`, so a page that reloads can redraw the same destination instead of minting a new one.

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

Example response

{
  "mode": "simulation",
  "data": {
    "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
    "order_id": "ORDER-1029",
    "amount": 150000,
    "currency": "IDR",
    "status": "success",
    "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDuebxr4FDEHHHRYEEDYNs7CSKvxYWQ1jSzAbnBV",
    "scenario": null,
    "payment_method": "bca_va",
    "payment_channel": "bca_va",
    "instruction": {
      "kind": "transfer",
      "label": "Virtual account number",
      "account_number": "88080000003",
      "account_holder": "BCA",
      "amount": 150000,
      "amount_formatted": "Rp1,500.00",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32.000000Z"
    },
    "expires_at": "2026-09-12T17:28:32.000000Z",
    "resolved_at": "2026-09-12T16:58:32.000000Z",
    "created_at": "2026-09-12T16:58:32.000000Z"
  }
}
GET /api/v1/payment-intents 200 OK

List payment intents

Newest first, 25 per page. Filter with `status` and `order_id`. Each entry is the same object as the retrieve endpoint, `instruction` 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.
page query integer No Page number, 25 per page.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "order_id": "ORDER-1029",
      "amount": 150000,
      "currency": "IDR",
      "status": "success",
      "checkout_url": "https://fauxpay.test/checkout/J2ZlOvlEqDu…",
      "scenario": null,
      "payment_method": "bca_va",
      "payment_channel": "bca_va",
      "instruction": { "kind": "transfer", "account_number": "88080000003", "…": "…" },
      "expires_at": "2026-09-12T17:28:32.000000Z",
      "resolved_at": "2026-09-12T16:58:32.000000Z",
      "created_at": "2026-09-12T16:58:32.000000Z"
    }
  ],
  "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 }
}

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`, `supports_payout` and `limits`.

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": 2,
      "formatted": "Rp1,500.00"
    },
    "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", "…"],
        "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:32.000000Z",
    "is_expired": false
  }
}
POST /api/v1/checkout/{token}/channel 200 OK

Select a payment channel

Issues the destination for the chosen channel: a virtual account number, a QR payload, a wallet deep link, a counter code. Calling it again returns the same destination — the instruction is generated once and stored on the intent, so a reload 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": "88080000003",
      "account_holder": "BCA",
      "amount": 150000,
      "amount_formatted": "Rp1,500.00",
      "unique_code": null,
      "expires_at": "2026-09-12T17:28:32.000000Z"
    },
    "channels": ["… the same list as above …"],
    "expires_at": "2026-09-12T17:28:32.000000Z",
    "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 `409` 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": "88080000003", "…": "…" },
    "channels": ["… the same list as above …"],
    "expires_at": "2026-09-12T17:28:32.000000Z",
    "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.

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,
      "limits": { "min_amount": null, "max_amount": null }
    },
    {
      "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,
      "limits": { "min_amount": null, "max_amount": null }
    }
  ]
}

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": "Rp3,000.00",
      "pending_formatted": "Rp0.00",
      "total_formatted": "Rp3,000.00"
    }
  ]
}
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.

Field Type Required Notes
currency query string No Filter to one currency.
type query string No `deposit`, `payout`, `payout_reversal`, `refund`, `fee`, `adjustment`.
settlement query string No `pending` or `settled`.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "reference": "bt_01M2B8WW0J39D04E1N28GWWFD6",
      "amount": 150000,
      "amount_formatted": "Rp1,500.00",
      "currency": "IDR",
      "type": "deposit",
      "type_label": "Deposit",
      "description": "Deposit for order ORDER-1029",
      "is_settled": false,
      "available_at": "2026-09-12T16:59:32.000000Z",
      "settled_at": null,
      "created_at": "2026-09-12T16:58:32.000000Z"
    }
  ],
  "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 Filter to one currency.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "settlement_id": "stl_01M2B8ZE0GMW68WJKB3N6B4EPX",
      "amount": 300000,
      "amount_formatted": "Rp3,000.00",
      "currency": "IDR",
      "entry_count": 2,
      "settled_at": "2026-09-12T16:59:56.000000Z"
    }
  ],
  "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

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 a payout settles as `paid`; `always_fail` writes a reversing credit, `always_pending` leaves it `processing` so you can cancel it.

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.
destination.account_name string Yes Recipient name.
destination.account_number string Yes Account number, wallet phone number, or on-chain address.
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",
    "amount": 100000,
    "amount_formatted": "Rp1,000.00",
    "currency": "IDR",
    "channel": "bca_transfer",
    "channel_name": "BCA Bank Transfer",
    "destination": {
      "account_name": "Budi Santoso",
      "account_number": "1234567890"
    },
    "status": "paid",
    "failure_reason": null,
    "scenario": null,
    "created_at": "2026-09-12T16:59:56.000000Z",
    "resolved_at": "2026-09-12T16:59:56.000000Z"
  }
}
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",
    "amount": 100000,
    "amount_formatted": "Rp1,000.00",
    "currency": "IDR",
    "channel": "bca_transfer",
    "channel_name": "BCA Bank Transfer",
    "destination": { "account_name": "Budi Santoso", "account_number": "1234567890" },
    "status": "paid",
    "failure_reason": null,
    "scenario": null,
    "created_at": "2026-09-12T16:59:56.000000Z",
    "resolved_at": "2026-09-12T16:59:56.000000Z"
  }
}
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`.
currency query string No Filter to one currency.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "payout_id": "po_01M2B8ZEEM16505ERJGCQYWX79",
      "amount": 50000,
      "amount_formatted": "Rp500.00",
      "currency": "IDR",
      "channel": "gopay",
      "status": "cancelled",
      "created_at": "2026-09-12T16:59:56.000000Z"
    }
  ],
  "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 `409`. 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.

Example response

{
  "mode": "simulation",
  "data": {
    "payout_id": "po_01M2B8ZEEM16505ERJGCQYWX79",
    "reference": "py_01M2B8ZEEM16505ERJGCQYWX7A",
    "amount": 50000,
    "amount_formatted": "Rp500.00",
    "currency": "IDR",
    "channel": "gopay",
    "channel_name": "GoPay",
    "destination": { "account_name": "Siti Rahma", "account_number": "081234567890" },
    "status": "cancelled",
    "failure_reason": null,
    "scenario": "always_pending",
    "created_at": "2026-09-12T16:59:56.000000Z",
    "resolved_at": "2026-09-12T16:59:56.000000Z"
  }
}

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`.
currency query string No Filter to one currency.

Example response

{
  "mode": "simulation",
  "data": [
    {
      "reference": "txn_01M2B8WW0C4Y4WZY1NGYTHDAQJ",
      "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
      "order_id": "ORDER-1029",
      "amount": 150000,
      "currency": "IDR",
      "status": "success",
      "paid_at": "2026-09-12T16:58:32.000000Z",
      "created_at": "2026-09-12T16:58:32.000000Z"
    }
  ],
  "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.

Example response

{
  "mode": "simulation",
  "data": [
    { "code": "IDR", "name": "Indonesian Rupiah", "symbol": "Rp", "decimal_precision": 2, "is_active": true },
    { "code": "VND", "name": "Vietnamese Dong", "symbol": "₫", "decimal_precision": 0, "is_active": true },
    { "code": "USDT", "name": "Tether (simulated)", "symbol": "₮", "decimal_precision": 6, "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": "Rp1,500.00",
        "unique_code": null
      }
    }
  }

What instruction.kind tells you to render

One switcher over five values covers every market. Switch on the kind, never on the currency — the same component renders a Vietnamese wallet and an Indian one.

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 app_name, deep_link, phone, 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

An intent event is payment_intent. plus its status, with success renamed to succeeded. A payout event is simply payout. plus its status, so payout.processing can arrive too — treat any 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.
POST {callback_url}
X-FauxPay-Signature: <hmac-sha256 of the raw body, keyed with your secret>
X-FauxPay-Event: payment_intent.succeeded
X-FauxPay-Attempt: 1
User-Agent: FauxPay-Webhooks/1.0 (simulation)
Content-Type: application/json
{
  "event": "payment_intent.succeeded",
  "intent_id": "pi_01M2B8WVQ8KVKEVWM6YWG65FV2",
  "order_id": "ORDER-1029",
  "status": "success",
  "amount": 150000,
  "currency": "IDR",
  "paid_at": "2026-09-12T16:58:32+00:00",
  "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);

if ($event['status'] === 'success') {
    // Safe to fulfil, keyed by $event['order_id'].
}

Currencies & amounts

Amounts are always integers in the currency's smallest unit — never decimals, never floats. 150000 in IDR is Rp1,500.00. The precision to use comes from the currency itself, which is why JPY takes 4800 for ¥4,800 but USDT takes six decimals. FauxPay never converts between currencies.

Code Currency Symbol Decimals Example
IDR Indonesian Rupiah Rp 2 Rp1,500.00
INR Indian Rupee 2 ₹1,500.00
MYR Malaysian Ringgit RM 2 RM1,500.00
VND Vietnamese Dong 0 ₫1,500
MMK Myanmar Kyat K 0 K1,500
THB Thai Baht ฿ 2 ฿1,500.00
JPY Japanese Yen ¥ 0 ¥1,500
USDT Tether (simulated) 6 ₮1,500.000000
PHP Philippine Peso 2 ₱1,500.00
PKR Pakistani Rupee 2 ₨1,500.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

upi, promptpay, vietqr, duitnow_qr, qrph, qris

ewallet wallet panel

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

bank_transfer transfer panel

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

over_the_counter counter panel

konbini, seven_eleven_ph

crypto crypto panel

usdt_trc20, usdt_erc20

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.

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.

Ready to make a real request?

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