API/Overview

REST & WebSocket

API reference

A resource-oriented HTTP API over JSON, plus a WebSocket surface for everything that changes. Predictable URLs, standard verbs, standard status codes, and one error envelope everywhere.

Base https://api.strixhood.xyz/v1 Version 2026-07-01 Endpoints 34 Channels 3

Base

Overview

Every endpoint is served over TLS 1.3 from https://api.strixhood.xyz. HTTP is refused, not redirected. Request and response bodies are UTF-8 application/json unless an endpoint says otherwise.

Versioning

The path carries the major version (/v1); the Strix-Api-Version header carries the dated minor version. Omitting the header pins you to the version that was current when your API key was created, so existing integrations do not move under you.

curl -sS https://api.strixhood.xyz/v1/agents \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Strix-Api-Version: 2026-07-01"
VersionReleasedBreaking changes
2026-07-012026-07-01intent.estimated replaces intent.preview; equity_order requires venue.
2026-03-142026-03-14Cursor pagination replaces offset pagination on every list endpoint.
2025-11-202025-11-20Initial public version.

Environments

EnvironmentKey prefixChainsNotes
Teststrx_sk_test_Base Sepolia, Arbitrum Sepolia, Solana devnetNo $STRX bond, faucet available, same schemas.
Livestrx_sk_live_All seven production networksReal value. Bonds and fees apply.

A test key against a live chain returns 403 environment_mismatch, and the reverse is also true. There is no flag that lets one key straddle both.

Access

Authentication

Bearer tokens on every request. There are no cookies, no sessions and no request signing for REST — signing happens onchain with session keys, not at the API boundary.

Bearer authentication

Authorization: Bearer strx_sk_live_9f2c41bd7a084e6cb35d0e17
Content-Type: application/json
Strix-Api-Version: 2026-07-01
Idempotency-Key: 6f1c0d9a-8b52-4a1e-9f77-2c3d4e5f6a7b

A missing or malformed header returns 401 authentication_error. A well-formed key that lacks the required scope returns 403 permission_error and names the scope it wanted.

Scopes

Scopes are assigned at key creation and cannot be widened afterwards — create a new key instead. Every endpoint on this page states the scope it requires.

ScopeGrantsSafe for an agent runtime
agents:readRead agents, session-key metadata, reputation.Yes
agents:writeCreate, update, retire agents; issue and revoke session keys.No
intents:readRead intents and executions.Yes
intents:writeSubmit, cancel and approve intents.Only with a tightly scoped policy
policies:readRead policies and their commitments.Yes
policies:writeCreate and update policies.Never — this is authority over authority.
portfolio:readBalances, history, transactions.Yes
webhooks:writeManage webhook endpoints.No
quotes:readQuotes and prices. The only scope on pk_ keys.Yes
Scope your keys per process, not per team

The blast radius of a leaked key is exactly its scope set. One key with everything is one compromise away from a rewritten policy.

Rotation and revocation

Keys support overlapping rotation: create the replacement, deploy it, then revoke the old key. Revocation is immediate and global — there is no propagation window. Keys unused for 90 consecutive days are automatically disabled and must be re-enabled from the console.

curl -sS -X DELETE https://api.strixhood.xyz/v1/api-keys/key_01JQ8ZV9R2T4W6Y8A0C2E4G6J8 \
  -H "Authorization: Bearer $STRIX_ADMIN_KEY"

IP allowlists

Secret keys accept an optional CIDR allowlist. Requests from outside it return 403 ip_not_allowed and are logged with the source address. Publishable pk_ keys cannot be IP-restricted, because they are meant to be public.

Shape

Requests & responses

These rules hold for every endpoint. They are stated once here and not repeated per resource.

Idempotency

Every POST accepts an Idempotency-Key header. Replaying a key inside 24 hours returns the original response — same status, same body, plus Idempotency-Replayed: true. Replaying a key with a different body returns 409 idempotency_conflict; the protocol will not guess which one you meant.

SituationResult
Same key, same body, inside 24 h200/202 with the original object and Idempotency-Replayed: true
Same key, different body409 idempotency_conflict
Same key, original request still in flight409 idempotency_in_progress, retry after Retry-After
Key older than 24 hTreated as new

Pagination

All list endpoints are cursor-paginated. Because object IDs are ULIDs they sort by creation time, so the cursor is just an ID.

ParameterTypeDefaultDescription
limitinteger251–100.
starting_afterstringnullReturn objects created after this ID. Forward pagination.
ending_beforestringnullReturn objects created before this ID. Backward pagination.
orderenumdescasc or desc by created_at.
{
  "object": "list",
  "data": [ { "id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA", "object": "intent" } ],
  "has_more": true,
  "next_cursor": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
  "total_estimated": 1284
}

total_estimated is exactly that — an estimate from the index, cheap to compute and never used for correctness. Loop on has_more, not on a count.

Timestamps, amounts and identifiers

  • Timestamps are RFC 3339 UTC strings with millisecond precision: 2026-08-16T09:00:00.412Z. Never Unix epochs, never local time.
  • Token amounts are decimal strings in human units, not integers in base units: "1500.000000" USDC, not 1500000000. This avoids float truncation in JavaScript and ambiguity about decimals.
  • USD values are JSON numbers with at most 2 decimal places, and are always estimates unless the field name ends in _settled.
  • Basis points are integers: 50 means 0.50%.
  • Identifiers are prefix_ULID: agt_, int_, exe_, pol_, qte_, whk_, evt_, key_. 26-character Crockford base32, lexicographically sortable.
  • Chains are CAIP-2 strings: eip155:8453.

Expanding objects

Related objects are returned as IDs by default. Request them inline with expand[], up to three levels deep and four expansions per request.

curl -sS -G https://api.strixhood.xyz/v1/intents/int_01JQ8ZP1V6C3MD8R0YF2WKGSTA \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  --data-urlencode "expand[]=execution" \
  --data-urlencode "expand[]=execution.attestation" \
  --data-urlencode "expand[]=agent.policy"

Request IDs

