API reference
Authentication
SIWE nonce + JWT issuance.
Issue a single-use SIWE nonce
POST /auth/nonces
Public — no credentials
Responses
- 201 Nonce created.
curl -X POST "https://api.rail0.xyz/auth/nonces"const res = await fetch("https://api.rail0.xyz/auth/nonces", { method: "POST",});
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/auth/nonces")req = Net::HTTP::Post.new(uri)
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")
func main() { url := "https://api.rail0.xyz/auth/nonces" req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { panic(err) }
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))}{ "nonce": "string", "expires_at": "2026-10-01T12:00:00Z"}Verify a signed SIWE message and return a JWT
POST /auth
Public — no credentials
Request body
-
message string required
EIP-4361 SIWE message text. Purpose-bound: its
statementmust be exactly "Sign in to RAIL0" — a proof signed for wallet registration (or with no statement) is refused with 422siwe_purpose_mismatch, so a signature obtained for one flow can never be spent at the other. -
signature string required
Wallet signature over the SIWE message (0x…).
Responses
- 201 SIWE verified; JWT issued.
-
422
SIWE verification failed. The
codefield identifies the failing step:invalid_siwe,invalid_nonce,nonce_used,signer_mismatch, or one of the four binding failuressiwe_domain_not_allowed/siwe_uri_mismatch/siwe_chain_mismatch/siwe_proof_expired(each names the value the caller sent, never the gateway's expected value). The message'schainIdmay be 1, or the id of any active chain of this environment's catalog; anything else issiwe_chain_mismatch. An address with no gateway account is NOT a failure — it yields an account-less token (201).
curl -X POST "https://api.rail0.xyz/auth" \ -H "Content-Type: application/json" \ -d '{ "message": "acme.example wants you to sign in with your Ethereum account:\n0x8ba1f109551bD432803012645Ac136ddd64DBA72\n\nSign in to RAIL0\n\nURI: https://acme.example\nVersion: 1\nChain ID: 84532\nNonce: 32891756\nIssued At: 2026-10-01T12:00:00Z", "signature": "0x6b1f…9c1b"}'const res = await fetch("https://api.rail0.xyz/auth", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message: "acme.example wants you to sign in with your Ethereum account:\n0x8ba1f109551bD432803012645Ac136ddd64DBA72\n\nSign in to RAIL0\n\nURI: https://acme.example\nVersion: 1\nChain ID: 84532\nNonce: 32891756\nIssued At: 2026-10-01T12:00:00Z", signature: "0x6b1f…9c1b" }),});
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/auth")req = Net::HTTP::Post.new(uri)req["Content-Type"] = "application/json"req.body = { message: "acme.example wants you to sign in with your Ethereum account:\n0x8ba1f109551bD432803012645Ac136ddd64DBA72\n\nSign in to RAIL0\n\nURI: https://acme.example\nVersion: 1\nChain ID: 84532\nNonce: 32891756\nIssued At: 2026-10-01T12:00:00Z", signature: "0x6b1f…9c1b"}.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(`{ "message": "acme.example wants you to sign in with your Ethereum account:\n0x8ba1f109551bD432803012645Ac136ddd64DBA72\n\nSign in to RAIL0\n\nURI: https://acme.example\nVersion: 1\nChain ID: 84532\nNonce: 32891756\nIssued At: 2026-10-01T12:00:00Z", "signature": "0x6b1f…9c1b"}`) url := "https://api.rail0.xyz/auth" 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"}End the current session
POST /auth/logout
Bearer JWT (Sign-In With Ethereum)
Revokes the token presented in the Authorization header, so it stops authenticating before its exp. PER TOKEN, not per address: signing out one device leaves the others signed in — revoking every session of an address is a different operation and is not this one. Requires the session it revokes, which is what keeps it from being a way to sign someone else out.
Best-effort by construction. The revocation list fails OPEN: if its store cannot be reached, a token cannot be known to be revoked and the request proceeds — one store outage must not sign out the whole platform. revoked in the response therefore reports whether the revocation was durably written; false means the token remains usable until exp, and the caller should treat its own copy as compromised.
Responses
-
200
Session ended (see
revoked). - 401 Authentication failed.
curl -X POST "https://api.rail0.xyz/auth/logout" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/auth/logout", { method: "POST", 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/auth/logout")req = Net::HTTP::Post.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/auth/logout" req, err := http.NewRequest(http.MethodPost, 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))}{ "revoked": false}Revoke every session of an address (fresh SIWE proof, not a session)
POST /auth/revoke_all
Public — no credentials
Writes an address-wide cutoff: every JWT issued to the address before it is refused with 401 sessions_revoked. Gated on a fresh SIWE proof carrying its own statement ("Sign out of RAIL0 everywhere") rather than on a session — an operator reacting to a leaked key holds the wallet, not the stolen token, and purpose-binding keeps a login signature from being replayed here. Self-revoking: the cutoff is now+1, so any token the caller holds dies too; sign in again afterwards. The revocation propagates within seconds (a short per-process cache pays the hot-path read), and the write is durable in Postgres — unlike the per-token denylist, this lever fails closed.
Request body
-
message string required
EIP-4361 SIWE message signed by the address, carrying the revoke-all statement.
-
signature string required
Signature over the SIWE message (0x…).
Responses
- 200 Every session of the address issued before cutoff_at is revoked.
- 422 The proof failed verification (same SIWE codes as POST /auth) or carries the wrong statement (siwe_purpose_mismatch).
curl -X POST "https://api.rail0.xyz/auth/revoke_all" \ -H "Content-Type: application/json" \ -d '{ "message": "acme.example wants you to sign in with your Ethereum account: …", "signature": "0x6b1f…9c1b"}'const res = await fetch("https://api.rail0.xyz/auth/revoke_all", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message: "acme.example wants you to sign in with your Ethereum account: …", signature: "0x6b1f…9c1b" }),});
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/auth/revoke_all")req = Net::HTTP::Post.new(uri)req["Content-Type"] = "application/json"req.body = { message: "acme.example wants you to sign in with your Ethereum account: …", signature: "0x6b1f…9c1b"}.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(`{ "message": "acme.example wants you to sign in with your Ethereum account: …", "signature": "0x6b1f…9c1b"}`) url := "https://api.rail0.xyz/auth/revoke_all" 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))}{ "revoked_all": true, "cutoff_at": "2026-10-01T12:00:01Z"}