Payments
Webhooks
Register an HTTPS endpoint to be told when a payment settles on-chain. Every delivery is signed, so you can prove it came from RSends before you act on it.
Register a webhook
/api/v1/merchant/webhook/registerThe URL must be HTTPS and must resolve to a publicly routable address. Loopback, private, link-local and otherwise reserved addresses are rejected at registration with 422 WEBHOOK_URL_FORBIDDEN, and re-checked immediately before every delivery — so a hostname that later starts resolving to an internal address stops being delivered to. Webhooks are environment-scoped: one registered with a test key never receives live events, and vice versa. Registration is limited to 5 per hour.
{
"url": "https://your-app.example.com/rsends/webhook",
"events": ["payment.completed", "payment.expired"]
}{
"webhook_id": 42,
"url": "https://your-app.example.com/rsends/webhook",
"secret": "a1b2c3… (64 hex chars)",
"events": ["payment.completed", "payment.expired"],
"is_active": true
}Secret shown once
secret is returned only in this registration response. Store it — it is the key used to verify the signature on every delivery and cannot be retrieved again. When you go live, you swap this secret along with your API key.Send a test delivery
/api/v1/merchant/webhook/testPosts a sample event to your URL so you can confirm your endpoint and signature check work before a real payment.
The settlement event
When a payment settles on-chain and RSends matches it to your intent, you receive payment.completed. This is the one event you should treat as “money received.” payment.expired fires when an intent lapses unpaid. In the rare case of a deep chain reorganization shortly after completion, payment.reversed can follow a payment.completed within roughly the chain’s finality window (~15 minutes on Base) — treat it as “stop fulfilment.” After a payment.reversed, poll the intent before writing the order off: if the network re-includes the transaction, the intent returns to paid without a second payment.completed.
| Event | Meaning |
|---|---|
payment.completed | Payment settled on-chain and matched — fulfil the order |
payment.expired | Intent reached expires_at with no payment |
payment.completed_late | Settled after expiry, accepted per your policy |
payment.overpaid / payment.partial | Matched outside the exact amount, within tolerance |
payment.needs_review | Held — could not be matched cleanly, awaiting manual resolution |
payment.expired_rejected | Late payment rejected per your policy — no settlement will occur |
payment.reversed | A delivered payment.completed was rolled back by a chain reorg — un-fulfil |
payment.cancelled / payment.ambiguous | Reserved — subscribable today, not yet emitted |
Event payload
Deliveries POST a JSON body. Every event carries the same field set — the example below is the full shape, and the test delivery posts exactly it. Alongside the intent fields, it carries the on-chain proof of settlement — the transaction hash, the chain (canonical lowercase name plus numeric chain_id), and the onchain_invoice_id you can look up directly. Amount fields are strings, and amount is what settled to you — whole, never net of anything. There is deliberately no fee field: RSends is paid by off-chain subscription, and where the deployed router charges the payer an on-chain fee it is readable from the transaction itself via tx_hash.
{
"event_id": "evt_3f9a…",
"event": "payment.completed",
"intent_id": "pi_abc123…",
"reference_id": "a1b2c3d4e5f6a7b8",
"onchain_invoice_id": "0x9f…", // on-chain key for the PaymentMade event
"amount": "100",
"amount_received": "100",
"overpaid_amount": null,
"underpaid_amount": null,
"currency": "USDC",
"chain": "base_sepolia",
"chain_id": 84532,
"recipient": "0x…", // where the funds settled
"tx_hash": "0x…", // the settlement transaction
"status": "completed",
"completed_late": false,
"late_minutes": null,
"metadata": { "order_id": "ORD-1024" },
"timestamp": "2026-06-30T12:00:05+00:00",
"created_at": "2026-06-30T12:00:00+00:00",
"completed_at": "2026-06-30T12:00:05+00:00",
"settlement": "onchain" // extra on settlement events only
}Deriving full on-chain proof
tx_hash on sepolia.basescan.org you can read the rest of the proof — the block number, the payer address and the exact amount — straight from the PaymentMade event. Two event shapes exist, keyed by topics[0]: the router deployed today (routerVersion 1) emits the 7-argument shape with a trailing fee word (topic 0xab7ccb7f…deb04947), while the fee-less router arriving at the mainnet cutover emits PaymentMade(bytes32,address,address,address,uint256,uint256) — token, amount, blockTimestamp (topic 0xc3e210e1…cc843241). Richer proof fields embedded directly in the payload are on the roadmap; today the load-bearing handle is tx_hash + onchain_invoice_id.Delivery headers & signature
| Header | Value |
|---|---|
X-RSend-Signature | HMAC-SHA256 of "{timestamp}.{body}", hex |
X-RSend-Timestamp | Unix seconds for this attempt — part of the signed string |
X-RSend-Event | The event type |
X-RSend-Delivery | Per-delivery UUID |
X-RSend-Delivery-Id | Idempotency key — dedupe retries on this |
The signature covers both the timestamp and the exact raw body, so tampering with either invalidates it. This is the only signing scheme RSends uses — there is no other variant to support.
import crypto from 'node:crypto'
function verifyRSendsWebhook(secret, headers, rawBody) {
const ts = headers['x-rsend-timestamp']
const sig = headers['x-rsend-signature']
// 1) Freshness: reject anything outside a 5-minute window (anti-replay).
const now = Math.floor(Date.now() / 1000)
if (!ts || Math.abs(now - Number(ts)) > 300) return false
// 2) Recompute HMAC-SHA256 over "{timestamp}.{rawBody}" and compare in
// constant time. rawBody MUST be the exact bytes received (do not re-serialize).
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex')
return sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
}Always verify the signature
"{timestamp}.{raw_body}", confirm the timestamp is within 5 minutes, and compare in constant time before reading any field. This is the load-bearing rule of the integration. Use X-RSend-Delivery-Id to dedupe retries.Retries
A delivery counts as accepted when your endpoint answers 2xx. Anything else — a non-2xx status, a timeout, a connection error — is retried. Each attempt has a 10-second timeout, and there are up to 5 retries with exponential backoff:
| Attempt | Sent after the previous failure |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 8 minutes |
| 4th retry | 32 minutes |
| 5th retry | 2 hours |
After the 5th retry the delivery is marked failed and is not attempted again — reconcile from Reporting or the chain. Answer quickly and do the work asynchronously: a slow handler that exceeds 10 seconds is treated as a failure even if it eventually succeeds, which turns into duplicate deliveries.
Dedupe on the delivery id, not the event
X-RSend-Delivery-Id. Key your handler on it and make it idempotent — that is the whole retry contract.Refund events are merchant-recorded
RSends never moves money, so there is no processor-driven refund event. A refund is a transfer you send from your own wallet and then record against the original payment for reporting — see Refunds.
Verify on-chain yourself
The webhook is a convenience, the chain is the record
PaymentMade event for your reference is public on BaseScan; anyone can confirm the amount, the recipient, and the payer independently of RSends. If the webhook and the chain ever disagree, the chain wins.