Developers
Build on custody primitives, not around them.
A REST API with the resource model custody teams already use — vault accounts, asset wallets, transactions, policies, whitelists and webhooks — where every money-moving call is authenticated, signed, idempotent and audited.
Resources
Everything the console does, the API does.
JSON over HTTPS under /api/v1, with snake_case paths. The console is a client of the
same API, so anything you can do in it, your systems can do too — within the roles you grant them.
-
Vaults & addresses
- POST
/vault/ accounts - GET
/vault/ accounts_paged - POST
/vault/ accounts/ :id/ :assetId/ addresses - GET
/vault/ assets
Creating a vault runs distributed key generation; addresses derive instantly.
- POST
-
Transactions
- POST
/transactions - GET
/transactions - POST
/transactions/ :id/ speedup - POST
/transactions/ :id/ cancel
Full lifecycle with fee estimates, same-nonce replacement and CSV export.
- POST
-
Policy
- GET
/tap/ active_policy - PUT
/tap/ draft - POST
/tap/ simulate - POST
/tap/ publish
Draft, dry-run and publish rules; publishing opens a quorum approval.
- GET
-
Whitelists
- POST
/whitelist_wallets - POST
/whitelist_wallets/ :id/ addresses - POST
/whitelist_wallets/ :id/ approve
Internal, external and contract destinations with approval and cooldown.
- POST
-
Gas Station
- GET
/gas_station - PUT
/gas_station/ configuration/ :assetId - GET
/gas_station/ topups
Thresholds, top-up amounts and gas-price caps per chain; top-up history.
- GET
-
Webhooks
- POST
/webhooks - POST
/webhooks/ :id/ rotate_secret - POST
/webhooks/ :id/ notifications/ resend_failed
Subscribe to events, rotate secrets without downtime, replay failures.
- POST
-
Approvals & quorum
- GET
/approval_requests ?status=pending&mine=true - POST
/approval_requests/ :id/ approve
One resource for every quorum-gated action, with deny-wins semantics.
- GET
-
Workspace controls
- POST
/workspace/ freeze - GET
/workspace/ status - POST
/vault/ accounts/ :id/ freeze
Kill switch and per-vault freezes; lifting them needs the quorum.
- POST
Authentication
Mutual TLS, plus a signature on every request.
An API user gets a client certificate from a CSR you submit, and signs each request with an Ed25519 key that never leaves your system. There is no shared secret to leak.
- Client certificate issued by your deployment's internal CA, valid for a year and renewed by CSR.
- Canonical string v1 — six lines: version, method, path with sorted query, body hash, timestamp, nonce.
- Replay protection — timestamps within ±300 seconds and a single-use UUID nonce.
- Scopes and IP allowlists per API user; failures are audited and alerted.
optitor-v1
POST
/api/v1/transactions
<hex SHA-256 of the raw body>
1791470000
3b9d2f0e-61a4-4c3e-8f7b-2a5d9e1c4b60 import crypto from 'node:crypto';
// Your API user's Ed25519 private key (PEM). It never leaves your system.
const key = crypto.createPrivateKey(process.env.OPTITOR_API_KEY);
// Form-encode like Go's url.QueryEscape: spaces become '+'.
const esc = (s) =>
encodeURIComponent(s)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
.replace(/%20/g, '+');
export function signedHeaders(method, path, query = {}, body = '') {
const qs = Object.keys(query)
.sort()
.map((k) => esc(k) + '=' + esc(String(query[k])))
.join('&');
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomUUID();
const canonical = [
'optitor-v1',
method,
qs ? path + '?' + qs : path,
crypto.createHash('sha256').update(body).digest('hex'),
timestamp,
nonce,
].join('\n');
const signature = crypto.sign(null, Buffer.from(canonical), key);
return {
'X-Auth-Timestamp': timestamp,
'X-Auth-Nonce': nonce,
'X-Auth-Signature': signature.toString('base64'),
};
} Conventions
Predictable by default.
-
Envelopes
Success is
{ "data": … }; lists addpaging.next; errors are{ "error": { "code", "message" } }with a stable code. -
Idempotency
Send
Idempotency-Keyon every money-moving POST. A replay returns the original resource;externalTxIdis a second key. -
Pagination & export
Cursor pagination with
afterandlimit(up to 500). Marked lists stream CSV with?format=csv. -
Step-up
High-value creates, approvals and quorum actions carry a single-use
X-MFA-Tokenbound to the action and resource. -
Asynchronous work
Long operations answer
202with a job or approval-request id you can poll — or wait for the webhook. -
Audit everywhere
Every mutation writes an append-only audit row with the actor, before and after, and the result.
Transactions
A withdrawal tells you exactly what will happen.
The create response carries the policy decision — the matched rule and the approval quorum — persisted with the transaction, so later policy edits cannot change it.
POST /api/v1/transactions
Idempotency-Key: <a UUID you generate per payout>
{
"operation": "withdrawal",
"chainId": 8453,
"assetSymbol": "USDC",
"vaultAccountId": "a3f1c2d4-…",
"destination": { "type": "whitelisted", "address": "0x5b2e…91c4" },
"amount": "25000.00",
"note": "vendor payout",
"externalTxId": "payout-4821"
} {
"data": {
"id": "f0b7…",
"status": "pending",
"chainId": 8453,
"assetSymbol": "USDC",
"amount": "25000.00",
"dstAddressType": "whitelisted",
"policy": {
"decision": "REQUIRE_APPROVAL",
"matchedRule": "large whitelisted",
"quorum": {
"logic": "AND",
"groups": [
{ "name": "Ops", "threshold": 2 },
{ "name": "Security", "threshold": 1 }
]
}
},
"requiredApprovals": 3,
"currentApprovals": 0,
"expiresAt": "2026-10-09T09:41:00Z"
}
} Lifecycle
- pending
- approved
- broadcasting
- broadcast
- confirming
- completed
or ends as rejectedfailedcancelledexpiredfrozen
Webhooks
Signed events, delivered at least once.
Deliveries are signed with HMAC-SHA256 over the timestamp and the raw body. Retries back off from ten seconds to six hours, and every delivery can be replayed.
- Rotate without dropping events — both secrets sign for 24 hours after a rotation.
- Reject stale deliveries older than 300 seconds; process each notification id once.
- Delivery logs and metrics per endpoint, with resend by notification, resource or failure window.
transaction.createdtransaction.approval_requiredtransaction.approvedtransaction.rejectedtransaction.broadcasttransaction.confirmedtransaction.completedtransaction.faileddeposit.detecteddeposit.crediteddeposit.quarantinedpolicy.publishedwhitelist.pendingwhitelist.activatedwhitelist.disabledapproval.requestedapproval.approvedapproval.rejectedmpc.refresh.donerecovery.backup_staleworkspace.frozenworkspace.unfrozengas.lowreconciliation.driftsecurity.alert import crypto from 'node:crypto';
// X-Optitor-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
// Two v1 values are sent while a secret rotation overlaps.
export function verifyWebhook(rawBody, header, secret, tolerance = 300) {
const pairs = header.split(',').map((p) => p.trim().split('='));
const t = Number(pairs.find(([k]) => k === 't')?.[1]);
const now = Date.now() / 1000;
if (!Number.isFinite(t) || Math.abs(now - t) > tolerance) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.' + rawBody)
.digest();
return pairs
.filter(([k]) => k === 'v1')
.some(([, hex]) => {
const got = Buffer.from(hex, 'hex');
return got.length === expected.length &&
crypto.timingSafeEqual(got, expected);
});
} Realtime
Watch money move as it happens.
One authenticated WebSocket at /api/v1/ws streams the same events as the webhooks —
deposits the moment they are seen, approvals, and every status change of a transaction. Webhooks stay the durable
path; the socket is for live screens.
→ { "type": "subscribe", "topics": ["deposits", "approvals", "tx:f0b7…"] }
← { "type": "subscribed", "topics": ["deposits", "approvals", "tx:f0b7…"] }
← { "type": "event", "topic": "tx:f0b7…", "event": "transaction.approved",
"data": { "id": "f0b7…", "status": "approved", "currentApprovals": 3 } } Errors & limits
Stable codes you can branch on.
Every error carries a code that will not change between releases, and every 429 says when to retry.
| Code | HTTP | Meaning |
|---|---|---|
POLICY_BLOCKED | 409 | A policy rule blocked the request — or no rule matched. |
DESTINATION_NOT_WHITELISTED | 409 | The destination is not an active whitelist entry. |
DESTINATION_IN_COOLDOWN | 409 | The whitelist entry is still in its cooldown. |
LIMIT_EXCEEDED | 409 | A velocity limit or circuit breaker was hit. |
INSUFFICIENT_FUNDS | 409 | The vault cannot cover the amount. |
IDEMPOTENT_REPLAY | 409 | Same Idempotency-Key — the original resource is returned. |
WORKSPACE_FROZEN | 423 | The kill switch is engaged. |
RESOURCE_LOCKED | 423 | The vault, transaction or asset is frozen. |
MFA_REQUIRED | 401 | A single-use step-up token is required for this action. |
RATE_LIMITED | 429 | Retry after the number of seconds in Retry-After. |
| Tier | Keyed by | Limit |
|---|---|---|
| Authentication | per client IP | 5 / min |
| Money movement | per user or API user | 30 / min, burst 10 |
| Other writes | per user or API user | 60 / min, burst 20 |
| Reads | per user or API user | 600 / min, burst 100 |
Get credentials for the testnet API.
Tell us what you are building. We will set you up with an API user on a testnet deployment and walk your engineers through signing, webhooks and the policy model.