FeeRelayer

API reference

A relay co-signs the Solana transaction you built and signed, so the network gas is covered, and charges a USDC service fee inside that same transaction. It takes no custody, holds no balance, and opens no account. This page documents the contract exactly as the worker enforces it.

Machine-readable twin: /openapi.json. Relay list: /relays.json.

Two hosts, two different APIs

The apex feerelayer.net is the service website. A subdomain <name>.feerelayer.net is a relay. Which routes exist depends entirely on which of the two you are talking to.

HostRouteWhat it does
feerelayer.netGET /Homepage: what the service does, what it costs, and the first relays.
feerelayer.netGET /createThe creation form, for a human.
feerelayer.netPOST /createCreate a relay. Same path, and the method is what separates them.
feerelayer.netGET /statusPer-relay health, fee policy and usage.
feerelayer.netGET /legalThe notice and terms, in full.
feerelayer.netGET /relays.jsonThe machine-readable relay list.
feerelayer.netGET /docs, GET /openapi.jsonThis page, and its machine-readable twin.
<name>.feerelayer.netGET /The relay's own page: what it charges, a fee quote, and a way to get a stuck transaction sponsored. Same as /manual.
<name>.feerelayer.netGET /infoThe relay's fee policy and co-signer wallet.
<name>.feerelayer.netPOST /relayCo-sign and submit a transaction.
<name>.feerelayer.netPOST /simulateThe same checks, no signature, no submission.

Anything else is a 404. In particular /info and /relay on the apex are not relay routes, and /create on a subdomain is not a create route.

The fee model

A relay declares a percent rate (hard cap 2%) and a minimum floor in USDC (between 0.002 and 0.10). All money math happens in raw USDC units: integers, six decimals, so 1.00 USDC is 1000000.

feeRaw = max( round(amountRaw * rate), minRaw )

There is no rounding up to the cent: a sub-cent floor is honored exactly as declared. The client derives this same number before it signs, and the relay re-derives it server side. If the two disagree, the relay rejects.

The split across recipients

The total fee is then split across one or two declared recipients. With one recipient it all goes there. With two, from src/fee-split.js:

cut0 = min( fee, max( minRaw0, floor(fee * bps0 / 10000) ) )
cut1 = fee - cut0

Recipient 0 is always the relay and gas payer. Recipient 1, when declared, is the operator. bps0 is recipient 0's share in basis points, taken from the relay record as-is. minRaw0 is that recipient's own floor: the record states it as min_usdc in USDC, and the worker converts it to raw units before this line runs, so a declared min_usdc of 0.01 enters the formula as 10000. Convert it yourself before you compare, or your cut0 will not match the relay's.

Read this one twice: cut1 can collapse to zero

cut0 takes max(minRaw0, ...) before it is clamped to the total fee. So whenever the whole fee is at or below recipient 0's floor, recipient 0 takes all of it and cut1 is exactly 0. The transaction builder omits a zero-value transfer, so the transaction carries one fee transfer, not two, and the relay accepts it that way (the operator check is skipped when cuts[1] is 0).

Worked example on the hosted policy (bps0 = 3000, min_usdc 0.01 so minRaw0 = 10000, rate 1%, min 0.01). A 0.50 USDC payment:

amountRaw = 500000
fee   = max( round(500000 * 0.01), 10000 ) = max(5000, 10000) = 10000   // 0.01 USDC
cut0  = min( 10000, max( 10000, floor(10000 * 3000 / 10000) ) )
      = min( 10000, max( 10000, 3000 ) ) = 10000
cut1  = 10000 - 10000 = 0                                              // operator gets nothing

On that policy the operator earns nothing at all until the total fee exceeds 0.01 USDC, which takes a payment above roughly 1.00 USDC. This has cost production two incidents. If you write anything that counts fee transfers, assume one or two, never exactly two. If you write anything that pays an operator, do not promise them a cut of small payments.

Where the payee is also a fee recipient