Every response carries X-Request-Id. It is the only thing support needs to find the full trace, including the policy evaluation and the simulation transcript. Log it.

Failure

Errors

One envelope, always. If a response has a status of 400 or above, this is its shape — there are no special cases.

The error envelope

{
  "error": {
    "type": "policy_error",
    "code": "limit_exceeded",
    "message": "Intent notional 780.00 USD exceeds per_tx_usd of 250.00 USD.",
    "param": "params.sell_amount",
    "rule": "limits.per_tx_usd",
    "stage": "policy_check",
    "doc_url": "https://strixhood.xyz/docs.html#policy-limits",
    "request_id": "req_01JQ8ZW4T6V8X0Z2B4D6F8H0K2"
  }
}
FieldTypeDescription
typeenumCoarse family. Branch on this.
codestringSpecific, stable machine code. Never reworded within a version.
messagestringHuman sentence with concrete numbers. Safe to log, not safe to parse.
paramstring | nullDotted path to the offending request field.
rulestring | nullDotted path to the policy clause that refused. Only on policy_error.
stagestring | nullLifecycle stage that produced the failure.
request_idstringMirrors X-Request-Id.

HTTP status codes

StatusMeaningRetry?
200OK.
201Created. Location points at the object.
202Accepted. The intent is queued; watch the stream.
204No content. Successful delete.
400Malformed JSON or unknown field.No — fix the request
401Missing, malformed or revoked key.No
403Key valid, action not permitted.No
404No such object, or not visible to this key.No
409Idempotency conflict or state conflict.No
422Well-formed but refused: policy, simulation or business rule.No
429Rate limited. Honour Retry-After.Yes, with backoff
500Unhandled error on our side. Already alerting.Yes
503Dependency degraded — chain, bundler or relay.Yes

Error codes

typecodeHTTPCause and fix
authentication_errorinvalid_api_key401Key unknown, revoked or from the other environment.
authentication_errorenvironment_mismatch403Test key against a live chain, or the reverse.
permission_errormissing_scope403message names the scope. Mint a new key; scopes are immutable.
permission_errorip_not_allowed403Source address outside the key's CIDR allowlist.
invalid_request_errorunknown_parameter400Unknown keys are rejected, never ignored. Check spelling and version.
invalid_request_errormissing_parameter400param names the field.
invalid_request_errorunresolvable_token400Symbol has no canonical address on that chain. Pass the address.
policy_errorpolicy_stale422Committed hash differs from the stored policy. Re-commit before retrying.
policy_errorchain_not_allowed422chain is absent from allow.chains.
policy_erroraction_not_allowed422action is absent from allow.actions.
policy_errorasset_not_allowed422A token, collection, contract or venue is not allow-listed.
policy_errordenied422Matched a deny entry or category.
policy_errorlimit_exceeded422rule names which ceiling. Wait for the window or raise the policy.
policy_errorapproval_required202Not an error on submit — the intent is held at awaiting_approval.
simulation_errorsimulation_failed422The candidate path reverts on a fork. message carries the revert reason.
simulation_errorprice_impact_too_high422Exceeds simulation.max_price_impact_bps. Split the order.
simulation_errorunsafe_target422Drainer, approval-sweep or honeypot heuristic fired. Not overridable by intent.
routing_errorno_route422No solver and no direct route inside the slippage bound.
routing_errorquote_expired409Quote older than its expires_at. Request a fresh one.
settlement_errorinsufficient_balance422Smart account cannot fund the sell leg plus gas.
settlement_errorreverted200Reported on the execution object, not as an HTTP failure.
idempotency_erroridempotency_conflict409Same key, different body.
rate_limit_errortoo_many_requests429Honour Retry-After; use the WebSocket instead of polling.
api_errorinternal_error500Retry with backoff. Include request_id if it persists.
api_errordependency_degraded503Chain, bundler or relay is unhealthy. See the status page.
Resource

Agents

An agent bundles a smart account, a bound policy, session keys and a reputation record. Creating one mints a passport NFT and locks a bond on live networks.

Create an agent

POST /v1/agents scope agents:write

Deploys an ERC-4337 smart account with the session-key validator module, mints the passport, and binds the policy. Idempotent on Idempotency-Key.

Body parameters

ParameterTypeRequiredDescription
namestringrequired1–48 chars, unique per account. Appears in approval prompts and the marketplace.
kindenumrequiredtrader, collector, treasury, service, verified.
policy_idstringrequiredExisting policy to bind. Its hash is embedded in every session key issued.
chainsstring[]requiredCAIP-2 list. Must be a subset of the policy's allow.chains.
session_keyobjectoptional{ ttl_seconds, rotate }. Issues a first key immediately. TTL 300–604800.
owneraddressoptionalRoot key that controls the smart account. Defaults to the account's registered owner.
metadataobjectoptionalUp to 20 keys.

Request

curl -sS https://api.strixhood.xyz/v1/agents \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-dca-eth-01" \
  -d '{
    "name": "dca-eth",
    "kind": "trader",
    "policy_id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD",
    "chains": ["eip155:8453"],
    "session_key": { "ttl_seconds": 86400, "rotate": true }
  }'

Response

{
  "id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "object": "agent",
  "name": "dca-eth",
  "kind": "trader",
  "status": "active",
  "smart_account": "0x1F3c7A9b04E2d586Cf01B7e34a9D2c6058Ba9aE2",
  "chains": ["eip155:8453"],
  "policy": { "id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD", "version": 1,
              "hash": "0x7d41a9c0b83e5f2d16c4a87b90ef3524ca1d6b8f0472e93a5c18df6027ab4e91" },
  "passport": { "chain": "eip155:8453", "contract": "0x2Fb1…6Ef2",
                "token_id": "4182", "level": 1 },
  "bond": { "amount": "2500", "token": "STRX", "status": "locked" },
  "reputation": { "score": null, "settled_intents": 0 },
  "session_keys": [ { "id": "key_01JQ8ZNB5C7E9G1J3L5N7Q9S1U",
                      "address": "0xA4e1…7C2b", "expires_at": "2026-08-17T09:00:00Z" } ],
  "created_at": "2026-08-16T09:00:00.114Z"
}

