Back to documentation

Balance and transfer API reference

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.

Success
{
  "code": "ok",
  "message": "success",
  "data": {}
}
Error
{
  "code": "insufficient_balance",
  "message": "insufficient balance",
  "data": null
}
POST
/v1/transferswrite

Move points, credits, or coins

Submit one or more source entries and destination entries. The totals must balance for each asset.

Request body

Transfer request
{
  "transfer_id": "order_123",
  "scene": "spend",
  "background_completion": true,
  "meta": { "order_id": "order_123" },
  "from_accounts": [{
    "account_id": "buyer_42",
    "asset": "points",
    "amount": "100",
    "action": "spend",
    "allow_overdraft": true
  }],
  "to_accounts": [
    { "account_id": "seller_7", "asset": "points", "amount": "90", "action": "spend" },
    { "account_id": "-2", "asset": "points", "amount": "10", "action": "spend" }
  ]
}

Success

Response
{
  "code": "ok",
  "message": "success",
  "data": null
}

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 request
{
  "transfer_id": "order_123",
  "scene": "spend"
}
Success response
{
  "code": "ok",
  "message": "success",
  "data": null
}

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.

Response
{
  "code": "ok",
  "message": "success",
  "data": {
    "transfer_id": "order_123",
    "scene": "spend",
    "from_accounts": [
      { "account_id": "buyer_42", "asset": "points", "amount": "100", "action": "spend", "allow_overdraft": true }
    ],
    "to_accounts": [
      { "account_id": "seller_7", "asset": "points", "amount": "90", "action": "spend" },
      { "account_id": "-2", "asset": "points", "amount": "10", "action": "spend" }
    ],
    "status": "succeeded",
    "meta": { "order_id": "order_123" },
    "created_at": 1784188800,
    "updated_at": 1784188801
  }
}
processing
Source or destination work is in progress
reversing
Recorded work is being reversed
completing
Source is durable; destination continues
succeeded
All entries are durable
reversed
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.

All-assets response
{
  "code": "ok",
  "message": "success",
  "data": {
    "points": {
      "current": "40",
      "future": "80",
      "permanent": "40",
      "batches": [
        { "amount": "80", "start_at": 1798761600, "expire_at": 0, "active": false }
      ]
    }
  }
}
Single-asset response
{
  "code": "ok",
  "message": "success",
  "data": {
    "current": "40",
    "future": "80",
    "permanent": "40",
    "batches": [
      { "amount": "80", "start_at": 1798761600, "expire_at": 0, "active": false }
    ]
  }
}

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.

Request body
{
  "account_ids": ["member_42", "member_43"],
  "assets": ["points", "credits"]
}
Response
{
  "code": "ok",
  "message": "success",
  "data": {
    "member_42": {
      "points": {
        "current": "100",
        "future": "20",
        "permanent": "80",
        "batches": [
          { "amount": "20", "start_at": 1798761600, "expire_at": 0, "active": false }
        ]
      }
    }
  }
}

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.

GET
/v1/accounts/seller_7/records?page=1&page_size=20read

List records for one account

Use the primary activity endpoint for one application account, newest first, without scanning unrelated ledger records.

Paged response
{
  "code": "ok",
  "message": "success",
  "data": [{
    "account_id": "seller_7",
    "transfer_id": "order_123",
    "scene": "spend",
    "direction": "credit",
    "transfer_status": "posted",
    "amount": "90",
    "asset": "points",
    "action": "spend",
    "meta": { "order_id": "order_123" },
    "created_at": 1784188800,
    "updated_at": 1784188800
  }],
  "total": 1,
  "page": 1,
  "page_size": 20
}

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.

Response
{
  "code": "ok",
  "message": "success",
  "data": {
    "actions": {
      "top_up": "Top up",
      "spend": "Spend",
      "refund": ""
    },
    "scenes": {
      "top_up": "Top up",
      "spend": "Spend",
      "promotion": ""
    },
    "assets": {
      "points": "Points",
      "credits": ""
    },
    "platform_accounts": [
      { "account_id": "-1", "name": "Top-up account", "length": 1, "status": "active" },
      { "account_id": "-2", "name": "Spending account", "length": 1, "status": "active" }
    ]
  }
}

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.

