Resources · Documentation

Custody, as an API.

Everything the holdway dashboard does, your systems can do over REST. Balances, transfers, statements, and policy — with webhooks for everything that changes state.

Introduction

The holdway API is a REST API served from https://api.holdway.xyz/v1. Requests and responses are JSON. Every resource you see in the dashboard — balances, transfers, statements, policies — is an API object with a stable identifier, and every state change emits a signed webhook.

Two things distinguish this API from a typical payments API, and both are deliberate. First, writes are policy-gated: creating a transfer does not move funds; it opens a request that your organization's approval policy must satisfy before signing occurs. Second, reads are attestable: balance and statement data reconciles against on-chain proof-of-reserves attestations, which you can consume programmatically. The reporting page describes the export formats built on these endpoints.

Conventions

  • Object identifiers are prefixed by type: tr_ transfers, pol_ policies, stmt_ statements, att_ attestations, evt_ webhook events. An identifier is never reused, even after deletion.
  • The API is versioned in the path; /v1 is current. Breaking changes ship only in a new path version. Additive changes — new fields, new event types — can appear in /v1 at any time, so parse responses tolerantly.
  • List endpoints paginate with limit (default 25, max 100) and a starting_after cursor; responses include has_more.
  • All timestamps are ISO 8601 in UTC. All amounts are strings.
The API is available to all custody clients at no additional cost. Request sandbox credentials from your onboarding contact or support@holdway.xyz.

Authentication

Every request authenticates with a bearer token: put your API key on the Authorization: Bearer line. Keys are issued per environment: sandbox keys are prefixed hk_test_ and live keys hk_live_. A request signed with the wrong environment's key returns 401 — the two environments share nothing, including key material.

Keys are scoped per policy, not per organization. A key created under a read-only policy can call GET endpoints and nothing else; a key created under a treasury policy can open transfer requests but can never bypass the quorum attached to that policy. Compromising an API key therefore never compromises funds — the worst a stolen write key can do is create a pending request your approvers will see and reject.

Authenticated request
# Sandbox: use your hk_test_ key against the same base URL
curl https://api.holdway.xyz/v1/balances \
  -H "Authorization: Bearer hk_test_9f2c..."

Rotate keys from the dashboard at any time; old keys keep working for a 24-hour overlap window so deployed systems can roll without downtime. Suspected compromise? Revoke immediately from the dashboard, or email security@holdway.xyz.

Environments

Sandbox and live are the same codebase behind the same base URL, distinguished only by key prefix. Sandbox custody accounts hold testnet ETH and BTC and test-network USDC, run the same policy engine with the same states and the same webhook events, and enforce the same rate limits. The one intentional difference: sandbox transfers auto-confirm after a short delay instead of waiting for real block confirmations, so integration tests complete in seconds.

What carries over from sandbox to live, and what does not:

  • Identical: endpoints, request and response shapes, policy engine behavior, transfer states, webhook events and signatures, error codes, rate limits.
  • Sandbox only: testnet assets, auto-confirmation of broadcast transfers, and a dashboard control to approve as any test quorum member so one developer can exercise a 3-of-5 policy alone.
  • Live only: real proof-of-reserves attestations. Sandbox publishes placeholder attestation objects with the same shape but no on-chain backing.

We recommend exercising your full approval flow — including a rejection — in sandbox before requesting live keys. Platform availability for both environments is published on the status page.

Balances

GET /v1/balances returns current holdings across your segregated wallets, broken out by asset and storage tier. Amounts are strings in the asset's smallest conventional unit representation to avoid floating-point loss. Each response carries the identifier of the most recent proof-of-reserves attestation covering these balances.

GET /v1/balances
{
  "object": "list",
  "attestation": "att_2026_07_01_0400Z",
  "data": [
    { "asset": "ETH",  "tier": "cold", "amount": "412.5031" },
    { "asset": "BTC",  "tier": "cold", "amount": "36.2210" },
    { "asset": "USDC", "tier": "warm", "amount": "1250000.00" }
  ]
}

Transfers

POST /v1/transfers opens a transfer request. The request is evaluated against your policy at creation time: destination checked against allow-lists, amount checked against limits, time-locks applied. If evaluation passes, the transfer enters pending_approval and your quorum is notified. Funds do not move until enough approvers sign off.

POST /v1/transfers
curl https://api.holdway.xyz/v1/transfers \
  -H "Authorization: Bearer hk_live_..." \
  -H "Idempotency-Key: treasury-2026-07-02-001" \
  -d asset=ETH \
  -d amount="25.0" \
  -d destination="0x4bd1...a9f0"

# 201 Created
{
  "id": "tr_8m2kq",
  "state": "pending_approval",
  "policy": "pol_treasury",
  "approvals": { "required": 3, "received": 0 }
}