Paying the relay operator through their own relay is legitimate and does work. The payment and the fee then land on the same account, so the validator cannot classify transfers by destination alone. It falls back to transaction order (the builder always emits the recipient transfer first), and in that fallback it charges the fee on the largest transfer present, so the ambiguity always resolves in the relay's favour rather than the caller's.

GET /info

On a relay subdomain. Machine discovery: the wallet reads this before it builds anything, to learn who co-signs and what the fee will be. No authentication, open CORS, no-store.

curl -s https://free.feerelayer.net/info

200, application/json:

{
  "version": 2,
  "name": "free",
  "wallet": "yffkypTLRypQ7AJJM1dqZfNKQGvye9ifedBc3dxaCJo",
  "fees": {
    "memo": 0,
    "payment": {
      "type": "percent",
      "rate": 0.01,
      "min": 0.01,
      "recipients": [
        { "ata": "<base58 token account>", "bps": 3000, "min_usdc": 0.01 },
        { "ata": "<base58 token account>", "bps": 7000 }
      ]
    }
  },
  "free_cancellation": false,
  "supported_actions": ["memo", "payment"],
  "turnstile_sitekey": "0x4AAAAAAA..."
}
FieldMeaning
versionAlways 2 for this shape.
nameThe relay name, which is also its subdomain.
walletThe co-signer pubkey. Put this at account slot 0 of your transaction, as the fee payer.
fees.memoUSDC charged for a free-flow memo. Currently 0 on every hosted relay: the revenue model is the payment fee.
fees.payment.typeAlways "percent".
fees.payment.rateFraction, not percent. 0.01 means 1%. Capped at 0.02.
fees.payment.minThe floor in USDC, between 0.002 and 0.10.
fees.payment.recipientsOne or two entries, each { ata, bps, min_usdc? }. These are the destinations your fee transfers must hit. See the fee model above.
free_cancellationAlways false.
supported_actions["memo", "payment"], the two accepted values of type on /relay.
turnstile_sitekeyThe public Turnstile sitekey, or null. Needed only for FREE flows, see below.
StatusBodyWhen
404{"error":"unknown_relay"}The host carries no relay name, the registry is not bound, or there is no record under that name.
400{"error":"relay_policy_invalid"}The stored record fails the re-check (rate or floor out of caps, malformed recipients). The relay refuses to serve a policy it cannot vouch for.

POST /relay

On a relay subdomain. You build and sign your own transaction; the relay adds the fee-payer signature so the SOL gas is covered. By default it then submits the transaction for you; with "mode": "sign" it only returns its signature and you broadcast the co-signed transaction yourself. It never takes custody of anything.

Request

POST https://<name>.feerelayer.net/relay
Content-Type: application/json
X-Turnstile-Token: <token>        // FREE flows only, see below