Errors

codeHTTPWhen
missing_parameter400policy_id absent.
policy_chain_mismatch422chains is not a subset of the policy's allowed chains.
insufficient_bond422Account holds less $STRX than the kind requires.
name_taken409Another active agent already uses that name.

List agents

GET /v1/agents scope agents:read

Cursor-paginated, newest first.

Query parameters

ParameterTypeDescription
statusenumactive, paused, retired, slashed.
kindenumFilter by agent kind.
chainstringCAIP-2. Returns agents enabled on that chain.
policy_idstringAgents bound to a specific policy.
limit, starting_after, ending_before, orderSee Pagination.
curl -sS -G https://api.strixhood.xyz/v1/agents \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -d status=active -d chain=eip155:8453 -d limit=25
{
  "object": "list",
  "data": [
    { "id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP", "object": "agent", "name": "dca-eth",
      "kind": "trader", "status": "active", "reputation": { "score": 96.4, "settled_intents": 812 } }
  ],
  "has_more": false,
  "next_cursor": null
}

Retrieve an agent

GET /v1/agents/{agent_id} scope agents:read

Returns the full agent object. Supports expand[]=policy and expand[]=passport.traits.

curl -sS -G https://api.strixhood.xyz/v1/agents/agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  --data-urlencode "expand[]=policy"

Update an agent

PATCH /v1/agents/{agent_id} scope agents:write

Only name, status, policy_id, chains and metadata are mutable. Rebinding policy_id revokes every live session key in the same call, because the keys carry the old policy hash.

Body parameters

ParameterTypeDescription
statusenumactive or paused. Pausing rejects new intents instantly; in-flight ones finish.
policy_idstringRebind. Triggers session-key revocation and a new commitment.
chainsstring[]Must remain a subset of the bound policy's chains.
curl -sS -X PATCH https://api.strixhood.xyz/v1/agents/agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"paused"}'

Retire an agent

DELETE /v1/agents/{agent_id} scope agents:write

Retirement is a state, not a deletion: history, attestations and the passport survive. All session keys are revoked onchain and the bond enters a 14-day unbonding period. Returns 409 has_open_intents if anything is still in flight.

{
  "id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "object": "agent",
  "status": "retired",
  "session_keys_revoked": 2,
  "bond": { "amount": "2500", "token": "STRX", "status": "unbonding",
            "claimable_at": "2026-08-30T09:04:11Z" }
}

Issue a session key

POST /v1/agents/{agent_id}/session-keys scope agents:write

Generates a keypair inside the enclave, registers its permission blob on the agent's validator module, and returns the public address. The private key is never returned and never leaves the enclave — the API signs on the agent's behalf when an intent clears.

Body parameters

ParameterTypeRequiredDescription
ttl_secondsintegerrequired300–604800. Shorter is better; rotation is free.
chainsstring[]optionalDefaults to the agent's chains. Cannot exceed them.
rotatebooleanoptionalAuto-issue a replacement at 80% of TTL. Default false.
max_value_usdnumberoptionalAdditional per-key ceiling, applied on top of the policy. Cannot be higher than the policy's per_tx_usd.
{
  "id": "key_01JQ8ZNB5C7E9G1J3L5N7Q9S1U",
  "object": "session_key",
  "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "address": "0xA4e1B9d3f70C2b84E651A0d7c3958Ff24bD07C2b",
  "chains": ["eip155:8453"],
  "policy_hash": "0x7d41a9c0b83e5f2d16c4a87b90ef3524ca1d6b8f0472e93a5c18df6027ab4e91",
  "permissions": { "selectors": ["0x3593564c", "0xa9059cbb"], "max_value_usd": 250 },
  "registration_tx": "0x4b18e0c9a2d7f5361840be9c07a2d5f31c68e40a95bd7213ce80f6a419d2c7b5",
  "expires_at": "2026-08-17T09:00:00Z",
  "rotate": true
}

Revoke a session key

DELETE /v1/agents/{agent_id}/session-keys/{key_id} scope agents:write

Submits an onchain revocation and refuses the key immediately at the API boundary. Returns 202 with the revocation transaction; the key is unusable via the API before that transaction is mined.

{
  "id": "key_01JQ8ZNB5C7E9G1J3L5N7Q9S1U",
  "object": "session_key",
  "status": "revoked",
  "revocation_tx": "0x91cb47e2a05d8f3617b24ce09a7d51f38c60e24b95af7013dc80b6a41ed2f7c9",
  "revoked_at": "2026-08-16T11:22:04.881Z"
}
Resource

Intents

The intent object is documented field by field in the protocol reference. This section covers the endpoints that create and manage them.

Submit an intent

POST /v1/intents scope intents:write

Accepts the intent, runs stages 01–03 synchronously and returns 202 as soon as the policy check passes. Everything after that is asynchronous — watch the executions channel.

Body parameters

ParameterTypeRequiredDescription
agent_idstringrequiredMust be active with a live session key on chain.
actionenumrequiredswap, transfer, nft_bid, nft_buy, equity_order, subscribe, agent_hire.
chainstringrequiredCAIP-2.
paramsobjectrequiredAction-specific. See Action types.
constraintsobjectoptionalSlippage, fee ceilings, route preference, MEV protection.
simulate_onlybooleanoptionalReturn the quote and asset diff without signing.
expires_attimestampoptionalDefault +300 s.
metadataobjectoptionalEchoed on every webhook.

Request

curl -sS https://api.strixhood.xyz/v1/intents \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dca-2026-08-16-0900" \
  -d '{
    "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
    "action": "swap",
    "chain": "eip155:8453",
    "params": { "sell_token": "USDC", "buy_token": "WETH", "sell_amount": "150.00" },
    "constraints": { "max_slippage_bps": 40, "route_preference": "best_price" }
  }'

Response

