API reference
Payments
Payment lifecycle: create, sign, prepare/submit per operation.
- GET /payments
- POST /payments
- GET /payments/{id}
- PATCH /payments/{id}
- PUT /payments/{id}/sign
- GET /payments/{id}/transactions
- GET /payments/{id}/transactions/{transaction_id}
- POST /payments/{id}/transactions/{transaction_id}/redrive
- POST /payments/{id}/{operation}/prepare
- POST /payments/{id}/{operation}
- POST /payments/{id}/{operation}/submitted
- POST /payments/{id}/dispute/submitted
- POST /payments/{id}/dispute/close/submitted
- POST /payments/{id}/dispute/prepare
- POST /payments/{id}/dispute
- POST /payments/{id}/dispute/close/prepare
- POST /payments/{id}/dispute/close
- GET /payments/{id}/disputes
List payments for the authenticated wallet (payer or payee)
GET /payments
Bearer JWT (Sign-In With Ethereum)
Query parameters
-
page integer
1-based page number.
-
per_page integer
Items per page (capped at 100).
-
after string
Cursor paging: the
x-next-cursor(or the Linknext'safter) of the previous page. Walks the collection by-created_atwithout a total count, at a constant cost per page;pageand anysortother than-created_atare rejected with 422invalid_cursorbeside it. The last page carries nox-next-cursorand nonextlink. -
sort string
Comma-separated sort fields; prefix with - for descending (e.g. -created_at,status). An unsupported field is rejected with 422
invalid_sort, whose detail names the allowed set — it is never silently dropped. -
status array of string
Lifecycle state(s): one value, a comma-separated list (
status=authorized,expired) or the repeatedstatus[]=form; matches any of them. An unknown value is 400validation_failednaming it. Deliberately a HAPPY-PATH label, not a ledger: a partial operation does NOT move it, so a payment captured 100 and refunded 40 still readscaptured, and a full refund that leaves uncaptured escrow does too.capturable_amount/refundable_amountare the authoritative residuals — reconcile on those, not on this.partially_refundedis retained for historical rows only and is no longer produced.One of:unsigned,signed,authorized,charged,captured,partially_captured,expired,voided,released,refunded,partially_refunded -
mode stringOne of:
authorize,charge -
payer string
-
payee string
-
token string
-
rail0_id string
Filter by the logical on-chain payment id (0x…).
-
chain_id integer
Filter by the payment's chain (resolved via its contract).
-
disputed boolean
Filter by whether an open dispute exists.
-
min_amount string
Minimum amount in token base units (inclusive).
-
max_amount string
Maximum amount in token base units (inclusive).
-
created_from string (date-time)
Only payments created at/after this time (ISO-8601).
-
created_to string (date-time)
Only payments created at/before this time (ISO-8601).
-
operation string
Only payments carrying at least one transaction with this operation.
One of:authorize,capture,charge,void,release,refund,dispute,close_dispute -
scope string
mine(default): the authenticated wallet's payments.all(admin only, 403 otherwise): every payment on the gateway.One of:mine,all -
account_id string
scope=all only: payments whose payee is one of this account's wallets.
-
has_error boolean
scope=all only: true = payments carrying a last_error_code; false = none.
-
expiring_within integer
scope=all only: capturable holds (authorized, or partially captured with escrow still held) whose authorization_expiry falls within this many seconds from now and has not yet passed.
Responses
- 200 Payments.
- 401 Authentication failed.
curl "https://api.rail0.xyz/payments?status=authorized&sort=-created_at" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments?status=authorized&sort=-created_at", { 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/payments?status=authorized&sort=-created_at")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/payments?status=authorized&sort=-created_at" 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": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "contract_id": "0f1e2d3c-4b5a-4968-8776-655443322110", "chain_id": 84532, "rail0_id": "0x9c2f0d4e7b1a3c5e8f6d2b4a1c3e5f7d9b0a2c4e6f8d1b3a5c7e9f0d2b4a6c8e", "status": "authorized", "mode": "authorize", "amount": "10500000", "capturable_amount": "10500000", "refundable_amount": "10500000", "config_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "decimals": 6, "authorization_expiry": 1791633600, "refund_expiry": 1794225600, "escrow_stranded": false, "escrow_returnable_at": null, "disputed": false, "in_flight": false, "last_error_code": null, "last_error_message": null, "description": "Order #1042", "metadata": { "order_id": "ord_1042" }, "signed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" }]Create a payment
POST /payments
Bearer JWT (Sign-In With Ethereum)
Session required (an account-less token is fine); the payer in the body must equal the authenticated address (403 otherwise) — a buyer only drafts payments payable from its own wallet. payer and payee must be different addresses (a payment to oneself is rejected with 422). Idempotent on the Idempotency-Key header, scoped to the caller: when a payment already exists for the key AND the same payer, that record is returned with 200 instead of 201. Keys are per payer, uniqueness included: a key another payer already used is not shared and does not conflict — this payer's request creates its own payment (201), and never returns or reveals the other one.
Idempotency-Key (optional header) REJECTS a reused key rather than replaying it: the same key with different terms answers 422 idempotency_key_reused, not 200 carrying the first payment — silently returning a payment whose terms are not the ones just sent is how a client charges the wrong amount. Same key + same terms replays with 200 (amounts compare converted, so "1.0" and "1.00" match). A key used by another payer is independent of this payer's: it neither replays nor conflicts. Rotate per logical order.
Headers
-
Idempotency-Key string
Client-supplied key; replays return the existing payment.
Request body
-
chain_id integer required
EVM chain id of the target deployment.
-
mode string requiredOne of:
authorize,charge -
amount string required
Human decimal amount, e.g. "10.50".
-
token string required
Token address (0x, 40 hex).
-
payer string required
Buyer address (0x, 40 hex).
-
payee string required
Merchant address (0x, 40 hex).
-
description string
Free-text note (what is being paid for, a reconciliation reference). Gateway-side only: in nothing signed on chain, editable afterwards via PATCH /payments/{id}, and not an idempotency term.
-
metadata object
Responses
- 200 Existing payment returned (idempotent replay).
- 201 Payment created.
-
422
Validation error.
codeis one ofno_active_contract,unknown_token,unsupported_payment_method(the payee does not offer that token/chain),invalid_amount,idempotency_key_reused(theIdempotency-Keywas already used by this payer for a payment with DIFFERENT terms — retry the original request with that key, or use a new key for the new terms; the response carries the existingpayment_id).
curl -X POST "https://api.rail0.xyz/payments" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H 'Idempotency-Key: ord_1042-create' \ -H "Content-Type: application/json" \ -d '{ "chain_id": 84532, "mode": "authorize", "amount": "10.50", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "metadata": { "order_id": "ord_1042" }}'const res = await fetch("https://api.rail0.xyz/payments", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": "ord_1042-create", }, body: JSON.stringify({ chain_id: 84532, mode: "authorize", amount: "10.50", token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payer: "0x8ba1f109551bD432803012645Ac136ddd64DBA72", payee: "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", metadata: { order_id: "ord_1042" } }),});
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/payments")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Idempotency-Key"] = "ord_1042-create"req["Content-Type"] = "application/json"req.body = { chain_id: 84532, mode: "authorize", amount: "10.50", token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payer: "0x8ba1f109551bD432803012645Ac136ddd64DBA72", payee: "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", metadata: { order_id: "ord_1042" }}.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(`{ "chain_id": 84532, "mode": "authorize", "amount": "10.50", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "metadata": { "order_id": "ord_1042" }}`) url := "https://api.rail0.xyz/payments" 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") req.Header.Set("Idempotency-Key", "ord_1042-create")
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", "contract_id": "0f1e2d3c-4b5a-4968-8776-655443322110", "chain_id": 84532, "rail0_id": "0x9c2f0d4e7b1a3c5e8f6d2b4a1c3e5f7d9b0a2c4e6f8d1b3a5c7e9f0d2b4a6c8e", "status": "authorized", "mode": "authorize", "amount": "10500000", "capturable_amount": "10500000", "refundable_amount": "10500000", "config_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "decimals": 6, "authorization_expiry": 1791633600, "refund_expiry": 1794225600, "escrow_stranded": false, "escrow_returnable_at": null, "disputed": false, "in_flight": false, "last_error_code": null, "last_error_message": null, "description": "Order #1042", "metadata": { "order_id": "ord_1042" }, "signed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z", "rail0_contract": "0xE6E694Dc786Dc8a43152a4547b56A2433e7e9B1c", "transactions": [ { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" } ], "signing_payload": null}Get a payment with embedded transactions and optional signing payload
GET /payments/{id}
Bearer JWT (Sign-In With Ethereum)
Participant-only: readable only by the payment's payer or payee (bearerAuth), not by anyone who learns the id. The payer authenticates with an account-less SIWE token. 401 without a token, 403 for a non-participant. An active admin reads ANY payment through the Full entity: everything a participant reads (chain_id, rail0_contract, the escrow fields, the embedded transactions, and signing_payload while unsigned) plus every column — signatures, broadcast bookkeeping, error diagnostics. The operator's debugging view, a superset of the participant's, so an admin who is party to the payment can act on it.
Path parameters
-
id string (uuid) required
The
:idaccepts either the payment UUID or itsrail0_id(the contract's bytes32 paymentId,0x…), resolved to the UUID internally. This holds for every/payments/{id}/…route.
Responses
- 200 Payment detail.
- 401 Authentication failed.
- 404 Resource not found.
curl "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b" 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": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "contract_id": "0f1e2d3c-4b5a-4968-8776-655443322110", "chain_id": 84532, "rail0_id": "0x9c2f0d4e7b1a3c5e8f6d2b4a1c3e5f7d9b0a2c4e6f8d1b3a5c7e9f0d2b4a6c8e", "status": "authorized", "mode": "authorize", "amount": "10500000", "capturable_amount": "10500000", "refundable_amount": "10500000", "config_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "decimals": 6, "authorization_expiry": 1791633600, "refund_expiry": 1794225600, "escrow_stranded": false, "escrow_returnable_at": null, "disputed": false, "in_flight": false, "last_error_code": null, "last_error_message": null, "description": "Order #1042", "metadata": { "order_id": "ord_1042" }, "signed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z", "rail0_contract": "0xE6E694Dc786Dc8a43152a4547b56A2433e7e9B1c", "transactions": [ { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" } ], "signing_payload": null}Update a payment's description
PATCH /payments/{id}
Bearer JWT (Sign-In With Ethereum)
Participant-only (payer or payee): 401 without a token, 404 for a non-participant, 403 wallet_deactivated from a retired wallet. Allowed in every status, closed ones included. The description is gateway-side only — in nothing signed or hashed on chain — so this moves no money and dispatches no webhook. A changed value bumps updated_at; writing the same value back is a no-op.
Path parameters
-
id string (uuid) required
The
:idaccepts either the payment UUID or itsrail0_id(the contract's bytes32 paymentId,0x…), resolved to the UUID internally. This holds for every/payments/{id}/…route.
Request body
-
description string | null required
New description; null or an empty string clears it.
Responses
- 200 Payment updated.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
- 422 Validation error.
curl -X PATCH "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "description": "Order #1042"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ description: "Order #1042" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b")req = Net::HTTP::Patch.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { description: "Order #1042"}.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(`{ "description": "Order #1042"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b" 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", "contract_id": "0f1e2d3c-4b5a-4968-8776-655443322110", "chain_id": 84532, "rail0_id": "0x9c2f0d4e7b1a3c5e8f6d2b4a1c3e5f7d9b0a2c4e6f8d1b3a5c7e9f0d2b4a6c8e", "status": "authorized", "mode": "authorize", "amount": "10500000", "capturable_amount": "10500000", "refundable_amount": "10500000", "config_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "decimals": 6, "authorization_expiry": 1791633600, "refund_expiry": 1794225600, "escrow_stranded": false, "escrow_returnable_at": null, "disputed": false, "in_flight": false, "last_error_code": null, "last_error_message": null, "description": "Order #1042", "metadata": { "order_id": "ord_1042" }, "signed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z", "rail0_contract": "0xE6E694Dc786Dc8a43152a4547b56A2433e7e9B1c", "transactions": [ { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" } ], "signing_payload": null}Store the payer's signature on a payment
PUT /payments/{id}/sign
Bearer JWT (Sign-In With Ethereum)
Session required (an account-less token is fine) and the caller must be a participant (payer or payee). The signer itself is additionally verified from the recovered signature, not the session party.
Path parameters
-
id string (uuid) required
Request body
-
signature string required
Payer's EIP-3009 signature (0x…, 65 bytes).
Responses
-
200
Signature stored. Returns the updated payment, now in the
signedstate. - 401 Authentication failed.
- 404 Resource not found.
-
422
Payment not signable, signer mismatch, or
invalid_signature: a signature that cannot be recovered, or whose recovery bytevis not 27/28 (0/1 are read as 27/28).
curl -X PUT "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/sign" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "signature": "0x6b1f…9c1b"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/sign", { method: "PUT", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/sign")req = Net::HTTP::Put.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { 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" "os" "strings")
func main() { body := strings.NewReader(`{ "signature": "0x6b1f…9c1b"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/sign" req, err := http.NewRequest(http.MethodPut, 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", "contract_id": "0f1e2d3c-4b5a-4968-8776-655443322110", "chain_id": 84532, "rail0_id": "0x9c2f0d4e7b1a3c5e8f6d2b4a1c3e5f7d9b0a2c4e6f8d1b3a5c7e9f0d2b4a6c8e", "status": "authorized", "mode": "authorize", "amount": "10500000", "capturable_amount": "10500000", "refundable_amount": "10500000", "config_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "payer": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "payee": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "token": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "decimals": 6, "authorization_expiry": 1791633600, "refund_expiry": 1794225600, "escrow_stranded": false, "escrow_returnable_at": null, "disputed": false, "in_flight": false, "last_error_code": null, "last_error_message": null, "description": "Order #1042", "metadata": { "order_id": "ord_1042" }, "signed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z", "rail0_contract": "0xE6E694Dc786Dc8a43152a4547b56A2433e7e9B1c", "transactions": [ { "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" } ], "signing_payload": null}List a payment's transactions
GET /payments/{id}/transactions
Bearer JWT (Sign-In With Ethereum)
Participant-only (bearerAuth): readable only by the payment's payer or payee. 401 without a token, 403 for a non-participant.
Path parameters
-
id string (uuid) required
Query parameters
-
page integer
1-based page number.
-
per_page integer
Items per page (capped at 100).
-
sort string
Comma-separated sort fields; prefix with - for descending (e.g. -created_at,status). An unsupported field is rejected with 422
invalid_sort, whose detail names the allowed set — it is never silently dropped. -
operation stringOne of:
authorize,capture,charge,void,release,refund,dispute,close_dispute -
status array of string
Transaction status(es): one value, a comma-separated list or the repeated
status[]=form; matches any of them.One of:pending,submitting,submitted,confirmed,failed
Responses
- 200 Transactions.
- 401 Authentication failed.
- 404 Resource not found.
curl "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions" 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": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z" }]Fetch one of a payment's transactions
GET /payments/{id}/transactions/{transaction_id}
Bearer JWT (Sign-In With Ethereum)
Reads a single transaction by id - the read the list endpoint could only answer by returning every row. Anything holding a transaction id (the action_id an asynchronous integration is handed when an operation is accepted) resolves it directly, instead of fetching the payment and scanning its transactions for an id it already has. Participant-only (bearerAuth): readable by the payment's payer or payee. A caller who is party to neither gets 404 rather than 403, so the response never confirms the payment exists; an unknown, malformed or foreign transaction id answers 404 the same way.
Path parameters
-
id string (uuid) required
-
transaction_id string (uuid) required
The transaction row to read, resolved through the payment's own transactions - an id belonging to another payment answers 404.
Responses
- 200 The transaction.
- 401 Authentication failed.
- 404 Resource not found.
curl "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b" 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": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Re-enqueue a stuck broadcast for this transaction (payee, or an operator)
POST /payments/{id}/transactions/{transaction_id}/redrive
Bearer JWT (Sign-In With Ethereum)
Payee-gated, OR an operator session on any payment (audited as transaction.redrive). On-demand doorbell for the recovery the Recovery already performs at cadence: a pending transaction still holding its signed_transaction is a broadcast the queue lost (a hard worker kill drops the in-flight job), and this re-enqueues the broadcaster for it NOW instead of waiting for the recovery tick. Same redrivable predicate, same enqueue, no new state and no new worker. Payee-only, like the other operation writes. Idempotent under double-click: the broadcaster re-checks under the row lock before sending, so a duplicate enqueue is harmless and never double-broadcasts. Any other state is refused with 422 not_redrivable, the detail naming the state the row IS in (a pending row without its signed transaction has nothing to broadcast; submitting/submitted already reached the send; confirmed is settled; a failed row's repair path is the operation's /submitted endpoint, not another send).
Path parameters
-
id string (uuid) required
-
transaction_id string (uuid) required
The transaction row to redrive, resolved through the payment's own transactions - an id belonging to another payment answers 404.
Responses
- 202 Accepted; the broadcaster was re-enqueued and the broadcast happens asynchronously.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
The transaction is not in the redrivable state (
pendingwith itssigned_transactionstored). Codenot_redrivable; the detail names the state the row is in. -
503
queue_unavailable: the job queue could not be reached, so nothing was enqueued. The row is unchanged; retry in a moment.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b/redrive" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b/redrive", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b/redrive")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/transactions/7e3f1a2b-6c5d-4e8f-9a0b-1c2d3e4f5a6b/redrive" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Prepare an operation's unsigned transaction
POST /payments/{id}/{operation}/prepare
Bearer JWT (Sign-In With Ethereum)
Builds the unsigned transaction and stores it on a new pending transaction. Session required (bearerAuth) for every operation. The payee operations additionally require the caller to be the payee; release is not payee-gated (it is payer-or-payee on-chain and the payer has no gateway account, so its acting party is authorized on-chain — the signed tx + the contract's NotPayerOrPayee gate) but its prepare is still restricted to a payment participant. Each operation reads only its own body fields: amount for capture and refund (required for both), signature for refund, from for release; authorize, charge and void take none. A field another operation reads is ignored, never an error. refund is two-phase: with no signature it returns { signing_payload } (phase 1, no transaction row created); with signature it creates the pending transaction (phase 2). release is also the one operation with an INVERTED time window: it returns the uncaptured remainder of an authorization, which the contract keeps available to the merchant until the authorization expires, so release opens only AFTER authorization_expiry and answers 422 authorization_not_expired before then (the refusal carries the timestamp). The operation whose card-processing analogue is “cancel the hold early” is void, not release — and void requires the authorization to be fully intact, so any capture rules it out permanently.
Path parameters
-
id string (uuid) required
-
operation string required
Payment operation namespace. These six are the operations reachable through the generic namespace;
dispute/close_disputeare payer-only and have their own dedicated routes. The set of operations a stored transaction can CARRY is wider — seeTransaction.operation.One of:authorize,capture,charge,void,release,refund
Headers
-
Idempotency-Key string
Client-supplied key; a replay returns the transaction the first call created, with 200 instead of 201. Bound to the request as well as the key: the same key with different terms (a corrected amount, another dispute reason) is refused 422
idempotency_key_reusedrather than answered with the first transaction. Scoped to this payment. Without it a retry that arrives after the first transaction was signed and broadcast opens a SECOND transaction, which is the correct behaviour for a genuine sequential partial capture and the wrong one for a retry - only the caller can tell those apart.
Request body (optional)
-
amount string
captureandrefundonly (required there): the amount as a human decimal. Ignored by the other operations. -
signature string
refundonly: the payee's EIP-3009 refund signature (phase 2; 0x + 130 hex). Ignored by the other operations. -
from string
releaseonly: the submitter, the payment's payer or payee (422 release_submitter_not_a_party otherwise). The transaction is built with its nonce. Defaults to the caller. Ignored by the other operations.
Responses
-
200
Reused the existing pending transaction (idempotent re-prepare); or, for refund phase-1, the
{ signing_payload }for the payee to sign off-chain (no transaction created). - 201 Created the pending (unsubmitted) transaction.
-
400
Missing required param (
amountfor capture/refund,signaturefor refund phase-2). - 403 Caller not permitted.
- 404 Resource not found.
- 422 Payment not in a state that permits this operation. Inside the stranded-escrow window (after a partial capture, before authorization_expiry) the void and release refusals each name the other verb and the timestamp when release opens — the codes (not_voidable / authorization_not_expired) are unchanged and remain the field to branch on.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/prepare" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H 'Idempotency-Key: ord_1042-capture-1' \ -H "Content-Type: application/json" \ -d '{ "amount": "10.50"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/prepare", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": "ord_1042-capture-1", }, body: JSON.stringify({ amount: "10.50" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/prepare")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Idempotency-Key"] = "ord_1042-capture-1"req["Content-Type"] = "application/json"req.body = { amount: "10.50"}.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(`{ "amount": "10.50"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/prepare" 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") req.Header.Set("Idempotency-Key", "ord_1042-capture-1")
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", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Submit an operation's signed transaction
POST /payments/{id}/{operation}
Bearer JWT (Sign-In With Ethereum)
Stores the caller's signed raw transaction on the latest pending transaction for this operation and enqueues the broadcaster. Session required (bearerAuth) for every operation. The payee operations additionally require the caller to be the payee; release accepts any session (payer-or-payee submit it). As a robustness check the gateway also recovers the signed tx's sender and requires it to match the party the contract accepts as msg.sender — the payee for payee ops, the payer or payee for release — returning 403 otherwise and 400 if the tx can't be decoded, before broadcasting. Broadcast happens asynchronously.
Path parameters
-
id string (uuid) required
-
operation string required
Payment operation namespace. These six are the operations reachable through the generic namespace;
dispute/close_disputeare payer-only and have their own dedicated routes. The set of operations a stored transaction can CARRY is wider — seeTransaction.operation.One of:authorize,capture,charge,void,release,refund
Request body
-
signed_transaction string required
Signed raw transaction (0x…).
Responses
- 202 Accepted; broadcast enqueued.
- 400 The signed transaction could not be decoded.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
transaction_mismatch: the signed transaction's target, calldata, value or chain differs from the one this operation prepared. Sign the payload prepare returned, unmodified.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "signed_transaction": "0x02f8b1…"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ signed_transaction: "0x02f8b1…" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { signed_transaction: "0x02f8b1…"}.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(`{ "signed_transaction": "0x02f8b1…"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Record an externally-broadcast operation transaction (MetaMask)
POST /payments/{id}/{operation}/submitted
Bearer JWT (Sign-In With Ethereum)
For wallets that sign and broadcast in one step (MetaMask eth_sendTransaction) and so cannot hand the gateway a raw signed_transaction. The caller broadcasts the prepared transaction themselves and reports its hash here; the gateway VERIFIES on-chain that the transaction targets this payment's RAIL0 deployment and carries this payment's id in its calldata (422 foreign_transaction_hash otherwise — a hash no node has seen yet is accepted, since propagation is not instant), then attaches it to the operation's open transaction and moves it straight to submitted, WITHOUT the broadcaster — the indexer then confirms it by hash exactly as for a gateway-broadcast tx. JWT-gated (bearerAuth) since a bare hash carries no signature to authorize it: payee-only for the merchant operations (authorize/capture/charge/void/refund); release is payer-or-payee on-chain so it accepts EITHER participant (a MetaMask buyer can report a payer-side release). The payer operations dispute/close_dispute have their own payer-only /submitted endpoints. Re-callable while the tx is still unconfirmed to OVERWRITE a stuck or wrong hash; a confirmed/failed operation is terminal and returns 422.
Path parameters
-
id string (uuid) required
-
operation string required
Payment operation namespace. These six are the operations reachable through the generic namespace;
dispute/close_disputeare payer-only and have their own dedicated routes. The set of operations a stored transaction can CARRY is wider — seeTransaction.operation.One of:authorize,capture,charge,void,release,refund
Request body
-
transaction_hash string required
Hash of the already-broadcast transaction (0x + 64 hex).
Responses
- 202 Accepted; the hash was recorded and awaits on-chain confirmation.
- 400 Malformed transaction_hash.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
The operation's transaction is already confirmed — its hash is not overwritable. A
failedrow IS overwritable: re-reporting the hash repairs an orphan (mined on-chain, recorded failed) by moving it back tosubmitted.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/submitted" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/submitted", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/submitted")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}.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(`{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/capture/submitted" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Record an externally-broadcast dispute transaction (MetaMask)
POST /payments/{id}/dispute/submitted
Bearer JWT (Sign-In With Ethereum)
The payer's counterpart to /payments/{id}/{operation}/submitted: a MetaMask buyer signs+sends the dispute() tx in one step and reports its hash here. Payer-only (bearerAuth): the bare hash carries no signature, so the SIWE session is the authorization (the payer authenticates with an account-less token). Records the hash on the payment's dispute transaction and moves it to submitted, skipping the broadcaster. Re-callable while unconfirmed; a confirmed/failed tx is terminal (422).
Path parameters
-
id string (uuid) required
Request body
-
transaction_hash string required
Hash of the already-broadcast transaction (0x + 64 hex).
Responses
- 202 Accepted; the hash was recorded and awaits on-chain confirmation.
- 400 Malformed transaction_hash.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
The operation's transaction is already confirmed — its hash is not overwritable. A
failedrow IS overwritable: re-reporting the hash repairs an orphan (mined on-chain, recorded failed) by moving it back tosubmitted.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/submitted" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/submitted", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/submitted")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}.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(`{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/submitted" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Record an externally-broadcast close-dispute transaction (MetaMask)
POST /payments/{id}/dispute/close/submitted
Bearer JWT (Sign-In With Ethereum)
Payer-only report-by-hash for the closeDispute() tx, same contract as /payments/{id}/dispute/submitted. Lets a MetaMask buyer close a dispute it broadcast itself.
Path parameters
-
id string (uuid) required
Request body
-
transaction_hash string required
Hash of the already-broadcast transaction (0x + 64 hex).
Responses
- 202 Accepted; the hash was recorded and awaits on-chain confirmation.
- 400 Malformed transaction_hash.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
The operation's transaction is already confirmed — its hash is not overwritable. A
failedrow IS overwritable: re-reporting the hash repairs an orphan (mined on-chain, recorded failed) by moving it back tosubmitted.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/submitted" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/submitted", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/submitted")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { transaction_hash: "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}.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(`{ "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/submitted" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Prepare the dispute transaction (open; payer-signed)
POST /payments/{id}/dispute/prepare
Bearer JWT (Sign-In With Ethereum)
Builds the unsigned dispute() transaction on a pending transaction for the payer to sign. Signal-only (no fund effect). Session required (an account-less token is fine) and restricted to a payment participant (payer or payee); the acting party (payer) is authorized on-chain, not by the session (the payer has no gateway account) — the payer-signed tx + the contract's NotPayer gate. Submit the signed tx to POST /payments/{id}/dispute.
Path parameters
-
id string (uuid) required
Headers
-
Idempotency-Key string
Client-supplied key; a replay returns the transaction the first call created, with 200 instead of 201. Bound to the request as well as the key: the same key with different terms (a corrected amount, another dispute reason) is refused 422
idempotency_key_reusedrather than answered with the first transaction. Scoped to this payment. Without it a retry that arrives after the first transaction was signed and broadcast opens a SECOND transaction, which is the correct behaviour for a genuine sequential partial capture and the wrong one for a retry - only the caller can tell those apart.
Request body (optional)
-
reason string
Optional. A DisputeOpenReason code (e.g.
not_received), or exactly that code's bytes32 — keccak256("rail0.dispute.") as 0x-hex; the gateway encodes the bytes32 into the calldata. Omitted (or the all-zero bytes32) means no reason: the calldata carries bytes32 zero. Any other value is refused 422unknown_dispute_reason.One of:not_received,not_as_described,damaged_or_defective,duplicate,incorrect_amount,cancelled,refund_not_received,unauthorized,other
Responses
- 200 Reused the existing pending dispute transaction.
- 201 Pending dispute transaction with the unsigned payload.
- 401 Authentication failed.
-
403
not_the_payer: only the payment's payer can prepare this; a payee or another session gets this 403 (a stranger gets 404). - 404 Resource not found.
-
422
unknown_dispute_reasonwhenreasonis supplied, non-zero, and not a DisputeOpenReason code or its bytes32; otherwise the payment-state refusal oridempotency_key_reused.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/prepare" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/prepare", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/prepare")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/prepare" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Submit the signed dispute transaction (open; payer-signed)
POST /payments/{id}/dispute
Bearer JWT (Sign-In With Ethereum)
Stores the payer's signed dispute() transaction and enqueues the broadcaster. Session required (an account-less token is fine); the acting party is authorized on-chain — the gateway recovers the signed tx's sender and requires it to be the payer (403 otherwise, 400 if undecodable), mirroring the contract's NotPayer gate. The disputed flag is set when the indexer reports the on-chain event.
Path parameters
-
id string (uuid) required
Request body
-
signed_transaction string required
Signed raw transaction (0x…).
Responses
- 202 Accepted — broadcast happens asynchronously.
- 400 The signed transaction could not be decoded.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
transaction_mismatch: the signed transaction's target, calldata, value or chain differs from the one this operation prepared. Sign the payload prepare returned, unmodified.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "signed_transaction": "0x02f8b1…"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ signed_transaction: "0x02f8b1…" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { signed_transaction: "0x02f8b1…"}.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(`{ "signed_transaction": "0x02f8b1…"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Prepare the close-dispute transaction (open; payer-signed)
POST /payments/{id}/dispute/close/prepare
Bearer JWT (Sign-In With Ethereum)
Builds the unsigned closeDispute() transaction on a pending transaction for the payer to sign. Session required (an account-less token is fine) and restricted to a payment participant (payer or payee); the acting party (payer) is authorized on-chain, not by the session (the payer has no gateway account) — the payer-signed tx + the contract's NotPayer gate. Submit the signed tx to POST /payments/{id}/dispute/close.
Path parameters
-
id string (uuid) required
Headers
-
Idempotency-Key string
Client-supplied key; a replay returns the transaction the first call created, with 200 instead of 201. Bound to the request as well as the key: the same key with different terms (a corrected amount, another dispute reason) is refused 422
idempotency_key_reusedrather than answered with the first transaction. Scoped to this payment. Without it a retry that arrives after the first transaction was signed and broadcast opens a SECOND transaction, which is the correct behaviour for a genuine sequential partial capture and the wrong one for a retry - only the caller can tell those apart.
Request body (optional)
-
reason string
Optional. A DisputeCloseReason code (e.g.
withdrawn), or exactly that code's bytes32 — keccak256("rail0.dispute.") as 0x-hex; the gateway encodes the bytes32 into the calldata. Omitted (or the all-zero bytes32) means no reason: the calldata carries bytes32 zero.full_refundis recorded by the protocol itself and is not accepted here. Any other value is refused 422unknown_dispute_reason.One of:resolved_with_merchant,withdrawn,item_received,other
Responses
- 200 Reused the existing pending close-dispute transaction.
- 201 Pending close-dispute transaction with the unsigned payload.
- 401 Authentication failed.
-
403
not_the_payer: only the payment's payer can prepare this; a payee or another session gets this 403 (a stranger gets 404). - 404 Resource not found.
-
422
unknown_dispute_reasonwhenreasonis supplied, non-zero, and not a DisputeCloseReason code or its bytes32; otherwise the payment-state refusal oridempotency_key_reused.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/prepare" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/prepare", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/prepare")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close/prepare" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}Submit the signed close-dispute transaction (open; payer-signed)
POST /payments/{id}/dispute/close
Bearer JWT (Sign-In With Ethereum)
Stores the payer's signed closeDispute() transaction and enqueues the broadcaster. Session required (an account-less token is fine); the acting party is authorized on-chain — the gateway recovers the signed tx's sender and requires it to be the payer (403 otherwise, 400 if undecodable), mirroring the contract's NotPayer gate.
Path parameters
-
id string (uuid) required
Request body
-
signed_transaction string required
Signed raw transaction (0x…).
Responses
- 202 Accepted — broadcast happens asynchronously.
- 400 The signed transaction could not be decoded.
- 401 Authentication failed.
- 403 Caller not permitted.
- 404 Resource not found.
-
422
transaction_mismatch: the signed transaction's target, calldata, value or chain differs from the one this operation prepared. Sign the payload prepare returned, unmodified.
curl -X POST "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close" \ -H "Authorization: Bearer $RAIL0_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "signed_transaction": "0x02f8b1…"}'const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAIL0_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ signed_transaction: "0x02f8b1…" }),});
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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close")req = Net::HTTP::Post.new(uri)req["Authorization"] = "Bearer #{ENV.fetch("RAIL0_TOKEN")}"req["Content-Type"] = "application/json"req.body = { signed_transaction: "0x02f8b1…"}.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(`{ "signed_transaction": "0x02f8b1…"}`) url := "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/dispute/close" 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))}{ "id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "operation": "capture", "status": "pending", "redrivable": false, "error_code": null, "error_title": null, "error_detail": null, "unsigned_transaction": null, "transaction_hash": "0x4e3a3754410177e6937ef1f84bba68ea139e8d1a2258c5f85db9f1cd715a1bdd", "sender": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "amount": "10500000", "block_number": null, "gas_used": null, "gas_limit": null, "effective_gas_price": null, "base_fee_per_gas": null, "gas_cost": null, "pending_at": null, "submitted_at": null, "confirmed_at": null, "created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}List a payment's disputes
GET /payments/{id}/disputes
Bearer JWT (Sign-In With Ethereum)
Participant-only (bearerAuth): readable only by the payment's payer or payee. 401 without a token, 403 for a non-participant.
Path parameters
-
id string (uuid) required
Query parameters
-
page integer
1-based page number.
-
per_page integer
Items per page (capped at 100).
-
sort string
Comma-separated sort fields; prefix with - for descending (e.g. -created_at,status). An unsupported field is rejected with 422
invalid_sort, whose detail names the allowed set — it is never silently dropped. -
status string
Filter by dispute status.
One of:open,closed
Responses
- 200 Dispute open/close history.
- 401 Authentication failed.
- 404 Resource not found.
curl "https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/disputes" \ -H "Authorization: Bearer $RAIL0_TOKEN"const res = await fetch("https://api.rail0.xyz/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/disputes", { 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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/disputes")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/payments/5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b/disputes" 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": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "payment_id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b", "status": "open", "reason": "string", "reason_code": "not_received", "reason_description": "string", "opened_block": null, "opened_at": "2026-10-01T12:00:00Z", "closed_by": "payer", "close_reason": null, "close_reason_code": "resolved_with_merchant", "close_reason_description": null, "closed_block": null, "closed_at": null }]