{
  "tx": "<base64 of the serialized, partially signed transaction>",
  "type": "payment",                // or "memo"
  "mode": "sign"                    // optional, see below
}
FieldRule
txRequired, a non-empty base64 string. Serialize with signature verification off (the relay's slot-0 signature is still missing) and make sure account slot 0 is the relay wallet from /info.
typeRequired, "payment" or "memo". Anything else is a 400. The retired value "cancellation" gets a structured 410.
modeOptional. Omit it and the relay submits the co-signed transaction to its own RPC and returns the resulting signature. Set it to "sign" and the relay validates and co-signs exactly the same way but does not submit: the response carries its signature and you insert it in slot 0 and broadcast through any RPC you like. Any other value is a 400.

X-Turnstile-Token is for FREE flows only

A type: "payment" request skips Turnstile and the whole quota stack. That is deliberate and it is not going to change: the USDC fee is the rate limit. Spamming a paid endpoint costs the spammer money and pays the relay, so the economics already protect it, and the captcha was simply adding half a second to every real-money flow.

Free flows (type: "memo") still pay the full tax, because they are the residual spam vector: they cost the sender nothing. Send the header there, using the turnstile_sitekey from /info. A wallet already in credit with the relay (its reputation covers the memo cost) bypasses even that.

Response

200, application/json, with the relay name echoed in a header:

x-relay-mode: free

{ "signature": "5zKZ3uRd..." }

With "mode": "sign" the body also says so, and the signature is the relay's slot-0 signature, which is by construction the transaction id once you broadcast it:

{ "signature": "5zKZ3uRd...", "mode": "sign" }

If you asked for "mode": "sign" and the answer has no mode, you are talking to a relay that predates the option: it has already submitted the transaction, do not broadcast it again.

Every failure is { "error": "<string>" } at the status below. Open CORS on both.

Every error POST /relay can return

These are the literal strings, in the error field. Values in angle brackets are interpolated by the worker.

StatuserrorWhen
404unknown_relayNo relay name in the host, the registry is unbound, or no record exists under that name. The relay never falls back to a default policy: it would be charging the wrong fee.
400relay_policy_invalidThe stored record fails the caps and recipients re-check.
400Invalid JSON bodyThe body did not parse.
400tx (base64) requiredtx missing, not a string, or empty.
410cancellation_type_deprecatedtype: "cancellation", retired. Kept as a structured stub for old clients.
400Invalid type "<value>"type is neither payment nor memo. Your value is echoed back.
400Invalid base64 transactiontx is not decodable base64.
400Failed to parse transaction: <detail>The bytes decoded but are not a Solana transaction the parser accepts.
400Fee payer mismatchAccount slot 0 is not this relay's wallet. Take it from /info and put it first.
400cosign_refused: disallowed program in a relay-cosigned transactionGuard A. Only Memo, Token and Associated Token may appear.
400cosign_refused: instruction references an unknown program slotAn instruction points at an account index that does not exist.
400cosign_refused: relay fee ATA used as a transfer source/authorityGuard B. The relay's fee account was slot 0 of a token instruction.
400cosign_refused: relay pubkey used as a Token-instruction authorityGuard B. The relay's own pubkey appeared inside a token instruction.
400cosign_refused: relay misconfigured (bad USDC ATA)The relay cannot decode its own fee account. It refuses rather than guess.
403Turnstile: missing_tokenFREE flows only. No X-Turnstile-Token header.
403Turnstile: siteverify_failedFREE flows only. Cloudflare rejected the token. Tokens are single use, so reset the widget between attempts.
403Turnstile: siteverify_unreachableFREE flows only. Verification could not be reached. This fails closed.
429per_ip_limit_exceededFREE flows only. 50 per IP per day.
429total_daily_limit_exceededFREE flows only. 1000 across the service per day.
400Missing SPL Memo programPayment and memo. No memo program in the account list.
400Missing SPL Token programPayment. No token program in the account list.
400Multiple memosPayment and memo. Exactly one is expected. Foreign memos are ignored, not counted.
400No valid memoPayment and memo. No memo instruction parsed as one of ours.
400Memo kind must be "pay" or "withdraw" (got "<kind>")Payment. Those two kinds are structurally identical; the label only drives how the wallet renders the row.
400Duplicate transfer to fee recipient <slot>Payment. Two transfers landed on the same declared fee account and the transaction order could not disambiguate them.
400Ambiguous recipient transfer (expected 1, got <n>)Payment. After classifying fee transfers by destination, the number of remaining transfers was not exactly one.
400Missing our cut transfer to relay walletPayment. No transfer to recipient 0 at all.
400Insufficient payment fee (our cut): got <a>, need <b>Payment. Recipient 0 was short. Both numbers are raw USDC units.
400Missing operator transfer (need recipient-1 cut <n>)Payment. Recipient 1 is owed a non-zero cut and got no transfer. Never fires when cut1 collapsed to 0.
400Insufficient operator cut: got <a>, need <b>Payment. Recipient 1 was short. A client cannot underpay a declared operator.
400Insufficient our cut: ATA-creation reimbursement (refresh rate and retry) (...)Payment. This transaction creates a recipient USDC account at the relay's expense and the fee did not cover the rent. Refresh your SOL price and rebuild. The relay only rejects here after a price fetched within the last minute confirms it, and never on a stale price or a failed fetch.
410memo_type_deprecatedMemo. Only kind: "backup" and kind: "ack" travel this path now.
400memo_tx_must_not_create_ataMemo. A memo transaction may not create an associated token account at the relay's expense.
400Missing fee transfer to relay walletMemo, and only when the memo fee is non-zero. It is 0 on every hosted relay today, so memos carry no fee transfer.
400Insufficient memo fee: got <a>, need <b>Memo, non-zero memo fee only.
500Relay misconfigured (missing secret | missing pubkey | missing USDC ATA | missing RPC | bad secret | bad pubkey: <detail>)The worker is not wired up. Not your fault, and not retryable by you.
500Validation error: <detail>Validation threw unexpectedly.
502Solana submission failed: <detail>The transaction passed every check and the network refused it, or the RPC call failed.

Worked examples

Discover the policy:

curl -s https://free.feerelayer.net/info

Dry run a payment, which signs nothing:

curl -s -X POST https://free.feerelayer.net/simulate \
  -H 'Content-Type: application/json' \
  -d '{"tx":"AQAAAAAA...","type":"payment"}'

Submit a payment. No captcha header: paid flows do not carry one.

curl -s -i -X POST https://free.feerelayer.net/relay \
  -H 'Content-Type: application/json' \
  -d '{"tx":"AQAAAAAA...","type":"payment"}'

Submit a free memo. This one does need the captcha token:

curl -s -X POST https://free.feerelayer.net/relay \
  -H 'Content-Type: application/json' \
  -H 'X-Turnstile-Token: 0.abc123...' \
  -d '{"tx":"AQAAAAAA...","type":"memo"}'

List every relay, or fetch this reference as OpenAPI:

curl -s https://feerelayer.net/relays.json
curl -s https://feerelayer.net/openapi.json

POST /simulate

On a relay subdomain. The dry run of POST /relay: same body, same relay resolution, same parse, same co-sign safety guard, same validators, same reason strings. Then it stops and reports instead of signing.

It never signs and never submits. No signature is produced, nothing reaches the Solana network, no quota is consumed and nothing is written anywhere. It does not even need the signing key or an RPC URL to answer. Use it while you are still getting the fee split right, so the loop is "fix the transaction" rather than "spend one to read an error string".

curl -s -X POST https://free.feerelayer.net/simulate \
  -H 'Content-Type: application/json' \
  -d '{"tx":"<base64>","type":"payment"}'

Always 200, always the same shape

A refusal is still an HTTP 200: the report was produced, and the verdict is in the body. Read ok. status is the status POST /relay would have returned, and reason is the same string it would have returned, so the whole error table above applies here too. Every field below is always present.

{
  "ok": false,
  "reason": "Insufficient payment fee (our cut): got 3000, need 10000",
  "status": 400,
  "relay": "free",
  "type": "payment",
  "feeRequiredRaw": 10000,
  "feeRequiredUsdc": 0.01,
  "feePaidRaw": 3000,
  "feePaidUsdc": 0.003,
  "cuts": [
    { "index": 0, "ata": "<base58>", "bps": 3000, "minUsdc": 0.01,
      "requiredRaw": 10000, "requiredUsdc": 0.01,
      "paidRaw": 3000, "paidUsdc": 0.003, "present": true, "covered": false },
    { "index": 1, "ata": "<base58>", "bps": 7000, "minUsdc": null,
      "requiredRaw": 0, "requiredUsdc": 0,
      "paidRaw": 0, "paidUsdc": 0, "present": false, "covered": true }
  ],
  "recipient": "<base58 destination token account>",
  "amountRaw": 500000,
  "amountUsdc": 0.5,
  "wouldCosign": false,
  "simulated": true,
  "notes": []
}
FieldMeaning
okThe verdict. true means POST /relay would have accepted this transaction.
reasonnull when ok, otherwise the exact string /relay would have returned. Note the field is reason here, where /relay calls it error.
status200 when ok, otherwise the status /relay would have used.
relay, typeWhat was resolved from the host and the body.
feeRequiredRaw / UsdcThe total fee the relay computed, in raw units and in USDC.
feePaidRaw / UsdcWhat your transaction actually sends to the fee recipients.
cuts[]One entry per declared recipient: its bps, its own floor, what it is owed, what it was paid, whether a transfer is present, and whether it is covered. A recipient owed 0 is covered with no transfer at all, which is the collapse case from the fee model above.
recipient, amountRaw / UsdcThe transfer identified as the actual payment.
wouldCosignDerived from ok. Never true on a refusal.
simulatedAlways true. Nothing was signed.
notes[]Human-readable warnings about the ways this dry run is more permissive than the real thing. Read them.

Where a simulation is more permissive than POST /relay

A pass here is not a guarantee. Two gaps, both reported in notes rather than hidden:

  • Free flows. /simulate runs no part of the protection stack for any type, so a memo simulation says nothing about the Turnstile and quota layers that /relay still applies to it. A simulation can pass where the real request is rate limited.
  • New recipient accounts. When the transaction creates a recipient USDC account at the relay's expense, /relay checks the rent reimbursement against a freshly fetched SOL price. /simulate fetches no price, and the validator never rejects what it cannot prove, so a thin reimbursement can pass here and be refused there. Keep your client's margin.

POST /create, or: run your own relay

On the apex only. Self-service, no account, no KYC. You submit a name and the Solana wallet you want paid; the relay is live at <name>.feerelayer.net the moment it returns.

curl -s -X POST https://feerelayer.net/create \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "bobpay",
    "payout_wallet": "<your base58 Solana wallet>",
    "rpc": "helius",
    "rate": 0.015,
    "min": 0.01,
    "turnstileToken": "<token from the homepage widget>"
  }'