{
  "id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
  "object": "intent",
  "status": "simulating",
  "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "action": "swap",
  "chain": "eip155:8453",
  "params": { "sell_token": "USDC", "buy_token": "WETH", "sell_amount": "150.00" },
  "constraints": { "max_slippage_bps": 40, "route_preference": "best_price",
                   "mev_protection": true, "partial_fill": false },
  "policy": { "id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD", "version": 1, "notional_usd": 150.00 },
  "estimated": { "buy_amount": "0.04129", "price": "3632.10",
                 "fee_usd": 0.375, "gas_usd": 0.02, "price_impact_bps": 6 },
  "execution_id": null,
  "expires_at": "2026-08-16T09:05:00.412Z",
  "created_at": "2026-08-16T09:00:00.412Z"
}

Errors

codeHTTPWhen
limit_exceeded422Notional breaches a policy ceiling. rule names it.
asset_not_allowed422A token or venue in params is not allow-listed.
no_session_key422The agent has no live key for that chain.
unresolvable_token400Symbol has no canonical address on that chain.

Retrieve an intent

GET /v1/intents/{intent_id} scope intents:read

Returns the current state. Use expand[]=execution to include quotes, fills and the attestation in one call instead of two.

{
  "id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
  "object": "intent",
  "status": "settled",
  "execution_id": "exe_01JQ8ZQ7Y9B1D3F5H7K9M1P3R5",
  "settled": { "buy_amount": "0.041274", "price": "3634.02",
               "fee_usd_settled": 0.375, "gas_usd_settled": 0.019 },
  "rejection": null,
  "created_at": "2026-08-16T09:00:00.412Z",
  "settled_at": "2026-08-16T09:00:01.338Z"
}

List intents

GET /v1/intents scope intents:read

Query parameters

ParameterTypeDescription
agent_idstringRestrict to one agent.
statusenum | enum[]Repeatable: status=routing&status=submitted.
actionenumFilter by action.
chainstringCAIP-2.
created_aftertimestampInclusive lower bound.
created_beforetimestampExclusive upper bound.
metadata[key]stringExact match on a metadata key, e.g. metadata[strategy]=weekly-rebalance.
curl -sS -G https://api.strixhood.xyz/v1/intents \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -d agent_id=agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP \
  -d status=settled -d limit=50 \
  --data-urlencode "metadata[strategy]=weekly-rebalance"

Cancel an intent

POST /v1/intents/{intent_id}/cancel scope intents:write

Cancellable up to and including routing. Once the user operation is broadcast the intent is submitted and cancellation returns 409 not_cancellable — there is no way to unsend a transaction, and the API will not pretend otherwise.

{
  "id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
  "object": "intent",
  "status": "cancelled",
  "cancelled_at": "2026-08-16T09:00:00.902Z",
  "budget_released_usd": 150.00
}

Resolve a human gate

POST /v1/intents/{intent_id}/approval scope intents:write

Resolves an intent sitting at awaiting_approval. The decision is recorded with the approver's identity and is included in the attestation, so approvals are auditable after the fact.

Body parameters

ParameterTypeRequiredDescription
decisionenumrequiredapprove or reject.
approver_idstringrequiredMust appear in policy.hitl.approvers.
signaturestringrecommendedEIP-191 signature over intent_id + decision + nonce. Required when quorum > 1.
notestringoptional≤ 280 chars, stored on the attestation.
curl -sS https://api.strixhood.xyz/v1/intents/int_01JQ8ZP1V6C3MD8R0YF2WKGSTA/approval \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"decision":"approve","approver_id":"usr_01JQ8ZS2M4N6P8R0T2V4X6Z8B0","note":"Rebalance leg 1/3, reviewed"}'
Resource

Policies

Policies are the authority layer. Writing one requires policies:write, which should live on exactly one server process and nowhere near an agent runtime.

Create a policy

POST /v1/policies scope policies:write

Validates the document, canonicalises it, computes the hash and commits it onchain. Returns once the commitment transaction is broadcast; commitment.block is null until it is mined. Full field reference in the policy schema.

Body parameters

ParameterTypeRequiredDescription
namestringrequired1–64 chars, unique per account.
limitsobjectrequiredMust include per_tx_usd.
allowobjectrequiredMust include non-empty chains and actions.
denyobjectoptionalWins over allow.
simulationobjectoptionalThresholds on the simulated asset diff.
hitlobjectoptionalEscalation rules. Absent means never escalate.
expires_attimestamprecommendedAbsent means a standing grant with no end.
commit_chainstringoptionalWhere to commit the hash. Defaults to eip155:8453.
{
  "id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD",
  "object": "policy",
  "name": "dca-conservative",
  "version": 1,
  "hash": "0x7d41a9c0b83e5f2d16c4a87b90ef3524ca1d6b8f0472e93a5c18df6027ab4e91",
  "limits": { "per_tx_usd": 250, "daily_usd": 1000, "monthly_usd": 20000, "max_open_intents": 4 },
  "allow": { "chains": ["eip155:8453"], "actions": ["swap", "transfer"],
             "tokens": ["USDC", "WETH", "cbBTC"], "venues": ["uniswap_v4", "aerodrome"] },
  "deny": { "categories": ["leverage", "gambling", "unverified_contract"] },
  "hitl": { "threshold_usd": 200, "channels": ["webhook"], "timeout_sec": 180, "on_timeout": "reject" },
  "agent_ids": [],
  "commitment": { "chain": "eip155:8453",
                  "registry": "0x8Ae4…18Db",
                  "tx_hash": "0x2ad9…7f31", "block": null },
  "expires_at": "2027-01-01T00:00:00Z",
  "created_at": "2026-08-16T08:58:12.004Z"
}

Retrieve a policy

GET /v1/policies/{policy_id} scope policies:read

Add ?version=N to read a historical version. Historical versions are immutable and retained for seven years, because they are the evidence for why a past transaction was allowed.

curl -sS -G https://api.strixhood.xyz/v1/policies/pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD \
  -H "Authorization: Bearer $STRIX_API_KEY" -d version=3

List policies

GET /v1/policies scope policies:read

Filters: status (live, expired, superseded), agent_id, plus the standard pagination parameters.

Update a policy

