Resources · Documentation
Everything the holdway dashboard does, your systems can do over REST. Balances, transfers, statements, and policy — with webhooks for everything that changes state.
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.
tr_ transfers, pol_ policies, stmt_ statements, att_ attestations, evt_ webhook events. An identifier is never reused, even after deletion./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.limit (default 25, max 100) and a starting_after cursor; responses include has_more.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.
# 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.
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:
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.
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.
{
"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" }
]
}
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.
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_approval → approved → signing → broadcast → confirmed. 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.
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 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.
policy field and resolving it via GET /v1/policies/:id tells you exactly why a transfer is waiting and who can advance it.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.
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"
}
}
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.
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.
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:
# 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.
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.
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.)