Your back office pays suppliers, partners and group entities in USDC or USDT, and hears about every payment it receives. Only the beneficiaries you list, within the limits you set, with an approver above an amount, and a reason whenever something is refused.
Recorded from the real console on a sandbox, a private copy of Ethereum mainnet where nothing has value: a payout confirmed live, one refused by the policy, a large one approved, and an incoming payment verified.
The payment engine or ERP that decides a payout sends it to CryptoRail. The payout policy reads the payment itself: who is paid and how much. A payout outside the rules is refused before anything is signed, a large one waits for an approver, and the rest goes out signed with the treasury's key. Payments arriving at the treasury reach the same systems as signed notifications.
flowchart TD
ERP[Your ERP decides a payout] --> P{Payout policy}
P -->|beneficiary not listed or daily limit reached| X[Refused with the reason, nothing sent]
P -->|at or above the threshold| A[An approver signs what they read]
P -->|within the rules| S[Signed with the treasury's key]
A --> S
S --> N[Stablecoin transfer on chain]
N --> C[Confirmed: your ERP is told]
W[Someone pays your treasury] --> T[Transfer seen on chain]
T --> H[Signed webhook to your ERP]The pack connects the stablecoins that already exist; there is no contract of ours to trust.
USDC and USDT at the addresses their issuers publish, checked against the chain before use. See it in code ↓
A list of beneficiaries, a daily limit in the token's own units, and an approval threshold. See it in code ↓
The approver reads the decoded payment, beneficiary and amount, and signs exactly that. See it in code ↓
Every transfer to the treasury reaches your back office as a signed webhook, withdrawn if the chain reorganises. See it in code ↓
A retry never pays twice, a refusal always says why, and every approval is recorded with who signed what and when.
On every change, an end-to-end scenario runs the pack against the real USDC and USDT on a fork of Ethereum mainnet. On 2026-10-10 the same pack ran on Base Sepolia with Circle's official test USDC; anyone can check its transactions on the block explorer.
| On Base Sepolia | Outcome | Transaction |
|---|---|---|
| Payout of 2 USDC to a listed beneficiary | Confirmed; the beneficiary holds 2 USDC | 0x0a5081ff…536d96 |
| Payout of 1 USDC to an address not on the list | Refused by the policy; nothing sent | none |
| Payout of 6 USDC, above the approval threshold | Held until the approver signed; then confirmed | 0x57d475e5…844b9e |
| Payout of 4.5 USDC after 8 USDC paid that day | Refused: over the daily limit; nothing sent | none |
| Incoming payment of 1 USDC from an outside wallet | Delivered to the back office as a signed webhook, signature verified | 0xffb3bbac…627ea3 |
The rules of that run were sized to the test USDC a faucet gives: an approver from 5 USDC, at most 12 USDC a day. The treasury's address is 0xd81a680b…37422b. The scenario and the requirements behind every line above are listed on the evidence page.
The pack is a small library over CryptoRail's API: a manifest of the tokens verified against the chain, the payout policy for the service's rules engine, and a setup that registers the tokens, the webhook and the subscriptions. Your systems then use the typed SDK, or plain HTTP. The calls below are the ones the journey makes on every change.
CryptoRail does not need to know a contract to reach it. Register its ABI, even only the functions and events you use, then the contract's address under a name of yours: its selectors are checked against the deployed code, and from then on every function is a call by name and every event arrives decoded. The pack's setup does exactly this for each token.
flowchart TD
A[ABI: the functions and events you use] -->|POST /v1/abis| R[(Registry)]
C[Address on a chain] -->|POST /v1/contracts, under a name| R
R --> W[Writes: POST /v1/transactions, function by name]
R --> Q[Reads: POST /v1/contracts/id/call]
R --> E[Events: subscriptions and webhooks, decoded]// 1. The ABI: any subset of the contract, readable or JSON const abi = await cryptorail.POST('/v1/abis', { body: { fragments: [ 'function transfer(address to, uint256 amount) returns (bool)', 'function balanceOf(address owner) view returns (uint256)', 'event Transfer(address indexed from, address indexed to, uint256 value)', ], }, }) // 2. The contract: an address on a chain, under a name of yours const usdc = await cryptorail.POST('/v1/contracts', { body: { chainId: 84532, address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', label: 'pack-usdc', abiId: abi.data!.id, checkSelectors: true, // every function must be in the deployed code }, }) // 3. Every function is now a call by name, arguments checked // against the ABI before anything is signed await cryptorail.POST('/v1/contracts/{id}/call', { // a read params: { path: { id: usdc.data!.id } }, body: { function: 'balanceOf', args: [treasury] }, }) await cryptorail.POST('/v1/transactions', { // a write body: { chainId: 84532, actor: { kind: 'identity', identityId: treasuryId }, target: { kind: 'contract', contractId: usdc.data!.id, function: 'transfer', args: [supplierA, '2000000'], }, }, }) // The same events, decoded by name, feed subscriptions and webhooks
Three commands: verify that the addresses still hold the tokens the pack knows, print the policy, and register everything on the service. Run again, the setup registers nothing twice.
# Check every fact of the pack against the chain, at its pinned block node packages/pack-stablecoins/dist/bin.js verify --network base-sepolia # Print the payout policy for the service's configuration node packages/pack-stablecoins/dist/bin.js policy --network base-sepolia \ --beneficiaries 0xa1…,0xb0… --threshold 5 --cap USDC=12 \ --approvers <approver id> # Register the tokens, the ERP's webhook and the Transfer subscriptions CRYPTORAIL_API_KEY=<admin key> \ node packages/pack-stablecoins/dist/bin.js setup --network base-sepolia \ --url <service URL> --receiving <treasury> --webhook <ERP URL> # It prints what it registered, and the webhook's secret once
The rules read the payment itself, the beneficiary and the amount of the token transfer, in USDC, not in raw units. A payment the rules cannot read is refused, never let through.
import { BASE_SEPOLIA, payoutPolicy } from '@cryptorail/pack-stablecoins'
// The same document the command line prints, built in code
const policy = payoutPolicy({
version: 'treasury-2026-10', // recorded with every decision
network: BASE_SEPOLIA,
beneficiaries: [supplierA, supplierB],
dailyCap: { USDC: '12' }, // in USDC, all payouts of the day
approvalThreshold: '5', // from 5 USDC an approver signs
approverIds: [treasurerId],
})
// Service configuration:
// adapters: { policy: { kind: 'rules', options: policy } }One call from the ERP, keyed by the ERP’s own payment id. The policy decides before anything is signed; a refusal comes back as a failure report with a stable cause your systems can act on.
import { createClient } from '@cryptorail/sdk'
const cryptorail = createClient({ baseUrl, apiKey })
// The ERP's payment id as idempotency key: a retry never pays twice
const { data } = await cryptorail.POST('/v1/transactions', {
params: { header: { 'idempotency-key': `payout-${erpPaymentId}` } },
body: {
chainId: 84532, // Base Sepolia
actor: { kind: 'identity', identityId: treasuryId },
target: {
kind: 'contract',
contractId: usdcId, // registered by the pack
function: 'transfer',
args: [supplierA, '2000000'], // 2 USDC, six decimals
},
},
})
const tx = await cryptorail.GET('/v1/transactions/{id}', {
params: { path: { id: data!.transactionId } },
})
// tx.data.state: CONFIRMED, PENDING_APPROVAL or REJECTED_BY_POLICY// An unlisted beneficiary: refused before anything is signed { "state": "REJECTED_BY_POLICY", "failure": { "stage": "policy", "cause": "policy.operation-denied", … } } // Over the day's cap, the cause is "policy.limit-exceeded"
A payout at or above the threshold waits, holding no nonce and no fee. The approver signs the typed data the service serves; the signature is checked before it counts.
// What the approver reads: the decoded call, beneficiary and amount const approval = await cryptorail.GET('/v1/approvals/{transactionId}', { params: { path: { transactionId } }, }) // EIP-712 typed data with neither nonce nor fee: it never goes stale. // Any wallet signs it, MetaMask included: const signature = await wallet.request({ method: 'eth_signTypedData_v4', params: [approverAddress, JSON.stringify(approval.data!.typedData)], }) const signed = await cryptorail.POST( '/v1/approvals/{transactionId}/signatures', { params: { path: { transactionId } }, body: { approverId: treasurerId, signature }, }, ) // signed.data.quorumReached: the payout goes on to signing
Every USDC transfer to the treasury arrives as a signed webhook. Verify the signature, then apply each event once by its id; a reversal names the event it undoes.
import { verifyWebhook } from '@cryptorail/core'
// In the ERP's webhook endpoint, before trusting anything
const check = verifyWebhook([webhookSecret], {
id: headers['webhook-id'],
timestamp: headers['webhook-timestamp'],
signatureHeader: headers['webhook-signature'],
body: rawBody,
nowSeconds: Math.floor(Date.now() / 1000),
})
if (!check.ok) return reply(401) // never recorded
for (const event of JSON.parse(rawBody).events) {
// event.type: 'contract.event', or 'contract.event-reverted'
// (with payload.originalEventId) if the chain took it back
// event.payload: { contractLabel: 'pack-usdc',
// eventName: 'Transfer', params: { from, to, value }, … }
// event.id is stable: apply each one exactly once
}We show the pack on our sandbox, on a public test network where nothing of value moves. A pilot gives you your own: A pilot environment stood up in minutes with nothing installed on your side.