PATCH /v1/policies/{policy_id} scope policies:write

Updates create a new version and a new commitment. Every session key issued under the previous hash stops validating the moment the new commitment is mined, so bound agents must be re-keyed — pass reissue_session_keys: true to have the API do it in the same call.

There is a gap, and you should plan for it

Between the commitment landing and the new keys registering, the agent cannot sign. It is typically one block. Intents submitted in that window are held in policy_check rather than rejected, up to their expires_at.

curl -sS -X PATCH https://api.strixhood.xyz/v1/policies/pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"limits":{"per_tx_usd":250,"daily_usd":2500,"monthly_usd":20000,"max_open_intents":4},
       "reissue_session_keys":true}'
{
  "id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD",
  "object": "policy",
  "version": 2,
  "hash": "0xb2f70c1d94a5e836027cf4a1b8d05e93762ac4108fd35b6e29c07a41db85e6f2",
  "previous_hash": "0x7d41a9c0b83e5f2d16c4a87b90ef3524ca1d6b8f0472e93a5c18df6027ab4e91",
  "commitment": { "tx_hash": "0x8e31…04ba", "block": null },
  "session_keys_reissued": 2,
  "updated_at": "2026-08-16T12:40:09.771Z"
}

Simulate a policy

POST /v1/policies/{policy_id}/simulate scope policies:read

Evaluates a hypothetical intent against a policy without creating anything. Use it in CI: assert that the intents your agent is capable of producing are the intents your policy permits.

Body parameters

ParameterTypeRequiredDescription
intentobjectrequiredA full intent body minus agent_id.
as_oftimestampoptionalEvaluate rolling windows as of this time. Defaults to now.
include_marketbooleanoptionalAlso run simulation thresholds against live prices. Default false.
{
  "object": "policy_simulation",
  "allowed": false,
  "policy": { "id": "pol_01JQ8ZK3M2X7YB4N6PA0RTVCWD", "version": 2 },
  "notional_usd": 780.00,
  "checks": [
    { "rule": "allow.chains",       "result": "pass" },
    { "rule": "allow.actions",      "result": "pass" },
    { "rule": "allow.tokens",       "result": "pass" },
    { "rule": "deny.categories",    "result": "pass" },
    { "rule": "limits.per_tx_usd",  "result": "fail",
      "detail": "780.00 > 250.00" },
    { "rule": "limits.daily_usd",   "result": "skipped" }
  ],
  "would_escalate": true,
  "first_failure": "limits.per_tx_usd"
}
Resource

Quotes & routing

A quote is a priced, expiring, non-binding preview. Submitting an intent runs its own auction; a quote is for showing a number to a human or a model before committing.

Request a quote

POST /v1/quotes scope quotes:read

The only endpoint that accepts a publishable pk_ key, so a browser or agent runtime can price something without holding a secret.

Body parameters

ParameterTypeRequiredDescription
actionenumrequiredSame enum as intents.
chainstringrequiredCAIP-2.
paramsobjectrequiredAction-specific.
agent_idstringoptionalPrices against that agent's policy and returns policy_ok.
route_preferenceenumoptionalDefault best_price.
{
  "id": "qte_01JQ8ZT2K4M6P8R0T2V4X6Z8B1",
  "object": "quote",
  "chain": "eip155:8453",
  "sell": { "token": "USDC", "amount": "150.00" },
  "buy":  { "token": "WETH", "amount": "0.041293", "minimum": "0.041128" },
  "price": "3632.10",
  "price_impact_bps": 6,
  "fees": { "protocol_usd": 0.375, "solver_usd": 0.09, "gas_usd": 0.02 },
  "routes": [
    { "solver": "slv_kestrel", "venue": "uniswap_v4",  "out": "0.041293", "score": 1.000 },
    { "solver": "slv_harrier", "venue": "aerodrome",   "out": "0.041251", "score": 0.998 },
    { "solver": "direct",      "venue": "uniswap_v4",  "out": "0.041180", "score": 0.997 }
  ],
  "policy_ok": true,
  "expires_at": "2026-08-16T09:00:12.000Z"
}
Quotes expire in 12 seconds

That is roughly one Base block plus margin. A quote older than its expires_at cannot be attached to an intent — you get 409 quote_expired.

Retrieve a quote

GET /v1/quotes/{quote_id} scope quotes:read

Returns the quote as issued, including expired ones, for audit. It does not reprice.

List venues

GET /v1/routes scope quotes:read

Enumerates the venues reachable on a chain, their liquidity class and their session hours. Use it to build a policy's allow.venues from something real instead of guessing.

{
  "object": "list",
  "data": [
    { "venue": "uniswap_v4", "chain": "eip155:8453", "kind": "amm",
      "tvl_usd": 412000000, "session": null, "status": "live" },
    { "venue": "backed_rwa", "chain": "eip155:42161", "kind": "rwa_equity",
      "tvl_usd": 88000000,
      "session": { "opens_at": "2026-08-17T13:30:00Z", "closes_at": "2026-08-17T20:00:00Z",
                   "timezone": "UTC", "continuous_secondary": true },
      "status": "live" }
  ],
  "has_more": false
}
Resource

Executions

An execution is created when an intent enters routing and carries everything that happened afterwards: the auction, the fills, the receipts and the attestation.

Retrieve an execution

