Payments
Payment intents
A payment intent is your request for a specific amount, in a specific token, to your wallet. RSends turns it into on-chain pay instructions and watches the chain for a matching settlement.
Endpoints
/api/v1/merchant/payment-intent/api/v1/merchant/payment-intent/{intent_id}/api/v1/merchant/payment-intent/{intent_id}/cancel/api/v1/merchant/payment-intent/{intent_id}/resolvePaths are relative to the environment base URL — on the sandbox, https://pay.rsends.io/api/backend. See Environments.
Create an intent
The amount is denominated in the settlement token (e.g. 100 means 100 USDC), not in fiat. Send the token you want to be paid in and, optionally, the destination wallet.
curl -X POST https://pay.rsends.io/api/backend/api/v1/merchant/payment-intent \
-H "Authorization: Bearer rsend_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 100,
"currency": "USDC",
"chain": "base_sepolia",
"recipient": "0xYourDestinationWallet00000000000000000000",
"expires_in_minutes": 30,
"metadata": { "order_id": "ORD-1024" }
}'| Field | Type | Notes |
|---|---|---|
amount | number · required | In the settlement token, > 0 |
currency | string · required | Settlement token, and it is per-chain: USDC/ETH on Base Sepolia, EURC on Avalanche, USDT on TRON. Anything not enabled on the requested chain → 400 UNSUPPORTED_TOKEN |
chain | string · default "BASE" | Test keys: "base_sepolia", "avalanche_fuji", "tron_nile". Live keys: "base", "avalanche", "ethereum", "tron". A test key on a mainnet chain → 400 TESTNET_ONLY, and the reverse → 400 MAINNET_ONLY |
recipient | string? | Destination wallet (0x…, 40 hex). Omit it and your organisation's settlement wallet is used — see below |
expected_sender | string? | Restrict the payment to a known payer address |
expires_in_minutes | int · default 30 | 5–1440 (max 24h) |
metadata | object? | Arbitrary merchant data. merchant_name (or store_name) is the one key the payer sees on the checkout — everything else stays private |
allow_overpayment | bool · default true | Accept a payment larger than the amount |
allow_partial | bool · default false | Accept a payment of ≥ 50% of the amount |
amount_tolerance_percent | number · default 1.0 | Matching tolerance on the amount, 0–10 |
late_payment_policy | string · default "auto" | auto (complete with a late flag) · review (hold for manual resolution) · reject (don't match) |
split | array? | 2–20 recipients with share_bps summing to exactly 10000. Mutually exclusive with recipient. Not enabled on every chain → 422 SPLIT_UNAVAILABLE |
network | string? | Accepted but ignored — the network is derived from chain so it can never contradict it |
Where the money goes
An intent cannot exist without a resolvable on-chain recipient — that is the non-custodial invariant, enforced at creation. The recipient you pass wins; if you omit it, RSends falls back to your organisation's settlement wallet (set in Settings). If neither resolves, creation fails with 422 SETTLEMENT_WALLET_MISSING rather than defaulting to some address. A wallet claimed by more than one organisation fails with 422 SETTLEMENT_WALLET_AMBIGUOUS.
The response
The response echoes the intent plus an onchain object — the exact, non-custodial instructions the payer's wallet executes. There is no deposit address and no custodial key anywhere in it.
{
"intent_id": "pi_9f1c4a7e2b5d8036c1e94ab7d2508f63",
"reference_id": "a1b2c3d4e5f6a7b8",
"onchain_invoice_id": "0x9f…", // bytes32, derived from reference_id + chain id
"amount": 100.0,
"currency": "USDC",
"chain": "base_sepolia",
"recipient": "0xyourdestinationwallet00000000000000000000",
"status": "pending",
"expires_at": "2026-08-06T12:30:00+00:00",
"created_at": "2026-08-06T12:00:00+00:00",
"completed_at": null,
"tx_hash": null,
"metadata": { "order_id": "ORD-1024" },
"onchain": {
"invoiceId": "0x9f…",
"merchant": "0xyourdestinationwallet00000000000000000000",
"token": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"amount": "100000000", // base units (USDC has 6 decimals) — what YOU receive
"fee": "600000", // 0.60 USDC — read live from the router's quoteFee
"total": "100600000", // amount + fee — what the PAYER parts with
"maxFee": "600000", // payer's ceiling; pay() reverts above it
"chainId": 84532,
"router": "0x2Ec353815F2Cd382628d0D399F8d80959C1758CA",
"routerVersion": 1, // 1 = deployed sandbox router (fee); 2 = fee-less
"function": "pay", // "pay" (ERC-20) | "payNative" (ETH)
"decimals": 6,
"isNative": false,
"permitType": "eip2612", // "eip2612" → permit flow; "none" → approve + pay
"feeUnavailable": false
}
}| Field | Meaning |
|---|---|
intent_id | pi_ + 32 hex. Unguessable by design — it is also the checkout link's only credential. |
reference_id | Your on-chain matching key. RSends derives the bytes32 invoiceId from it and matches the emitted PaymentMade event back to this intent. |
onchain.invoiceId | The invoiceId argument to pay(...). |
onchain.router | The immutable RSends router the payment goes through. |
onchain.routerVersion | 1 — the router deployed today: quotes an on-chain flat fee and takes a maxFee argument. 2 — the fee-less, ownerless router arriving at the mainnet cutover: no fee, no fee arguments. Branch on this field, never on a hardcoded assumption. |
onchain.fee | On routerVersion 1, the flat fee read live from the router's quoteFee. On version 2 it is always "0". |
onchain.total | amount + fee — the number the payer's wallet actually authorises. Show this in your UI, not amount. |
onchain.feeUnavailable | true when the live fee quote failed (RPC down): fee/total/calldata come back null and the client is expected to read quoteFee on-chain itself. |
status | Lifecycle state — see below. |
Hosting the checkout
There is no checkout_url field in the response — you compose the URL from the intent_id:
https://demo.rsends.io/pay/pi_9f1c4a7e2b5d8036c1e94ab7d2508f63That page re-fetches the intent and its onchain instructions, then drives the payer's wallet — see Hosted checkout.
Fees: what the sandbox does today
Your principal always arrives whole. Nothing is ever deducted from what settles into your wallet — RSends never touches the transfer. What differs between the two routers is whether the payer is charged something on top.
| routerVersion 1 — deployed today | routerVersion 2 — at mainnet cutover | |
|---|---|---|
| Payer parts with | total | amount |
| You receive | amount | amount |
| Fee | A flat fee (never a percentage), read live from the contract. For USDC: 0.60 under 1000 USDC, 3.00 at or above. Native ETH is configured at zero | None — the contract has no fee configuration at all |
| Call shape | Carries a maxFee argument; the ERC-20 approval must cover amount + fee | No fee argument; the approval covers amount |
What the payer signs for, today
total — amount + fee — not amount. If you render the amount yourself, render onchain.total, or your figure won't match what the wallet asks for. Every value comes back in the response: you never compute a fee client-side.Where RSends makes its money
Intent lifecycle
| status | Meaning |
|---|---|
pending | Created, awaiting an on-chain payment |
completed / paid | PaymentMade event matched — funds settled to your wallet. paid and completed are the same terminal success state (completed is the legacy spelling); accept both. |
overpaid / partial | Matched outside exact amount, accepted per your tolerance policy |
expired | expires_at passed with no payment |
cancelled | You cancelled it via the cancel endpoint |
review | Ambiguous or late payment held for manual review — resolve it below |
refunded | A merchant-sent refund was recorded against it — see Refunds |
Cancel an intent
Cancel a still-pending intent to stop accepting payment for it. Cancellation is an off-chain state change — there are no funds to return, because none were ever held. Any other state returns 400 INVALID_STATE.
/api/v1/merchant/payment-intent/{intent_id}/cancelMoney on-chain beats the cancel button
409 SETTLEMENT_IN_FLIGHT — the payer's funds have moved and the intent will settle. The same rule beats the expiry timer: an intent with an observed payment is never flipped to expired.Resolve a held payment
An intent in review (a late payment under late_payment_policy: "review") waits for your decision. Post {"action": "complete"} to accept it — which fires a payment.completed_late webhook — or {"action": "refund"} to mark it refunded for your books. Any other state returns 400 INVALID_STATE. See Refunds.
/api/v1/merchant/payment-intent/{intent_id}/resolveErrors you should handle
| Code | HTTP | When |
|---|---|---|
UNSUPPORTED_TOKEN | 400 | Token not enabled on that chain |
UNSUPPORTED_CHAIN | 400 | Chain has no settlement path at all |
TESTNET_ONLY / MAINNET_ONLY | 400 | Key environment vs requested chain |
SETTLEMENT_WALLET_MISSING | 422 | No recipient passed and no settlement wallet set |
INTENT_NOT_FOUND | 404 | Unknown id — or it belongs to someone else, or to the other environment |
INVALID_STATE | 400 | Cancel/resolve from a state that doesn’t allow it |
SETTLEMENT_IN_FLIGHT | 409 | Cancel after an on-chain payment was observed |
MONTHLY_LIMIT_EXCEEDED | 429 | Key’s monthly intent quota reached (unlimited by default) |
Full table, envelope shapes and rate limits: Errors.