Testnet phase optitor runs on public testnets today. Mainnet follows an independent cryptography audit. Where we are

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.

  • Transactions

    • POST /transactions
    • GET /transactions
    • POST /transactions/:id/speedup
    • POST /transactions/:id/cancel

    Full lifecycle with fee estimates, same-nonce replacement and CSV export.

  • 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.

  • Whitelists

    • POST /whitelist_wallets
    • POST /whitelist_wallets/:id/addresses
    • POST /whitelist_wallets/:id/approve

    Internal, external and contract destinations with approval and cooldown.

  • 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.

  • Webhooks

    • POST /webhooks
    • POST /webhooks/:id/rotate_secret
    • POST /webhooks/:id/notifications/resend_failed

    Subscribe to events, rotate secrets without downtime, replay failures.

  • 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.

  • Workspace controls

    • POST /workspace/freeze
    • GET /workspace/status
    • POST /vault/accounts/:id/freeze

    Kill switch and per-vault freezes; lifting them needs the quorum.

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.
Canonical string v1 (LF-joined)
optitor-v1
POST
/api/v1/transactions
<hex SHA-256 of the raw body>
1791470000
3b9d2f0e-61a4-4c3e-8f7b-2a5d9e1c4b60
Sign a request · Node.js
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 add paging.next; errors are { "error": { "code", "message" } } with a stable code.

  • Idempotency

    Send Idempotency-Key on every money-moving POST. A replay returns the original resource; externalTxId is a second key.

  • Pagination & export

    Cursor pagination with after and limit (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-Token bound to the action and resource.

  • Asynchronous work

    Long operations answer 202 with 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.

Request
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"
}
Response
{
  "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

  1. pending
  2. approved
  3. broadcasting
  4. broadcast
  5. confirming
  6. 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
Verify a delivery · Node.js
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.

WebSocket · subscribe
→ { "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.