GET /v1/executions/{execution_id} scope intents:read
{
  "id": "exe_01JQ8ZQ7Y9B1D3F5H7K9M1P3R5",
  "object": "execution",
  "intent_id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
  "status": "settled",
  "chain": "eip155:8453",
  "solver": { "id": "slv_kestrel", "reliability": 0.9987, "bond_strx": "180000" },
  "quote": { "id": "qte_01JQ8ZT2K4M6P8R0T2V4X6Z8B1",
             "guaranteed_out": "0.041128", "bids_received": 3, "auction_ms": 176 },
  "simulation": { "digest": "0x93af…21c7", "price_impact_bps": 6,
                  "asset_diff": [ { "token": "USDC", "delta": "-150.000000" },
                                  { "token": "WETH", "delta": "+0.041274" } ],
                  "warnings": [] },
  "fills": [ { "buy_amount": "0.041274", "sell_amount": "150.000000", "price": "3634.02",
               "tx_hash": "0x7c02e91a4f5b8d3607a2c14be9350df82461ac09b7de52318ca06f4b19e7d3a2",
               "block": 24817552, "gas_usd": 0.019 } ],
  "fees": { "protocol_usd": 0.375, "solver_usd": 0.09, "gas_usd": 0.019,
            "split": { "treasury_usd": 0.15, "stakers_usd": 0.1125, "buyback_usd": 0.1125 } },
  "attestation": { "uid": "0x5ea1…9d40", "schema": "strixhood.settlement.v1",
                   "chain": "eip155:8453", "explorer_url": "https://base.easscan.org/attestation/view/0x5ea1…9d40" },
  "timeline": [
    { "status": "routing",   "at": "2026-08-16T09:00:00.598Z" },
    { "status": "submitted", "at": "2026-08-16T09:00:00.774Z" },
    { "status": "settled",   "at": "2026-08-16T09:00:01.338Z" }
  ]
}

List executions

GET /v1/executions scope intents:read

Filters: agent_id, status, chain, solver, settled_after, settled_before, plus standard pagination. Set format=csv to stream a CSV for accounting instead of JSON.

curl -sS -G https://api.strixhood.xyz/v1/executions \
  -H "Authorization: Bearer $STRIX_API_KEY" \
  -d format=csv -d settled_after=2026-07-01T00:00:00Z -d settled_before=2026-08-01T00:00:00Z \
  -o july-executions.csv

Retrieve an attestation

GET /v1/executions/{execution_id}/attestation scope intents:read

Returns the decoded attestation plus the raw ABI-encoded payload, so you can verify it against the EAS contract yourself rather than trusting this API.

{
  "uid": "0x5ea1c73b0428f96d15a0c8e4712bd936084fa5c2e1739bd60c48af2107e59d40",
  "object": "attestation",
  "schema": "strixhood.settlement.v1",
  "attester": "0x9D07…0d75",
  "recipient": "0x1F3c7A9b04E2d586Cf01B7e34a9D2c6058Ba9aE2",
  "revocable": false,
  "data": {
    "intent_hash": "0xc41d…8a02",
    "policy_hash": "0x7d41a9c0b83e5f2d16c4a87b90ef3524ca1d6b8f0472e93a5c18df6027ab4e91",
    "policy_version": 1,
    "simulation_digest": "0x93af…21c7",
    "solver": "slv_kestrel",
    "settled_out": "41274000000000000",
    "approvals": []
  },
  "raw": "0x0000000000000000000000000000000000000000000000000000000000000020…",
  "verify_url": "https://base.easscan.org/attestation/view/0x5ea1…9d40"
}
Resource

Portfolio

Read-only views over an agent's smart account: balances, valuation, history and the transaction ledger. Prices come from the same oracle set the policy engine uses, so a portfolio number and a limit calculation never disagree.

Retrieve portfolio

GET /v1/portfolio scope portfolio:read

Query parameters

ParameterTypeRequiredDescription
agent_idstringrequiredWhich agent's account to value.
chainsstring[]optionalDefaults to all of the agent's chains.
includeenum[]optionaltokens, nfts, equities, positions. Default all.
min_value_usdnumberoptionalHide dust. Default 1.00.
{
  "object": "portfolio",
  "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "total_value_usd": 48213.77,
  "change_24h_pct": 1.84,
  "as_of": "2026-08-16T09:04:00.000Z",
  "tokens": [
    { "symbol": "WETH", "chain": "eip155:8453", "balance": "9.418200",
      "price_usd": 3634.02, "value_usd": 34226.31, "allocation_pct": 71.0 },
    { "symbol": "USDC", "chain": "eip155:8453", "balance": "9412.400000",
      "price_usd": 1.0, "value_usd": 9412.40, "allocation_pct": 19.5 }
  ],
  "equities": [
    { "symbol": "AAPLX", "chain": "eip155:42161", "quantity": "19.204000",
      "price_usd": 231.40, "value_usd": 4443.80, "venue": "backed_rwa" }
  ],
  "nfts": [
    { "collection": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", "token_id": "8842",
      "floor_price_eth": "3.62", "value_usd": 131.26 }
  ],
  "unrealised_pnl_usd": 2914.08
}

Portfolio history

GET /v1/portfolio/history scope portfolio:read

Time series of total value. interval accepts 5m, 1h, 1d; range accepts 24h, 7d, 30d, 1y, max. Points are snapshots at interval close, not interpolations.

{
  "object": "portfolio_history",
  "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
  "interval": "1h",
  "range": "24h",
  "points": [
    { "t": "2026-08-15T10:00:00Z", "value_usd": 47338.10 },
    { "t": "2026-08-15T11:00:00Z", "value_usd": 47510.62 },
    { "t": "2026-08-16T09:00:00Z", "value_usd": 48213.77 }
  ]
}

List transactions

GET /v1/portfolio/transactions scope portfolio:read

Every value movement in or out of the account, including ones not originated by an intent — deposits, airdrops, third-party transfers. Cursor-paginated, format=csv supported.

{
  "object": "list",
  "data": [
    { "id": "txn_01JQ8ZV1C3E5G7J9L1N3Q5S7U9", "direction": "in", "kind": "settlement",
      "token": "WETH", "amount": "0.041274", "value_usd": 150.00,
      "intent_id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
      "tx_hash": "0x7c02e91a4f5b8d3607a2c14be9350df82461ac09b7de52318ca06f4b19e7d3a2",
      "at": "2026-08-16T09:00:01.338Z" },
    { "id": "txn_01JQ8ZU9A1C3E5G7J9L1N3Q5S7", "direction": "in", "kind": "external_deposit",
      "token": "USDC", "amount": "5000.000000", "value_usd": 5000.00,
      "intent_id": null, "at": "2026-08-14T17:22:40.000Z" }
  ],
  "has_more": true,
  "next_cursor": "txn_01JQ8ZU9A1C3E5G7J9L1N3Q5S7"
}
Push