PlanMonthly transfersEntries / transferSustained API requests / minImmediate burst
Free5,000136010
Basic50,0002030030
Pro500,000501,20080
Max5,000,0001006,000200

Control-plane creation limits

These limits apply when creating configuration. They do not invalidate existing resources after a downgrade.

PlanLedgers / workspaceAdditional assets / ledgerAdditional platform account definitions / ledger
Free223
Basic5510
Pro201025
Max502050
EnterpriseCustomCustomCustom

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.

CodeClassRetry ruleMeaningCaller action
insufficient_balanceTerminal failureNew IDInsufficient balanceThis operation pair cannot later succeed. Fix the balance or overdraft policy and submit the replacement with a new transfer ID.
amount_out_of_rangeTerminal failureNew IDExecution-stage amount range failureWhen 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_reversedPair unavailableNew IDAlready reversed or reversal tombstoneThis ID-and-scene pair is sealed. Use a new transfer ID for a replacement operation.
idempotency_conflictPair unavailableNew IDIdempotency payload conflictThis pair belongs to a different payload, which may already be successful. Use a new transfer ID for the current intent.
transfer_state_conflictUncertainSame ID + exact payloadTransfer state changedDirectly retry the exact original payload with the same ID and scene; do not create a new ID.
internal_errorUncertainSame ID + exact payloadInternal failureDirectly retry the exact same complete business payload with the same ID and scene; do not create a new ID.
service_unavailableUncertainSame ID + exact payloadService temporarily unavailableBack off, then directly retry the exact payload with the same ID and scene.
invalid_requestPre-executionOriginal IDInvalid requestCorrect the IDs, vocabulary, query, entry count, or field values. A valid original transfer ID remains available.
invalid_request_bodyPre-executionOriginal IDMalformed HTTP request bodySend one valid JSON value with Content-Type: application/json and no unknown fields; reuse the original ID if it was valid.
unauthenticatedPre-executionOriginal IDInvalid or revoked API keyReplace the credential, then retry the original transfer identity.
permission_deniedPre-executionOriginal IDAPI key scope deniedUse a key with write scope, then retry the original transfer identity.
route_not_foundPre-executionOriginal IDAPI route not foundUse the documented /v1 path; no transfer identity was consumed.
method_not_allowedPre-executionOriginal IDHTTP method not allowedUse the documented POST method; no transfer identity was consumed.
resource_not_foundPre-executionOriginal IDResource not foundCorrect the requested resource identity. This response does not seal a new transfer.
conflictPre-executionOriginal IDNon-transfer resource conflictCorrect the current resource state. This generic response does not seal a new transfer.
ledger_not_foundPre-executionOriginal IDLedger not foundRestore or select the intended active ledger, then retry the original transfer identity.
transfer_not_foundPre-executionOriginal IDTransfer not foundThis read result does not consume an ID. If retrying a create, send the exact original request under its original ID and scene.
account_not_foundPre-executionOriginal IDAccount balance not foundCorrect the account ID or asset. Batch reads omit requested account IDs with no matching stored account-asset records.
platform_account_not_foundPre-executionOriginal IDPlatform account not foundUse a configured platform account, then retry with the original transfer ID.
platform_account_disabledPre-executionOriginal IDPlatform account disabledEnable or choose an active platform account, then retry with the original transfer ID.
invalid_time_boundaryPre-executionOriginal IDInvalid time boundaryCorrect the Unix-second boundaries, then retry with the original transfer ID.
rate_limit_exceededPre-executionOriginal IDWorkspace rate limit exceededBack off with jitter, then retry the original ID and payload.
quota_exceededPre-executionOriginal IDWorkspace quota exceededWait for quota or upgrade, then retry the original ID and payload.
billing_suspendedPre-executionOriginal IDBilling suspendedRestore billing, then retry the original ID and payload.
payload_too_largePre-executionOriginal IDRequest body too largeReduce 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.

Read quickstart