API reference
Accounts
Merchant accounts. Reads and updates are owner-scoped; listing and creating accounts is operator-only.
Complete an onboarding request: create the account with the emailed link and a SIWE proof by its wallet
POST /claim
Public — no credentials
Unauthenticated: the account does not exist until this call succeeds. The one way an account is born, for an operator's invite and a public sign-up alike. Two factors at once: token, the link the GATEWAY emailed for the onboarding request (HMAC-signed; it names the request, its link version, address, email and name, and expires with the request), and a SIWE proof whose statement is exactly "Claim this RAIL0 merchant account with this wallet", signed by the request's wallet (nonce from POST /auth/nonces; same domain/uri/chain binding as login). The link and the request are checked before the proof, so a stale link never spends the nonce. On success, in one transaction: the account is created with the REQUEST'S id, active, with its wallet; the request is marked completed with terms_version. The answer is the same session POST /auth would mint (201). A repeat with the same link after completion is a login: 200 and a fresh session if the account is active. A name, email or address taken by an account between the request and the claim answers 409 and leaves the request open. Throttled per client IP with the rest of the public surface (the same limit as POST /auth).
Request body
-
token string required
The token from the emailed link (
<claim page>#<token>): base64url(JSON payload) "." base64url(HMAC-SHA256(RAIL0_ONBOARDING_SECRET, payload segment)), both unpadded. Payload keys: v (3), env (the minting gateway's environment, RAIL0_ENVIRONMENT or by default RACK_ENV; a token from another environment is onboarding_link_invalid), onboarding_id, lv (the link version), address (lowercase), email (lowercase), name, exp (unix seconds). Minted only by the gateway; a client may decode the payload to show it, never mint one. -
message string required
EIP-4361 SIWE message signed by the request's wallet. Its statement must be exactly "Claim this RAIL0 merchant account with this wallet" — a login or wallet-add proof is refused with 422 siwe_purpose_mismatch.
-
signature string required
Signature over the SIWE message (0x…).
-
terms_version string required
The version of the terms the merchant accepted. Missing or blank is 422 terms_required.
Responses
- 200 The request was already completed: a fresh session for its (active) account.
- 201 Account created and operational. The session, as POST /auth returns it.
- 400 token, message or signature missing.
- 403 account_deactivated: a repeat of a completed claim whose account an operator has deactivated.
- 409 account_name_taken, account_email_taken or account_wallet_taken: an account took the value first. The request stays open.
- 422 onboarding_link_invalid (not signed by the gateway, malformed, for a request that no longer exists, or whose email/address differ from the request), onboarding_link_expired, onboarding_link_superseded (a newer link was sent: use the newest email), terms_required, signer_mismatch (the proof is not signed by the request's wallet), or one of the SIWE failures of POST /auth (invalid_siwe, invalid_nonce, nonce_used, siwe_purpose_mismatch, siwe_domain_not_allowed, siwe_uri_mismatch, siwe_chain_mismatch, siwe_proof_expired).
- 429 rate_limited.
- 503 onboarding_unavailable: this gateway has no RAIL0_ONBOARDING_SECRET configured.
curl -X POST "https://api.rail0.xyz/claim" \ -H "Content-Type: application/json" \ -d '{ "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "message": "acme.example wants you to sign in with your Ethereum account: …", "signature": "0x6b1f…9c1b", "terms_version": "string"}'const res = await fetch("https://api.rail0.xyz/claim", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", message: "acme.example wants you to sign in with your Ethereum account: …", signature: "0x6b1f…9c1b", terms_version: "string" }),});
if (!res.ok) { const { code, detail } = await res.json(); throw new Error(`${res.status} ${code}: ${detail}`);}const data = await res.json();require "json"require "net/http"
uri = URI("https://api.rail0.xyz/claim")req = Net::HTTP::Post.new(uri)req["Content-Type"] = "application/json"req.body = { token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", message: "acme.example wants you to sign in with your Ethereum account: …", signature: "0x6b1f…9c1b", terms_version: "string"}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }raise "#{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)data = JSON.parse(res.body)package main
import ( "fmt" "io" "net/http" "strings")
func main() { body := strings.NewReader(`{ "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "message": "acme.example wants you to sign in with your Ethereum account: …", "signature": "0x6b1f…9c1b", "terms_version": "string"}`) url := "https://api.rail0.xyz/claim" req, err := http.NewRequest(http.MethodPost, url, body) if err != nil { panic(err) } req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
data, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(data))}{ "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "account_id": null, "name": "Acme Store", "expires_at": "2026-10-01T12:00:00Z"}Create an onboarding request: a public sign-up, or an operator's invite
POST /signup
Bearer JWT (Sign-In With Ethereum)
One endpoint and one body for both entrances, discriminated by the Authorization header BEFORE the body is read:
- no header: a public self-service sign-up (origin
signup, link valid 24 h). Every outcome — a request created, an open one reused, or an email that already has an account (then the mailbox receives "account already exists" instead of a link) — answers the SAME 202{status: "check_your_email"}, so the response reveals nothing about which emails are registered. An address or a name already registered is explicit (409): addresses are public on-chain, and names are shown to payers. They are checked BEFORE the email, so that 409 is the same whatever the email, and the neutral 202 only ever covers an email clash. - an admin session (require_admin!): an operator's invite (origin
operator, link valid 7 days), audited as onboarding.create (onboarding.send when an open invite with the same email and address is reused), with explicit 409s. - any other token: 401 (invalid, expired, revoked; the codes of every authenticated route) or 403 (valid but without the operator grant, or a deactivated wallet) — never a fallback to the public path.
Before anything is written: validation (400), a delivering email provider (503) and the email domain allowlist (422). An open request of the same origin with the same email and address is REUSED (a new link: link_version + 1, renewed expiry, send_count + 1, and the name just submitted); otherwise a new request is inserted, and the open requests already past their expiry are deleted in the same transaction. Open requests never block each other: clashes with other requests are reported to the operator as conflicts. The mail is queued after the commit and sent by the gateway's worker; no response ever contains the link. RAIL0_SIGNUP_ENABLED=false switches the public path off (503 signup_unavailable) without touching invites. Rate limits: signup/ip (5/h per client IP, an IPv6 client by its /64) without a verifiable token, signup/operator (60/h per session) with one, plus the shared public per-IP tier.
Request body
-
name string required
The account's name (stripped). Unique among accounts.
-
email string required
Contact email: ONE bare address
x@y(no spaces, commas, semicolons, quotes or angle brackets: the mailer's own rule); the link is sent here. Stored stripped and lowercase. -
address string required
The merchant's wallet (stored lowercase). The claim must be signed by it.
Responses
- 200 Operator: an open invite with the same email and address was reused and a new link queued.
- 201 Operator: the invite was created and its mail queued.
- 202 Public: always this body — created, reused, or the email already has an account.
-
400
validation_failed: a field is missing or malformed (name 1–100 characters, email one bare
x@yaddress, address 0x + 40 hex). - 401 A token was sent but is not valid: invalid_token, token_expired, token_revoked, sessions_revoked.
- 403 forbidden (a valid session without the operator grant) or wallet_deactivated.
- 409 account_wallet_taken or account_name_taken; for the operator also account_email_taken (the public path answers that one with the neutral 202).
-
422
email_domain_not_allowed: the email is outside EMAIL_ALLOWED_DOMAINS (the body carries
allowed, the domains that are); nothing was written. - 429 rate_limited.
-
503
signup_unavailable (public path switched off), onboarding_unavailable, or email_provider_not_configured (operator: with
preview).
curl -X POST "https://api.rail0.xyz/signup" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Store", "email": "payments@acme.example", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F"}'const res = await fetch("https://api.rail0.xyz/signup", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Acme Store", email: "payments@acme.example", address: "0x71C7656EC7ab88b098defB751B7401B5f6d8976F" }),});
if (!res.ok) { const { code, detail } = await res.json(); throw new Error(`${res.status} ${code}: ${detail}`);}const data = await res.json();require "json"require "net/http"
uri = URI("https://api.rail0.xyz/signup")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { name: "Acme Store", email: "payments@acme.example", address: "0x71C7656EC7ab88b098defB751B7401B5f6d8976F"}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }raise "#{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)data = JSON.parse(res.body)package main
import ( "fmt" "io" "net/http" "os" "strings")
func main() { body := strings.NewReader(`{ "name": "Acme Store", "email": "payments@acme.example", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F"}`) url := "https://api.rail0.xyz/signup" req, err := http.NewRequest(http.MethodPost, url, body) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("RAIL0_TOKEN")) req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
data, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(data))}{ "onboarding": { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "origin": "operator", "name": "Acme Store", "email": "payments@acme.example", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "created_by": null, "link_version": 1, "last_sent_at": "2026-10-01T12:00:00Z", "send_count": 1, "last_delivered_at": null, "last_send_error": null, "expires_at": "2026-10-01T12:00:00Z", "completed_at": null, "terms_version": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z", "status": "open", "creator": { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "name": "Acme Store", "email": "payments@acme.example", "priority_tip": false, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" } }, "conflicts": [ { "field": "name", "with": "account", "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b" } ], "email": { "kind": "invite", "to": "string", "queued": true }}Read an account profile (own account; any account for an admin)
GET /accounts/{account_id}
Bearer JWT (Sign-In With Ethereum)
The account row. The owner reads their own profile (id, name, email, priority_tip and timestamps — the explicit allow-list, with no admin/role field: the operator axis leaves no trace on a standard response); an active admin reads ANY account, whole record (AccountFull: every column plus admin, whether the account holds the operator grant, and the derived status). For a non-admin, another account's id and an id that is not an account answer alike, so this cannot be used to tell whether an account exists.
Path parameters
-
account_id string (uuid) required
Responses
- 200 The owner's profile (Account: id, name, email, priority_tip, timestamps), or for an admin session the whole record (AccountFull).
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
curl "https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b", { headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, },});
if (!res.ok) { const { code, detail } = await res.json(); throw new Error(`${res.status} ${code}: ${detail}`);}const data = await res.json();require "json"require "net/http"
uri = URI("https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b")req = Net::HTTP::Get.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }raise "#{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)data = JSON.parse(res.body)package main
import ( "fmt" "io" "net/http" "os")
func main() { url := "https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("RAIL0_TOKEN"))
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
data, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(data))}{ "id": "a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b", "name": "Acme Store", "email": "payments@acme.example", "created_at": "2026-09-14T09:30:00Z", "updated_at": "2026-10-01T12:00:00Z"}Update an account: own profile; an admin may also set active
PATCH /accounts/{account_id}
Bearer JWT (Sign-In With Ethereum)
The one write on the account row. The owner updates name, email and/or priority_tip (the fee setting of its own transactions); active is an operator field — supplying it requires the operator grant (403 otherwise). An admin may also rename ANY account (audited as account.update), but not change another account's email (403 forbidden): the contact email is its owner's, and corrections before the account exists are made on the onboarding request (PATCH /admin/onboardings/{id}). The grant itself is not writable here: it is granted and revoked on /admin/administrators. Requires an ACTIVE session wallet: deactivation is the revocation primitive, and a revoked key must not be able to redirect the account's contact email. name/email are unique; a taken value is a 409. Last-admin guard: deactivating the account holding the last operative grant is refused with 422 last_admin — including doing it to yourself.
Path parameters
-
account_id string (uuid) required
Request body
-
name string
Display name. Unique across accounts.
-
email string
Contact email: ONE bare address
x@y(no spaces, commas, semicolons, quotes or angle brackets: the mailer's own rule). Stored lowercase; unique across accounts case-insensitively. -
active boolean
Active status (admin only).
-
priority_tip boolean
Tip the network's suggested priority fee on the merchant's own transactions (default true); false uses the chain's minimum tip (0 on most chains, 25 gwei on Polygon PoS/Amoy). Owner or admin.
Responses
- 200 The updated account: the owner's profile (Account), or for an admin session the whole record (AccountFull).
- 400 Neither field was supplied, or a value was blank/malformed.
- 401 Missing or invalid token.
- 403 Another account's id, the session wallet is deactivated, active without the operator grant, or an operator changing another account's email (forbidden).
- 409 The name or email is already taken by another account.
- 422 last_admin: the change would leave zero active admins.
curl -X PATCH "https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{}'const res = await fetch("https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({}),});
if (!res.ok) { const { code, detail } = await res.json(); throw new Error(`${res.status} ${code}: ${detail}`);}const data = await res.json();require "json"require "net/http"
uri = URI("https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b")req = Net::HTTP::Patch.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = {}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }raise "#{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)data = JSON.parse(res.body)package main
import ( "fmt" "io" "net/http" "os" "strings")
func main() { body := strings.NewReader(`{}`) url := "https://api.rail0.xyz/accounts/a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b" req, err := http.NewRequest(http.MethodPatch, url, body) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("RAIL0_TOKEN")) req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close()
data, err := io.ReadAll(res.Body) if err != nil { panic(err) } fmt.Println(res.Status, string(data))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "name": "Acme Store", "email": "payments@acme.example", "priority_tip": false, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}