Webhooks

Webhooks are the durable channel: signed, retried and replayable. The WebSocket is the fast channel. Production systems use both — the socket to react, the webhook to be certain.

Create an endpoint

POST /v1/webhooks scope webhooks:write

Body parameters

ParameterTypeRequiredDescription
urlstringrequiredHTTPS only. Must answer a POST probe with 2xx inside 5 s before the endpoint activates.
eventsstring[]requiredEvent types, or ["*"]. Unknown types are rejected.
agent_idsstring[]optionalRestrict to specific agents. Default all.
descriptionstringoptional≤ 120 chars, shown in the console.
{
  "id": "whk_01JQ8ZW7E9G1J3L5N7Q9S1U3W5",
  "object": "webhook_endpoint",
  "url": "https://ops.example.com/hooks/strix",
  "events": ["intent.rejected", "approval.requested", "execution.settled", "execution.failed"],
  "status": "active",
  "signing_secret": "whsec_2f8c1a04e75b39d6c0182e4a7f31b9d5",
  "created_at": "2026-08-16T09:10:22.500Z"
}
The signing secret is shown once

It is not retrievable afterwards. Store it in your secret manager immediately, or roll the endpoint and get a new one.

List endpoints

GET /v1/webhooks scope webhooks:write

Returns endpoints with delivery health: success_rate_24h, last_delivery_at, consecutive_failures. An endpoint that fails 20 consecutive deliveries is disabled and an webhook.disabled event is emitted to the remaining healthy endpoints.

Delete an endpoint

DELETE /v1/webhooks/{webhook_id} scope webhooks:write

Returns 204. Deliveries already queued are dropped; nothing is redelivered afterwards.

Event types

EventFires whenPayload data
intent.createdAn intent is accepted at stage 01.intent
intent.rejectedAny of stages 02–04 refuses it.intent with rejection
intent.expiredexpires_at passes without a fill.intent
intent.cancelledCancelled by the owner.intent
approval.requestedThe human gate opens. Carries the full asset diff.intent + simulation
approval.resolvedApproved, rejected or timed out.intent + decision
execution.submittedThe user operation is broadcast.execution
execution.settledIncluded, fee split, attestation written.execution + attestation
execution.failedReverted onchain or the solver defaulted.execution with failure
execution.revertedA settled execution was undone by a deep reorg.execution
policy.updatedA new policy version is committed.policy
agent.slashedA slashing claim executes against the bond.agent + slash
session_key.rotatedAuto-rotation issues a replacement key.session_key
webhook.disabledAn endpoint is disabled after 20 consecutive failures.webhook_endpoint
{
  "id": "evt_01JQ8ZX3G5J7L9N1Q3S5U7W9Y1",
  "object": "event",
  "type": "execution.settled",
  "api_version": "2026-07-01",
  "created_at": "2026-08-16T09:00:01.402Z",
  "livemode": true,
  "data": {
    "object": {
      "id": "exe_01JQ8ZQ7Y9B1D3F5H7K9M1P3R5",
      "object": "execution",
      "intent_id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
      "status": "settled",
      "fills": [ { "buy_amount": "0.041274", "price": "3634.02" } ],
      "attestation": { "uid": "0x5ea1…9d40" }
    }
  },
  "attempt": 1
}

Signature verification

Every delivery carries Strix-Signature: a timestamp and one or more HMAC-SHA256 signatures over timestamp + "." + raw_body, keyed with the endpoint's signing secret. Multiple v1= values appear during a secret roll — accept the request if any of them verifies.