FieldRule
nameMatches ^[a-z0-9][a-z0-9-]{1,30}$, so 2 to 31 characters. Immutable once created. A short list of names is reserved: infrastructure words such as admin, www, api, relay, info and create, the RPC backend names, and a few product names held back so nobody can register one and impersonate its owner. A rejected name is reported as name_reserved.
payout_walletYour Solana wallet, base58, decoding to exactly 32 bytes. Not a token account: the relay discovers your USDC account itself. Send yourself at least 0.01 USDC first if the wallet is brand new, otherwise there is no USDC account to find.
rpc"helius" or "alchemy". Invisible to your users, it only picks which provider broadcasts.
rateA fraction, not a percent. 0.015 is 1.5%. Range 0 to 0.02.
minThe floor in USDC. Range 0.002 to 0.10.
turnstileTokenRequired whenever the service has Turnstile configured, which it does in production.

200:

{
  "name": "bobpay",
  "endpoint": "https://bobpay.feerelayer.net",
  "share_link": "https://bobpay.feerelayer.net/manual"
}
StatuserrorWhen
405method_not_allowedNot a POST.
400invalid_jsonThe body is not JSON.
400pseudo_requiredname missing or not a string.
400pseudo_formatname fails the pattern.
400pseudo_reservedname is one of the reserved words.
400wallet_invalidpayout_wallet is not base58 decoding to 32 bytes.
400rpc_invalidrpc is neither helius nor alchemy.
400rate_invalidrate is not finite, is negative, or exceeds 0.02.
400min_fix_invalidmin is outside 0.002 to 0.10.
403turnstile_missing_token
turnstile_siteverify_failed
turnstile_siteverify_unreachable
The anti-spam gate. Tokens are single use and expire, so reset the widget between attempts.
409pseudo_takenThat name already exists. Names are never reassigned.
422ata_not_readyYour wallet has no USDC account yet. Send yourself a little USDC and retry.
400ata_mismatchAccounts came back but none was a USDC account owned by that wallet.
502ata_lookup_failedThe RPC lookup failed. This fails closed: nothing is written.
500misconfiguredThe registry is not bound on the worker.

