Going to Production#
Production write endpoints move real funds on Base. Treat every mutation as a real money movement. This guide collects the patterns that keep an integration correct under retries, timeouts, and partial failures, regardless of which surface you use.
Idempotency by transport#
Every mutating endpoint requires an idempotency key. Use one key per logical operation, and reuse the same key only when retrying the same request with the same body. Reusing a key with a different body returns idempotency_key_reuse_mismatch.
How each surface supplies the key:
| Surface | How the key is provided |
|---|---|
| Raw HTTP | Idempotency-Key header on the request — you generate it |
| SDK | idempotencyKey option; generate with createIdempotencyKey(prefix?) |
| CLI | Auto-generated and printed in the response, or pass --idempotency-key |
| MCP | Required idempotency_key argument on mutating tools — the client must supply it; Hightop does not synthesize one |
When a request did create a record, a retry with the same key and body replays that stored response (within 24h, with X-Idempotency-Replayed: true) instead of creating a second operation — see Conventions. When the outcome is unknown (a timeout) or the request was rejected before it ran (a 429, which creates no record), the same key and body simply guarantees at most one operation: it replays if the first attempt landed, or runs once if it didn't. Either way, retrying with the same key is safe.
const key = createIdempotencyKey('payout')
// First attempt times out (no response received)
// Retry with the SAME key and SAME body — at most one payment is created
await client.request('POST /v1/agent/one-off-payments', body, { idempotencyKey: key })Finalize every operation#
A successful write response does not mean the money has moved — it means the operation was accepted. Operation-backed (money-movement) writes return an operation you must poll to a terminal status; quote/simulate POSTs and webhook-management writes return their object directly. Check the generated endpoint metadata for a given endpoint's behavior.
Operation statuses:
accepted non-terminal — queued
submitted non-terminal — broadcast onchain
executed terminal — success
execution_failed terminal — failed onchain
policy_rejected terminal — blocked by your rules
cancelled terminal — cancelledPoll until terminal, then branch on the result. The SDK's operations.wait() throws HightopAgentOperationWaitTimeoutError if the deadline passes before a terminal status, so wrap it:
import { HightopAgentOperationWaitTimeoutError } from '@hightop/sdk'
try {
const final = await client.operations.wait(op.operation_id, { timeoutMs: 60_000 })
if (final.operation.status !== 'executed') {
// execution_failed / policy_rejected / cancelled — inspect and surface, do NOT blindly retry
}
} catch (error) {
if (error instanceof HightopAgentOperationWaitTimeoutError) {
// Not a failure: the operation may still settle. Re-poll by id later rather than re-submitting.
} else {
throw error
}
}The CLI equivalent is --wait; over MCP use agent_operations_wait_for_status. See Operations and Lifecycle.
Retry strategy#
Retry only what is safe to retry, and always with the original idempotency key:
- Network error / client timeout (
request_timeout) — retry the same key and body. The first attempt may or may not have landed; idempotency makes a duplicate impossible. 429 rate_limited— wait at leastRetry-After, add jitter, then retry. See Rate Limits.503 agent_api_cutover_in_progress/agent_api_chain_unavailable— transient; back off and retry.400 validation_failed— do not retry unchanged; fix the request.403policy errors — do not retry unchanged; the request is blocked by configuration (see below).
Use capped exponential backoff with jitter, and a maximum attempt budget. Never retry a different body under the same key.
Troubleshooting by status#
401 — authentication
| Code | Meaning | Fix |
|---|---|---|
authentication_failed | Bad or missing credentials | Check x-agent-id/x-api-key or bearer token and base URL |
agent_disabled | Agent turned off | Re-enable the agent in the app |
agent_not_yet_active | Before the agent's active window | Wait for active_window.starts_at |
agent_expired | After the agent's active window | Issue a new agent or extend the window |
403 — authorization and policy
| Code | Meaning |
|---|---|
insufficient_scope | OAuth token lacks the scope this route needs — request the scope (OAuth) |
permission_not_granted | The agent's permissions don't allow this action |
asset_not_allowed / recipient_not_allowed / protocol_not_allowed | The target is outside the agent's allowlists |
limit_exceeded | A spending or rate limit on the agent was hit |
ltv_too_high | Borrow action would exceed the allowed loan-to-value |
owner_only_action / identity_gated_action | Action is reserved for the app/owner, not an agent |
403 policy errors are working as designed — your rules blocked the action. Surface them; do not retry unchanged. See Permissions and Limits and If an Agent Goes Off-Script.
429 — rate limited. Back off per Retry-After. See Rate Limits.
400 — validation_failed. Fix the request shape; do not retry unchanged.
400 — signature_expired / signature_invalid_*. x402/signature-specific: re-sign the authorization and retry.
409 — quote_expired / quote_already_consumed. Conversion quotes are short-lived and single-use; fetch a fresh quote and submit again.
503 — transient. Back off and retry.
The full list is in Errors.
Pre-launch checklist#
- One idempotency key per logical write, reused only for retries of the same body.
- Every operation-backed write polled to a terminal operation status before you report success.
- Backoff with jitter on
429and503; no retry-unchanged on400/403. - Credentials in a secret store, never in source; one auth mode per client.
- Webhooks registered and HMAC-verified for async settlement instead of tight polling.
- Errors logged with
codeandrequest_idfor support. - Validate request shapes with
--simulate/client.simulatebefore going live.