POST /hooks/strix HTTP/1.1
Content-Type: application/json
Strix-Signature: t=1786953601,v1=6a1f0c4b8d29e7350a1c8f6b2e94d075c3a8b1f60e29d47a5c30b8e1f27a94d6
Strix-Event-Id: evt_01JQ8ZX3G5J7L9N1Q3S5U7W9Y1
Strix-Delivery-Attempt: 1
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=") as [string, string]),
  );
  const ts = Number(parts.t);
  if (!Number.isFinite(ts)) return false;
  // Reject replays outside the tolerance window.
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest();

  return header
    .split(",")
    .filter((kv) => kv.startsWith("v1="))
    .some((kv) => {
      const given = Buffer.from(kv.slice(3), "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
}
import hmac, hashlib, time

TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        ts = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    given = [kv.split("=", 1)[1] for kv in header.split(",") if kv.startswith("v1=")]
    return any(hmac.compare_digest(g, expected) for g in given)
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

const TOLERANCE_SECONDS: i64 = 300;

pub fn verify(raw_body: &[u8], header: &str, secret: &[u8]) -> bool {
    let mut ts: i64 = 0;
    let mut sigs: Vec<&str> = Vec::new();
    for kv in header.split(',') {
        match kv.split_once('=') {
            Some(("t", v)) => ts = v.parse().unwrap_or(0),
            Some(("v1", v)) => sigs.push(v),
            _ => {}
        }
    }
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
    if ts == 0 || (now - ts).abs() > TOLERANCE_SECONDS {
        return false;
    }

    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("key");
    mac.update(format!("{ts}.").as_bytes());
    mac.update(raw_body);
    let expected = hex::encode(mac.finalize().into_bytes());

    sigs.iter().any(|s| constant_time_eq(s.as_bytes(), expected.as_bytes()))
}
Verify against the raw bytes

Sign-check before parsing. Re-serialising the JSON changes key order and whitespace, and the signature will never match. Most webhook bugs are this bug.

Delivery, retries and ordering

  • Timeout — 5 seconds to respond. Return 2xx immediately and do the work asynchronously.
  • Retries — 8 attempts over 24 hours with exponential backoff: 10 s, 30 s, 2 m, 10 m, 30 m, 2 h, 6 h, 12 h.
  • Ordering is not guaranteed. Use created_at and the intent status machine to order events yourself; a retried execution.submitted can arrive after execution.settled.
  • At-least-once. Deduplicate on Strix-Event-Id. The same event can arrive twice.
  • ReplayPOST /v1/webhooks/{id}/replay with an event ID or a time range re-sends past events for backfill.
Stream

WebSocket API

One connection, many channels. Frames do not consume the REST rate-limit budget, which makes the socket the correct way to follow intents rather than polling.

Connect and authenticate

WSS wss://stream.strixhood.xyz/v1 scope intents:read

Authenticate with the first frame within 5 seconds of the handshake, or the socket closes with code 4001. Query-string keys are not accepted — they end up in proxy logs.

{ "op": "auth", "token": "strx_sk_live_9f2c41bd7a084e6cb35d0e17", "id": "c1" }
{ "op": "auth.ok", "id": "c1", "account": "acct_01JQ8Z…", "livemode": true,
  "heartbeat_sec": 20, "channels": ["intents", "executions", "prices"] }
wscat -c wss://stream.strixhood.xyz/v1 \
  -x '{"op":"auth","token":"'"$STRIX_API_KEY"'","id":"c1"}'

Frame format

Every frame is a JSON object with an op. Client frames may carry an id, which is echoed on the matching acknowledgement so you can correlate.

opDirectionMeaning
authclient → serverAuthenticate the connection.
subscribeclient → serverJoin a channel with optional filters.
unsubscribeclient → serverLeave a channel.
pingclient → serverApplication-level keepalive.
auth.ok / subscribed / unsubscribed / pongserver → clientAcknowledgements, echoing id.
eventserver → clientA channel payload.
errorserver → clientSame envelope as REST, plus the offending id.
// join two channels at once
{ "op": "subscribe", "id": "s1",
  "channels": [
    { "name": "executions", "agent_ids": ["agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP"] },
    { "name": "prices", "symbols": ["ETH", "AAPLX"], "chain": "eip155:8453" }
  ] }

// server acknowledges
{ "op": "subscribed", "id": "s1", "channels": ["executions", "prices"] }

// leave one
{ "op": "unsubscribe", "id": "s2", "channels": ["prices"] }
{ "op": "unsubscribed", "id": "s2", "channels": ["prices"] }

Channel: intents

Status transitions for every intent visible to the key. Filters: agent_ids, statuses, chains. One frame per transition, never a full re-send.

{
  "op": "event",
  "channel": "intents",
  "seq": 88214,
  "at": "2026-08-16T09:00:00.598Z",
  "data": {
    "id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
    "object": "intent",
    "status": "routing",
    "previous_status": "simulating",
    "agent_id": "agt_01JQ8ZM7T4B0KC2N9XE5RVDHQP",
    "execution_id": "exe_01JQ8ZQ7Y9B1D3F5H7K9M1P3R5"
  }
}

Channel: executions

Auction results, fills and settlement. Filters: agent_ids, chains, solvers. This is the channel to drive a UI from.

{
  "op": "event",
  "channel": "executions",
  "seq": 88217,
  "at": "2026-08-16T09:00:01.338Z",
  "data": {
    "id": "exe_01JQ8ZQ7Y9B1D3F5H7K9M1P3R5",
    "object": "execution",
    "intent_id": "int_01JQ8ZP1V6C3MD8R0YF2WKGSTA",
    "status": "settled",
    "solver": "slv_kestrel",
    "fills": [ { "buy_amount": "0.041274", "price": "3634.02",
                 "tx_hash": "0x7c02e91a4f5b8d3607a2c14be9350df82461ac09b7de52318ca06f4b19e7d3a2" } ],
    "attestation": { "uid": "0x5ea1…9d40" }
  }
}

Channel: prices

The same oracle prices the policy engine uses for notional calculation, so a client-side limit preview matches the server's decision. Throttled to 4 updates per second per symbol; subscribe to at most 50 symbols per connection.

{
  "op": "event",
  "channel": "prices",
  "seq": 88219,
  "at": "2026-08-16T09:00:01.500Z",
  "data": {
    "symbol": "ETH",
    "chain": "eip155:8453",
    "price_usd": "3634.02",
    "change_24h_pct": 1.84,
    "sources": 5,
    "staleness_ms": 380
  }
}
staleness_ms is not decoration

If it exceeds 5,000 the oracle set is degraded and the policy engine widens its own tolerance. Do not size an order off a stale price.

Heartbeats and reconnect

The server sends ping every heartbeat_sec. Miss two and the socket closes. Every event carries a monotonic seq per channel; reconnect with resume_from to replay the gap from a 15-minute buffer.

{ "op": "subscribe", "id": "s3",
  "channels": [ { "name": "executions", "resume_from": 88217 } ] }
Close codeMeaningWhat to do
1000Normal closure.Nothing.
4001Authentication timeout or failure.Fix the key. Do not reconnect in a loop.
4003Scope missing for a requested channel.Mint a key with the scope.
4008Too many connections for the tier.Multiplex channels onto one socket.
4009Heartbeat missed.Reconnect with resume_from.
4029Subscription flood — more than 20 subscribe ops per minute.Back off 60 s.
1012Server restarting for a deploy.Reconnect after a jittered 1–5 s.
function connect(url: string, token: string, onEvent: (e: unknown) => void) {
  let seq = 0;
  let backoff = 500;

  const open = () => {
    const ws = new WebSocket(url);

    ws.onopen = () => ws.send(JSON.stringify({ op: "auth", token, id: "c1" }));

    ws.onmessage = (m) => {
      const f = JSON.parse(m.data as string);
      if (f.op === "auth.ok") {
        backoff = 500;
        ws.send(JSON.stringify({
          op: "subscribe", id: "s1",
          channels: [{ name: "executions", resume_from: seq || undefined }],
        }));
      } else if (f.op === "event") {
        seq = f.seq;
        onEvent(f.data);
      } else if (f.op === "ping") {
        ws.send(JSON.stringify({ op: "pong", id: f.id }));
      }
    };

    ws.onclose = (e) => {
      if (e.code === 4001 || e.code === 4003) return; // fatal: do not retry
      setTimeout(open, Math.random() * backoff);
      backoff = Math.min(backoff * 2, 30_000);
    };
  };

  open();
}