No relay record is written on any rejection: the store write is the last step, after every check has passed.

How your fee is shared

On a hosted relay with a non-zero rate, the record is created with two recipients: 3000 bps to the infrastructure with a 0.01 USDC floor, 7000 bps to you. That policy lives in /create only, written into your record as plain data. Everything downstream just honors whatever recipients a record declares. A relay with rate: 0, or one whose payout account is the infrastructure account itself, is created with a single recipient instead. Re-read the fee model above before you plan on that 70%: below roughly a 1.00 USDC payment, your cut is zero.

Integration snippet

Pick a relay and copy the client code for it. Everything below is generated in your browser from the list above, nothing is sent anywhere.

What this relay refuses to co-sign, and why

The relay checks that account slot 0 is its own wallet, and then it signs the whole message. That one signature authorizes every instruction in the transaction that touches the relay's key. So the interesting question is not what the relay signs, it is what it refuses to sign. Three rules, enforced before any signature exists, on payments and memos alike.

1. Only three programs are allowed to appear

SPL Memo, SPL Token, and Associated Token. That is the entire allowlist. The wallet's builders emit only those three, so nothing legitimate is lost, and the refusal kills two whole classes of abuse at once: a System Program transfer with the relay at slot 0 would drain its SOL gas outright, and a Compute Budget instruction would let a caller burn the relay's priority-fee budget for free. Any exotic cross-program call is refused for the same reason.

