Payments
Merchant payments: links, invoices and hosted checkout
A merchant creates a payment link, an invoice or a terminal request for an exact USDC amount. The payer opens a hosted checkout, chooses a network and sends native USDC from their own wallet to a destination reserved for that one checkout. FurlPay marks the payment paid only after it has read the transfer back from the chain.
Status: implemented, not production verified
503 and no payment is requested from the payer.What a merchant can create
| Parameter | Type | Description |
|---|---|---|
| linkrequired | kind | A payment link for a fixed amount. May be reusable (many payers) or single-use. |
| invoicerequired | kind | Single-use, with 1 to 50 line items. The total must equal the sum of quantity × unit price. |
| terminalrequired | kind | Single-use request shown as a QR code at a point of sale. |
Amounts are decimal strings with up to six decimals, above zero and at most 1,000,000 USDC. They are stored as integer micro-units; no floating-point arithmetic touches a payment amount.
Networks and confirmation depth
Only Circle-issued native USDC is accepted. A network is collectable only when it has a reviewed deposit policy; the required depth is the stricter of that policy and the payment registry. The checkout offers the networks that currently have an unassigned destination for the merchant.
| Parameter | Type | Description |
|---|---|---|
| Baserequired | 1 confirmation | Mainnet, chain ID 8453. |
| Arbitrumrequired | 1 confirmation | Mainnet, chain ID 42161. |
| Ethereumrequired | 12 confirmations | Mainnet, chain ID 1. |
| Polygonrequired | 128 confirmations | Mainnet, chain ID 137. |
| Solanarequired | 31 confirmations | Mainnet. Matched by a one-time reference key included in the transfer. |
| Arcrequired | 1 confirmation | Mainnet, chain ID 5042. Receipts are keyed on the network's system-emitter log. |
| Suirequired | 1 checkpoint | Mainnet. Read over GraphQL; a transaction in a certified checkpoint is final. |
| Arc Testnetoptional | testnet only | Chain ID 5042002. Collectable only with FURLPAY_TESTNET_COLLECTION=1 and never in a production environment. Testnet payments book to separate ledger accounts. |
| OP Mainnet, Avalancheoptional | not collectable | Listed in the registry, but they have no deposit policy, so a checkout cannot be reserved on them. |
Fees
Each link records the merchant's processing rate when it is created. Rates are tiers assigned by operations: starter 95, growth 75, scale 50 and enterprise 35 basis points. A merchant with no assigned tier pays the growth rate. The fee is rounded down to the smallest USDC unit. The merchant chooses who pays it:
- Merchant pays — the payer is charged the gross amount; the merchant nets gross minus fee.
- Customer pays — the payer is charged gross plus fee; the merchant nets the gross amount.
Network gas is separate and is always paid by the payer's wallet.
Checkout flow
- The merchant creates a link. The response contains a 24-character opaque slug; the hosted page is
/p/<slug>. Payers are anonymous and need no account. - The payer picks a network. FurlPay reserves one never-reused destination address for this checkout and records the chain height at that moment.
- The checkout shows a payment request and QR code for the payer's wallet.
- While the page is open it polls the checkout endpoint, which runs the verifier. A scheduled job runs the same verifier every two minutes, so confirmation does not depend on the page staying open.
- When the required depth is reached and screening passes, the receipt, ledger posting, status change and webhook event are committed together.
Payment statuses
| Parameter | Type | Description |
|---|---|---|
| awaiting_paymentrequired | status | Destination reserved; no qualifying transfer seen. |
| confirmingrequired | status | A transfer was seen but has not reached the required depth. |
| underpaidrequired | status | Less than the charge has been received; the checkout shows the remaining amount. |
| paidrequired | status | The exact charge was received and verified. |
| overpaidrequired | status | More than the charge was received. Treated as paid; the excess is recorded. |
| expiredrequired | status | The checkout window closed without full payment. |
| reviewrequired | status | Held for manual review, for example a screening result that is not an allow. |
How a payment is verified
The client never reports a payment. The verifier reads the transfer to the reserved address from the network, checks it independently against the transaction receipt, confirms the network identity and that the node head is fresh, and re-checks that the block is still canonical. Before crediting, the payer and the destination are screened (see Screening and cases).
If a node is unreachable the checkout keeps its last proven state and reports that verification is delayed. An outage is never turned into a success or a failure. A transfer is recorded once per network, transaction and log position, so a duplicate scan cannot credit twice.
Receipts
A one-page PDF receipt is issued only for a payment the verifier credited (paid or overpaid). An underpaid, confirming or held payment has no receipt.
Refunds
On-chain payments cannot be reversed, so a refund is a new transfer back to the payer. Requesting one reserves the amount in the ledger immediately. Operations approve it, and the custody provider that controls the collection addresses sends it. The refund is marked complete only when that transfer is read on-chain.
| Parameter | Type | Description |
|---|---|---|
| requestedrequired | status | Amount reserved; awaiting operations. |
| pending_external_executionrequired | status | Approved; waiting for the custody provider's transfer. |
| completedrequired | status | The refund transfer was verified on-chain. |
| cancelledrequired | status | Cancelled; the reservation is released. |
Refund execution is blocked by an external dependency
Reconciliation and alerts
Every ten minutes a scheduled job compares three records: the intent, the ledger and the chain. Recently recorded receipts are re-read from the network and must still be a successful, canonical USDC transfer of the recorded amount to the intent's address. It also raises liveness alerts for payments stuck confirming, a scanner that has stopped, held payments nobody has reviewed, and a network whose head is stale. Findings become alerts for operations. The job never edits an intent, reverses a journal or changes a merchant balance.
API
| Method | Path | Description |
|---|---|---|
| GET | /api/merchant/payments | Signed-in merchant: links, payment intents and ready networks; ?id=<uuid> returns one payment with events and receipts |
| POST | /api/merchant/payments | Create a link, invoice or terminal request. Idempotent on requestId |
| PATCH | /api/merchant/payments | Activate or deactivate a link |
| GET | /api/merchant/payments/receipt | PDF receipt for a credited payment |
| GET / POST / DELETE | /api/merchant/refunds | List, request or cancel refunds |
| GET / PUT / POST / DELETE | /api/merchant/webhook | Webhook endpoint; POST sends a test event or re-queues one failed event |
| GET | /api/checkout/merchant/{slug} | Public: link details; ?intent=<uuid> returns checkout status and runs verification |
| POST | /api/checkout/merchant/{slug} | Public: reserve a checkout on one network: { requestId, chain } |
| GET | /api/checkout/merchant/{slug}/receipt | Public: receipt for a credited checkout |
| GET / POST | /api/merchant/operations | Operators only: held payments, alerts, pricing tiers, address registration, refund approval |
curl -X POST https://furlpay.com/api/merchant/payments \
-H "content-type: application/json" \
-b "session" \
-d '{
"requestId": "7d0a5c4e-2c1b-4b7e-9a51-3f2d6c8e1a90",
"kind": "link",
"title": "Annual plan",
"description": "",
"amount": "250.00",
"chains": ["base", "arbitrum"],
"reusable": false,
"feePayer": "merchant",
"expiresAt": null,
"reference": "ORDER-1042",
"lines": []
}'Merchant routes use the signed-in session; the merchant is always the authenticated account and no merchant ID is accepted from the client. The public checkout routes are rate limited per IP.
Responses to handle
409on reserve — the link is closed, or a single-use checkout is already in progress.503on reserve — the network is not ready (no destination, or the node could not be verified). No payment was requested.503on merchant routes — payment storage is not configured or a migration is not applied.
Webhook events
A verified payment writes payment.succeeded to the merchant's outbox, and invoice.paid when the link is an invoice. Delivery, signature format and retry schedule are described in Webhook Events. The webhook is a notification; the payment record returned by the API is the source of truth.
Where merchants manage this
/dashboard/payments,/dashboard/payment-links,/dashboard/invoicesand/dashboard/qr— the merchant workspace (sign-in required)./dashboard/operations— the operator console./p/<slug>— the public hosted checkout for one link.
Setup
- Database — Supabase configured, with migrations
0040(payments),0041(webhooks),0042(pricing tiers, reconciliation),0043(Sui),0045(refunds),0046(liveness alerts) and0047(Arc testnet) applied in order. - Collection addresses — registered through the operator console. Registration requires proof of control: the address itself signs a message naming the merchant, network, address and custody reference. FurlPay never generates, derives or stores keys for collection addresses. Each address is used for exactly one checkout, and there is no fallback to a shared address.
- Node endpoints —
BASE_RPC_URL,ARBITRUM_RPC_URL,ETHEREUM_RPC_URL,POLYGON_RPC_URL,SOLANA_RPC_URL,ARC_RPC_URLandSUI_GRAPHQL_URL, with optional_SECONDARYendpoints for failover. Unset values fall back to public endpoints, which are rate limited. - Scheduled jobs —
/api/cron/merchant-paymentsevery two minutes and/api/cron/merchant-reconcileevery ten, both listed invercel.json. Sub-daily schedules depend on the hosting plan. - Webhook secrets —
FURLPAY_PARTNER_WEBHOOK_KEYseals merchant signing secrets at rest. - Check —
npm run merchant:check-envreports what is missing.npm run merchant:seed-destinationsregisters pilot addresses on a local or staging database only; it refuses the production project.
What this is not
