Issue, spend, split, inspect, and reverse points, credits, coins, or other app balances with a ledger-scoped service key.
Base URL:https://api.xferapi.com/v1. Your API key already belongs to one ledger and selects that isolated balance space. Never send a ledger ID in the URL or request body.
For code generation and validation, use the public OpenAPI 3.1 JSON .
Authentication
Send the complete secret from a backend service. Use read for GET endpoints and write for transfers and reversals; a key may carry both.
Authorization header
Authorization: Bearer xfer_sk_<key_id>_<secret>
Keep keys out of browser bundles, mobile apps, source control, and logs.
Response envelope
Business outcomes return HTTP 200 with a stable JSON envelope, including validation, balance, idempotency, and state conflicts. Authentication, malformed HTTP, payload limits, and infrastructure failures may use a non-2xx transport status. Always treat code, not HTTP alone, as the application result. data is present and null for errors or successful operations with no response payload. Every public timestamp uses Unix seconds.
The caller already owns transfer_id, so a successful create response does not repeat it. Persist the ID and scene before sending and use them for reads and exact retries.
IDs and vocabulary
transfer_id uses 1–64 case-sensitive ASCII letters or numbers with single - or _ separators: ^[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*$. Dots, tildes, spaces, and repeated separators are rejected. Prefixes such as adj_ and sys_ are generation conventions only; they do not change permission, ownership, visibility, lookup, create, or reversal behavior.
account_id uses 1–64 URL-safe ASCII characters: letters, numbers, -, ., _, or ~. The exact system account ID 0 is the only otherwise-valid account ID unavailable as public input; it may appear in XferAPI-generated expiration history. Any negative ID must be a canonical signed-64-bit decimal platform account ID, such as -2 rather than -02.
Scene, asset, and action codes match ^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$ (1–32 lowercase snake_case characters; uppercase letters and hyphens are not allowed) and must already be configured on the ledger for Transfer creation. The one additional write restriction is the exact scene sys_expire, which is reserved for XferAPI-generated expiration recycling; this restriction does not apply to an action or asset with the same text.
Transfer ID plus scene is the idempotency boundary. Reuse the pair only for an exact retry of the same request.
Amounts and entries
amount is a positive decimal JSON string. Each item and each side's per-asset total must not exceed 9223372036854775807.
The debit and credit totals must be equal for every asset.
Ordinary source accounts require sufficient available balance by default. Set allow_overdraft: true on a from_accounts item to permit that debit to leave a negative balance. Platform accounts always allow overdraft.
All source items for the same ordinary (account_id, asset) pair must use the same allow_overdraft value. The field is ignored on to_accounts items and omitted from their durable state.
Each (account_id, asset, action) tuple is unique across both arrays.
Both arrays are required and non-empty. The combined entry limit is plan-based: Free 13, Basic 20, Pro 50, and Max 100. Contact sales workspaces are configured separately. Free is sized for one source plus twelve monthly allocations.
Activation and expiration
Only ordinary destination entries support start_at and expire_at; debit entries and platform account destinations do not.
Time values are non-negative Unix seconds. Omit them or use 0 for immediate activation or no expiration.
The server's clock decides whether expire_at is in the future. It must also be strictly later than a non-zero start_at.
JSON envelope
meta is optional; omit it, send null, or send an empty object when no extra data is needed. Empty meta fields are omitted from responses. A non-empty value must be a JSON object. Top-level meta is limited to 16 KiB; all account-item meta objects combined are limited to 64 KiB.
Unknown JSON fields and a second top-level JSON value are rejected.
The complete HTTP request body is limited to 1 MiB; an oversized body returns payload_too_large.
The expire_at value is an exclusive boundary. With background_completion enabled, durable source work may finish before the destination side. A network timeout is uncertain: directly retry the exact original request with the same ID and scene.
POST
/v1/reversalswrite
Reverse a transfer
Reverse the complete operation identified by a transfer ID and scene.
Reversal is an entire-transfer operation. XferAPI applies inverse balance changes and moves the existing records to their reversal state. Repeating the same reversal is idempotent.
Use any syntactically valid transfer ID. Prefixes such as adj_ and sys_ carry no special semantics. Reversal validates lowercase snake_case scene syntax without requiring the scene to remain in the current configuration, but the exact XferAPI-managed scene sys_expire cannot be submitted.
A reversal may arrive before the matching transfer. In that case XferAPI records an empty reversal tombstone, and a later transfer with the same ID and scene is not allowed to execute.
GET
/v1/transfers/order_123?scene=spendread
Retrieve a transfer
Retrieve one transfer by its ID and scene. Scene is required because the pair is the idempotency boundary within the key's ledger.
Terminal reversal, failed-transfer compensation, or tombstone
GET
/v1/transfers?page=1&page_size=20read
List transfers
List transfer operations across the selected ledger, newest first, with pagination and filters.
Filter by transfer_id, scene, status, or an inclusive updated_at time window. The list includes XferAPI-generated expiration recycling transfers with scene sys_expire; that scene is also valid in read filters. start_time and end_time are Unix seconds, and the complete end_time second is included. page defaults to 1, page_size defaults to 20 and accepts 1–100, and the response includes top-level total, page, and page_size. Results use a stable newest-first order with a deterministic tie-breaker.
If a transfer needs continuation
A transfer in Processing or Completing is durable. XferAPI's inspection worker examines stale intermediate transfers and follows the valid completion or reversal path independently of the original HTTP request.
GET
/v1/accounts/member_42read
Read an account balance
Return an asset-keyed balance map containing available, future, permanent, and unexpired batch values.
The all-assets route returns data as an object keyed by asset. Use /v1/accounts/member_42/assets/points when you need one asset only; its data is the balance value directly and does not repeat asset. Every stored account-asset record remains present even when all balance totals are zero. Object property order is not contractual. Amounts are signed 64-bit integers encoded as decimal strings, so JavaScript callers can convert them directly to BigInt without losing precision.
For each ordinary source account and asset, XferAPI selects spendable value at the single server time captured for the Transfer. Batches that have not started or have reached their exclusive expire_at boundary are not spendable. Among active batches with an expiration, XferAPI spends the earliest expire_at first, then uses permanent balance. A source entry cannot select a specific batch, and relative order among batches with the same expire_at is not part of the API contract.
POST
/v1/accounts/batchread
Read balances in one bounded batch
Read all stored assets or an explicit asset subset for up to 100 account IDs without constructing a long URL or paging through unrelated accounts.
The response data is keyed first by account ID and then by asset, using the same balance value as the individual reads without repeated account_id or asset fields. account_ids is required and accepts 1–100 unique valid IDs. assets is optional: omit it to read every stored asset, or provide 1–100 unique lowercase vocabulary codes to return only matching rows. Requested accounts with no matching stored account-asset record are omitted instead of receiving an empty object. A matching stored record remains present even when all balance totals are zero. JSON object property order is not contractual. Empty arrays, excess items, duplicates, or invalid values return invalid_request; malformed JSON, assets: null, or unknown fields return invalid_request_body.
Other balance endpoints
Use the narrowest balance read that matches the application screen or backend job. List responses use the same stable newest-first pagination contract.
GET/v1/accounts/{account_id}/assets/{asset}
Read one asset balance and its active or future time batches for one account.
GET/v1/accounts?page=1&page_size=20
List one balance summary per account and asset pair across the selected ledger.
direction is credit or debit. transfer_status is posted, reversed, or empty_reversal. Filter by transfer_id, status, asset, scene, direction, or action. Pagination defaults to page 1 with 20 items, accepts 1–100 items per page, and returns invalid_request for an invalid page value.
GET
/v1/records?page=1&page_size=20read
List ledger-wide records
Read the selected ledger's complete record feed for audit, reconciliation, operations, or support workflows.
This endpoint returns the same record shape and filter vocabulary as account activity, but spans every account in the ledger. It includes XferAPI-generated expiration recycling records with scene sys_expire, including the system account 0 side of that movement; these are normal durable business-history entries, not hidden maintenance rows. Prefer /v1/accounts/{account_id}/records for a user or account history view; use the ledger-wide feed only when the workflow genuinely needs cross-account records.
GET
/v1/metaread
Read ledger configuration
Discover the configured vocabulary and platform account ranges after the core transfer, balance, and record endpoints.
actions, scenes, and assets are JSON objects whose property names are configured codes and whose values are display descriptions. This supports direct lookup such as data.actions[action]. A configured code with no description uses an empty string, for example "refund": ""; a vocabulary with no entries is {}.
JSON object property order is not part of the API contract. Do not infer Console configuration order or business priority by iterating these objects; sort codes explicitly when presentation order matters. Transfers may use only codes already configured on the selected ledger. The generated sys_expire scene is intentionally not user-configurable and does not need to appear in this map, while Transfer and Record reads still return and filter it normally.
platform_accounts remains a list because every item defines a negative-ID range with fields such as account_id, length, and status. The inclusive range is [account_id - length + 1, account_id], and length must be an integer from 1 through 20. Sending the entry ID lets XferAPI select a deterministic bucket; sending another ID inside the configured range uses that exact bucket. Disabled ranges remain visible for interpreting history but reject new transfer entries.
Workspace limits
Monthly transfer quota, sustained API rate, immediate burst, and transfer shape are separate limits. Contact sales workspaces use an agreed configuration.
Plan
Monthly transfers
Entries / transfer
Sustained API requests / min
Immediate burst
Free
5,000
13
60
10
Basic
50,000
20
300
30
Pro
500,000
50
1,200
80
Max
5,000,000
100
6,000
200
Control-plane creation limits
These limits apply when creating configuration. They do not invalidate existing resources after a downgrade.
Plan
Ledgers / workspace
Additional assets / ledger
Additional platform account definitions / ledger
Free
2
2
3
Basic
5
5
10
Pro
20
10
25
Max
50
20
50
Enterprise
Custom
Custom
Custom
Defaults, ranges, and downgrades
Every ledger counts toward the workspace limit, including a disabled ledger.
The exact built-in points asset does not consume an additional-asset slot. A ledger without points receives no hidden free slot.
The built-in -1 and -2 platform account definitions do not consume additional-definition slots. System account 0 is separate and is not a platform account.
Each additional platform account range counts once regardless of status or range length. Every range must contain 1–20 IDs.
A downgrade preserves existing workspaces, ledgers, assets, platform account definitions, and full ranges. Transfers, reads, description/order edits, enable, disable, and re-enable remain available. Only another creation is blocked while its current-plan limit is reached.
A user can create at most two Free workspaces they own. Member workspaces do not count, and existing downgraded workspaces remain operational even if the owner is then above two.
Actions and scenes retain their global vocabulary rules and are not tiered by plan.
What monthly quota counts
Each newly completed ordinary transfer or Console balance adjustment counts once. A successful idempotent retry does not count again. Validation failures, insufficient-balance failures, reversals, reads, and XferAPI-managed expiration work do not consume monthly quota.
How rate protection works
Every authorized API-key /v1 request consumes one token, whether it is a read, write, batch, success, or business failure. All keys and Ledgers in a Workspace share the bucket; Console transfers, adjustments, and reversals share it too. Tokens refill continuously at the sustained rate until the burst capacity is full. After rate_limit_exceeded, retry with exponential backoff and jitter. Requests rejected before API-key authorization sit outside this bucket and should be protected at the ingress.
The first-version token bucket is process-local and is intended for one public API instance. If you run multiple API replicas, put the rate check at a shared gateway or use a shared Redis bucket before presenting the plan value as one global Workspace limit.
The entry limit is the combined length of from_accounts and to_accounts. Billing suspension blocks new transfers while authenticated reads and reversals remain available for recovery and support.
Errors to handle in your app
Branch on the stable string code: only ok is success, and every error envelope keeps data: null. Business failures use HTTP 200; authentication, malformed-body, route, method, payload-limit, and infrastructure codes carry their relevant protocol or infrastructure status. A connection failure, timeout, invalid JSON response, or missing envelope is Uncertain.
Terminal failure
New transfer ID
The transaction was durably rejected and the original pair cannot later succeed.
Pair unavailable
New transfer ID
The pair is sealed by reversal or already belongs to another payload.
Uncertain
Same ID + exact payload
Retry the original create directly. Do not issue a replacement ID or require a read first.
Pre-execution
Original ID after correction
Execution did not begin. Resolve the gate or request problem, then reuse the original ID.
Code
Class
Retry rule
Meaning
Caller action
insufficient_balance
Terminal failure
New ID
Insufficient balance
This operation pair cannot later succeed. Fix the balance or overdraft policy and submit the replacement with a new transfer ID.
amount_out_of_range
Terminal failure
New ID
Execution-stage amount range failure
When POST /v1/transfers returns this after execution begins, the pair cannot later succeed. Correct the amount or account capacity and use a new transfer ID.
transfer_already_reversed
Pair unavailable
New ID
Already reversed or reversal tombstone
This ID-and-scene pair is sealed. Use a new transfer ID for a replacement operation.
idempotency_conflict
Pair unavailable
New ID
Idempotency payload conflict
This pair belongs to a different payload, which may already be successful. Use a new transfer ID for the current intent.
transfer_state_conflict
Uncertain
Same ID + exact payload
Transfer state changed
Directly retry the exact original payload with the same ID and scene; do not create a new ID.
internal_error
Uncertain
Same ID + exact payload
Internal failure
Directly retry the exact same complete business payload with the same ID and scene; do not create a new ID.
service_unavailable
Uncertain
Same ID + exact payload
Service temporarily unavailable
Back off, then directly retry the exact payload with the same ID and scene.
invalid_request
Pre-execution
Original ID
Invalid request
Correct the IDs, vocabulary, query, entry count, or field values. A valid original transfer ID remains available.
invalid_request_body
Pre-execution
Original ID
Malformed HTTP request body
Send one valid JSON value with Content-Type: application/json and no unknown fields; reuse the original ID if it was valid.
unauthenticated
Pre-execution
Original ID
Invalid or revoked API key
Replace the credential, then retry the original transfer identity.
permission_denied
Pre-execution
Original ID
API key scope denied
Use a key with write scope, then retry the original transfer identity.
route_not_found
Pre-execution
Original ID
API route not found
Use the documented /v1 path; no transfer identity was consumed.
method_not_allowed
Pre-execution
Original ID
HTTP method not allowed
Use the documented POST method; no transfer identity was consumed.
resource_not_found
Pre-execution
Original ID
Resource not found
Correct the requested resource identity. This response does not seal a new transfer.
conflict
Pre-execution
Original ID
Non-transfer resource conflict
Correct the current resource state. This generic response does not seal a new transfer.
ledger_not_found
Pre-execution
Original ID
Ledger not found
Restore or select the intended active ledger, then retry the original transfer identity.
transfer_not_found
Pre-execution
Original ID
Transfer not found
This read result does not consume an ID. If retrying a create, send the exact original request under its original ID and scene.
account_not_found
Pre-execution
Original ID
Account balance not found
Correct the account ID or asset. Batch reads omit requested account IDs with no matching stored account-asset records.
platform_account_not_found
Pre-execution
Original ID
Platform account not found
Use a configured platform account, then retry with the original transfer ID.
platform_account_disabled
Pre-execution
Original ID
Platform account disabled
Enable or choose an active platform account, then retry with the original transfer ID.
invalid_time_boundary
Pre-execution
Original ID
Invalid time boundary
Correct the Unix-second boundaries, then retry with the original transfer ID.
rate_limit_exceeded
Pre-execution
Original ID
Workspace rate limit exceeded
Back off with jitter, then retry the original ID and payload.
quota_exceeded
Pre-execution
Original ID
Workspace quota exceeded
Wait for quota or upgrade, then retry the original ID and payload.
billing_suspended
Pre-execution
Original ID
Billing suspended
Restore billing, then retry the original ID and payload.
payload_too_large
Pre-execution
Original ID
Request body too large
Reduce the JSON body to 1 MiB or less, then retry with the original transfer ID.
Caller-supplied request IDs are ignored. Use the response X-Request-ID as the server-authoritative correlation value, and never include the API key or complete request body in support messages.
Need a concrete integration path?
Start with the quickstart, then use the console to create a scoped key and inspect the first balance transfer.