A transfer moves through five states, in order: pending_approvalapprovedsigningbroadcastconfirmed. It can terminate early as rejected (an approver declined) or expired (the approval window lapsed). Each transition emits a webhook, and GET /v1/transfers/:id returns the full transition history with timestamps and approver identities.

Statements

GET /v1/statements lists finalized monthly statements; GET /v1/statements/:id returns download URLs for PDF and CSV renditions. Statements are immutable once issued — a correction produces a new statement version that references the original rather than silently replacing it. Fields, reconciliation methodology, and audit-package formats are documented on the reporting page.

Statements for a closed month are available by 09:00 UTC on the first business day of the following month; the statement.available webhook fires when yours is ready, so polling is unnecessary.

Policies

Policies are first-class API objects. A policy binds together the controls described on the security page — approval quorums, destination allow-lists, and time-locks — and every API key and transfer request belongs to exactly one.

GET /v1/policies and GET /v1/policies/:id are read-only over the API by design: a policy object shows its quorum (for example 3-of-5), its allow-listed destinations, and its time-lock windows, but changing any of those requires the change-control flow in the dashboard, which is itself quorum-gated. There is no API call that loosens a policy. This asymmetry is intentional — automation can read the rules and operate within them, but only humans in quorum can rewrite them.

Reading a transfer's policy field and resolving it via GET /v1/policies/:id tells you exactly why a transfer is waiting and who can advance it.

Webhooks: signed deliveries

Register up to 16 HTTPS endpoints per environment from the dashboard. Every delivery is signed: the Holdway-Signature header carries an HMAC-SHA256 of the timestamped payload, computed with your endpoint's secret. Verify the signature and reject deliveries whose timestamp is more than five minutes old — this closes off both forgery and replay. Endpoint secrets are shown once at creation and can be rotated with the same 24-hour overlap window as API keys.

Webhook delivery: transfer.confirmed
POST /your/endpoint HTTP/1.1
Holdway-Signature: t=1751414400,v1=5f8a...
Content-Type: application/json

{
  "event": "transfer.confirmed",
  "created": "2026-07-02T00:00:00Z",
  "data": {
    "id": "tr_8m2kq",
    "state": "confirmed",
    "tx_hash": "0x93c1...77de"
  }
}

Event types

The events most integrations subscribe to:

  • transfer.pending_approval — a transfer request passed policy evaluation and is awaiting quorum. Useful for paging approvers through your own channels.
  • transfer.confirmed — the transaction reached its confirmation threshold on-chain; the payload includes the transaction hash.
  • reserves.attested — a new proof-of-reserves attestation was published; the payload carries the attestation identifier referenced by subsequent balance reads.
  • statement.available — a monthly statement was finalized and its downloads are ready.

Intermediate transfer transitions (approved, signing, broadcast, rejected, expired) are also delivered if your endpoint subscribes to the full transfer.* group.

Retries

Your endpoint must respond with a 2xx within 5 seconds; anything else — timeout, 4xx, 5xx, connection failure — is treated as a failed delivery. Failed deliveries are re-attempted on a widening schedule — after about a minute, then five, then thirty, then hourly — until 72 hours have passed. Deliveries can arrive out of order after a retry window, so treat the created timestamp, not arrival order, as authoritative. Every delivery attempt is inspectable and manually re-sendable from the dashboard.

Errors

The API uses conventional HTTP status codes: 2xx success, 4xx a problem with the request, 5xx a problem on our side. Error responses carry a single error object:

Error object
# 403 Forbidden
{
  "error": {
    "type": "policy_violation",
    "code": "destination_not_allowlisted",
    "message": "Destination is not on the allow-list
                for policy pol_treasury.",
    "request_id": "req_c81xw"
  }
}

The codes you will see most: 401 bad or wrong-environment key, 403 policy violation, 404 unknown object, 409 idempotency conflict, 422 validation failure, 429 rate limited. Include the request_id when contacting support@holdway.xyz — it links directly to our request logs.

Rate limits

Limits are per key: 300 read requests and 60 write requests per minute, enforced with a sliding window. Exceeding a limit returns 429 with a Retry-After header stating when capacity frees up. These limits are deliberately generous for custody workloads — balances change on human timescales, and webhooks exist precisely so you never need to poll tightly. If a batch operation genuinely needs more, ask support; limits are configurable per key.

Idempotency

All POST requests accept an Idempotency-Key header — any string up to 255 characters that is unique per logical operation. When a key is reused, holdway replays the stored response from the first attempt rather than executing the operation twice; reusing a key with a changed payload is rejected with 409. Stored keys expire after 24 hours.

For transfers, idempotency keys are strongly recommended: a network timeout on POST /v1/transfers without one leaves you unsure whether an approval request now exists. With one, retrying is always safe. (Even without, policy evaluation and quorum approval mean a duplicate request results in a duplicate pending approval, not a duplicate payment — but your approvers will thank you.)

Build against custody you can prove.