StatusPrivate betaPublic testnets — test tokens, no real valueMainnet rolls out per jurisdiction

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

POST/api/v1/merchant/payment-intent
GET/api/v1/merchant/payment-intent/{intent_id}
POST/api/v1/merchant/payment-intent/{intent_id}/cancel
POST/api/v1/merchant/payment-intent/{intent_id}/resolve

Paths 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.

POST /api/v1/merchant/payment-intent
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" }
  }'
FieldTypeNotes
amountnumber · requiredIn the settlement token, > 0
currencystring · requiredSettlement 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
chainstring · 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
recipientstring?Destination wallet (0x…, 40 hex). Omit it and your organisation's settlement wallet is used — see below
expected_senderstring?Restrict the payment to a known payer address
expires_in_minutesint · default 305–1440 (max 24h)
metadataobject?Arbitrary merchant data. merchant_name (or store_name) is the one key the payer sees on the checkout — everything else stays private
allow_overpaymentbool · default trueAccept a payment larger than the amount
allow_partialbool · default falseAccept a payment of ≥ 50% of the amount
amount_tolerance_percentnumber · default 1.0Matching tolerance on the amount, 0–10
late_payment_policystring · default "auto"auto (complete with a late flag) · review (hold for manual resolution) · reject (don't match)
splitarray?2–20 recipients with share_bps summing to exactly 10000. Mutually exclusive with recipient. Not enabled on every chain → 422 SPLIT_UNAVAILABLE
networkstring?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.

200 application/json — sandbox (Base Sepolia)
{
  "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
  }
}
FieldMeaning
intent_idpi_ + 32 hex. Unguessable by design — it is also the checkout link's only credential.
reference_idYour on-chain matching key. RSends derives the bytes32 invoiceId from it and matches the emitted PaymentMade event back to this intent.
onchain.invoiceIdThe invoiceId argument to pay(...).
onchain.routerThe immutable RSends router the payment goes through.
onchain.routerVersion1 — 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.feeOn routerVersion 1, the flat fee read live from the router's quoteFee. On version 2 it is always "0".
onchain.totalamount + fee — the number the payer's wallet actually authorises. Show this in your UI, not amount.
onchain.feeUnavailabletrue 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.
statusLifecycle state — see below.

Hosting the checkout

There is no checkout_url field in the response — you compose the URL from the intent_id:

checkout URL
https://demo.rsends.io/pay/pi_9f1c4a7e2b5d8036c1e94ab7d2508f63

That 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 todayrouterVersion 2 — at mainnet cutover
Payer parts withtotalamount
You receiveamountamount
FeeA 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 zeroNone — the contract has no fee configuration at all
Call shapeCarries a maxFee argument; the ERC-20 approval must cover amount + feeNo fee argument; the approval covers amount

What the payer signs for, today

On the sandbox the payer's wallet authorises 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

Not from the payment. RSends pricing is a flat off-chain subscription; the fee above is a property of the currently-deployed router contract, and it disappears with the fee-less router at the mainnet cutover. Either way it is never netted out of your settlement.

Intent lifecycle

statusMeaning
pendingCreated, awaiting an on-chain payment
completed / paidPaymentMade event matched — funds settled to your wallet. paid and completed are the same terminal success state (completed is the legacy spelling); accept both.
overpaid / partialMatched outside exact amount, accepted per your tolerance policy
expiredexpires_at passed with no payment
cancelledYou cancelled it via the cancel endpoint
reviewAmbiguous or late payment held for manual review — resolve it below
refundedA 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.

POST/api/v1/merchant/payment-intent/{intent_id}/cancel

Money on-chain beats the cancel button

If an on-chain payment for the intent has already been observed, cancelling returns 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.

POST/api/v1/merchant/payment-intent/{intent_id}/resolve

Errors you should handle

CodeHTTPWhen
UNSUPPORTED_TOKEN400Token not enabled on that chain
UNSUPPORTED_CHAIN400Chain has no settlement path at all
TESTNET_ONLY / MAINNET_ONLY400Key environment vs requested chain
SETTLEMENT_WALLET_MISSING422No recipient passed and no settlement wallet set
INTENT_NOT_FOUND404Unknown id — or it belongs to someone else, or to the other environment
INVALID_STATE400Cancel/resolve from a state that doesn’t allow it
SETTLEMENT_IN_FLIGHT409Cancel after an on-chain payment was observed
MONTHLY_LIMIT_EXCEEDED429Key’s monthly intent quota reached (unlimited by default)

Full table, envelope shapes and rate limits: Errors.