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

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

POST/api/v1/merchant/webhook/register

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

request
{
  "url": "https://your-app.example.com/rsends/webhook",
  "events": ["payment.completed", "payment.expired"]
}
response
{
  "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

The 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

POST/api/v1/merchant/webhook/test

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

EventMeaning
payment.completedPayment settled on-chain and matched — fulfil the order
payment.expiredIntent reached expires_at with no payment
payment.completed_lateSettled after expiry, accepted per your policy
payment.overpaid / payment.partialMatched outside the exact amount, within tolerance
payment.needs_reviewHeld — could not be matched cleanly, awaiting manual resolution
payment.expired_rejectedLate payment rejected per your policy — no settlement will occur
payment.reversedA delivered payment.completed was rolled back by a chain reorg — un-fulfil
payment.cancelled / payment.ambiguousReserved — 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.

payment.completed — body
{
  "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

From 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

HeaderValue
X-RSend-SignatureHMAC-SHA256 of "{timestamp}.{body}", hex
X-RSend-TimestampUnix seconds for this attempt — part of the signed string
X-RSend-EventThe event type
X-RSend-DeliveryPer-delivery UUID
X-RSend-Delivery-IdIdempotency 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.

verify a delivery — Node.js
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

Treat an unverified body as untrusted input. Recompute the HMAC over "{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:

AttemptSent after the previous failure
1st retry30 seconds
2nd retry2 minutes
3rd retry8 minutes
4th retry32 minutes
5th retry2 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

Every retry of the same event carries the same 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

You never have to trust the webhook alone. The 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.