if (!allowed.some((a) => arraysEqual(a, progBytes))) {
  return { valid: false, reason: 'cosign_refused: disallowed program in a relay-cosigned transaction' };
}

2. The relay's own fee account may never be slot 0 of a token instruction

For Transfer, TransferChecked, Approve, Burn, CloseAccount and SetAuthority alike, the first account of an SPL Token instruction is the account being acted upon: the victim slot. If the relay's fee account sat there, the relay would be moving its own USDC under its own signature, and the fee validation would not notice because a small honest fee elsewhere in the transaction is enough to satisfy it. So the fee account is allowed to appear in exactly one role, as a transfer destination, and nowhere else.

const acct0 = idxs[0] != null ? parsed.accountKeys[idxs[0]] : null;
if (acct0 && arraysEqual(acct0, ourAtaBytes)) {
  return { valid: false, reason: 'cosign_refused: relay fee ATA used as a transfer source/authority' };
}

The check is deliberately discriminator-agnostic. It does not ask which token instruction this is, which is why it also covers instructions nobody has thought of yet.

3. The relay's own pubkey may not appear inside a token instruction at all

In a legitimate payment the relay is the transaction-level fee payer and, at most, the funder of a new recipient account through the Associated Token program. It is never an account inside a token instruction. Its presence there would mean it is the authority moving a token account it owns, on any mint, not just USDC. Refused: cosign_refused: relay pubkey used as a Token-instruction authority.

And one more, on the free path

A memo transaction has no legitimate reason to create an associated token account, so any Associated Token instruction in a memo transaction is refused with memo_tx_must_not_create_ata. Without that rule, a free memo could quietly make the relay pay account rent, once per transaction, forever. It is griefing rather than theft, and it is bounded by the captcha and the quotas, but there is no reason to accept it.

What the relay does not check

It does not know or care who you are paying, or why. It never holds your funds, it never sees a private key of yours, and it cannot alter the destination or the amount you signed. If your transaction is well formed, pays the declared fee, and touches none of the relay's own accounts as a source, it gets co-signed.

This page carries no third-party script, no remote font and no analytics: every byte of it is served by the relay itself. It also carries no per-relay usage figure of any kind, and it never will. The worker counts nothing here, so any number on this page would be invented, and the choice of relay decides where up to 2% of a merchant’s money goes. What IS measured is published on /status, and nowhere else.