Common errors - Terminal 3 Documentation
Errors
Errors come back as a JSON-RPC bad_request (HTTP 400) with { code: "bad_request", detail, request_id }. The SDK throws with detail — a human-readable message string, not a typed error object. Match on the substring shown below. User-authentication failures additionally carry a machine code at the front of detail (e.g. eth_authenticator_limit: …), so the SDK can branch with a single startsWith.
Tenant operations — register, maps, dispatch
You’ll see in detail |
Cause | Fix |
|---|---|---|
version <x> is not higher than current version <y> |
Re-registering a contract at a version that isn’t greater than the deployed one | Bump the version passed to contracts.register |
map already exists |
Re-running maps.create against an already-provisioned tenant |
Idempotent — safe to ignore on re-runs |
map not found |
A map tail in kv_store::get / put doesn’t match what maps.create created |
Match the tails exactly between Create tenant KV maps and your Rust |
canonical map name invalid: <reason> |
tail is empty, contains .., or starts with z: |
Pass only the local tail (e.g. "secrets") — the SDK prefixes z:<tid>: |
quota exceeded: <dim> (e.g. quota exceeded: max_contracts) |
Hit a per-tenant quota | Ask the cluster operator to raise the quota |
access denied: <caller> cannot <op> map "<map>" |
The contract isn’t on the map’s readers / writers ACL |
tenant.maps.update to add the contract id to readers / writers |
tenant is suspended |
The operator suspended your tenant | Ask the operator to resume |
host/http.egress_denied: host '<host>' is not in the authorised_hosts allowlist |
The contract called a host the caller’s agent_auth grant doesn’t authorize |
Add the host to the user’s grant (see Invoke your contract) |
InsufficientCreditError on a metered function call from an agent identity |
Metered calls are charged against the calling identity’s own T3N credit balance — an agent DID’s balance is separate from its tenant’s, and starts at zero even when the tenant has plenty of test tokens. | There’s currently no documented self-serve path for an agent (as opposed to a tenant) to acquire its own credit balance — token transfers are an admin-only action. Contact devrel@terminal3.io or the developer Telegram to get an agent identity funded. |
Contract-authored errors are whatever your contract returns. The flight example, for instance, surfaces duffel_api_key not found in z:<tid>:secrets — populate it via the tenant SDK when the secrets map wasn’t seeded (see Seed API key into secrets map) — that’s the contract’s own message, not a platform error.
Generic / opaque failures (HTTP 500, no clear cause)
A bad_request gives you a substring to match on. A bare HTTP 500 with no further detail is a different situation — it usually isn’t your contract logic. Before assuming it’s a bug in your code, check these in order:
- Grab the
request_id. Most 500 responses still include one — save it before you start changing code. If you end up asking for help, it’s the single most useful thing you can hand over. - Re-check egress and ACLs first. A missing outbound-host grant or a missing map ACL entry can surface as a 500 instead of the more specific error you’d expect — re-read the egress and map ACL requirements before looking further.
- Retry once, deliberately. A single unhealthy node can return a 500 for requests that are otherwise correct. If an identical request succeeds on retry, it was very likely transient — no code change needed.
- If it’s consistent and reproducible, it’s more likely a platform-side issue than your integration. Report it in the developer Telegram with the
request_id, rather than spending hours guessing at workarounds.
Common integration gotchas
These aren’t errors with a fixed message — they’re patterns that produce confusing behavior downstream, reported independently by multiple teams.
| Gotcha | What happens | Fix |
|---|---|---|
| Hex-encoding a DID before building a map path | format!("z:{}:secrets", hex::encode(&tenant_did())) double-encodes — tenant_did() already returns the string form, so wrapping it in hex::encode again produces a value that doesn’t match any map you actually created, and lookups silently fail to find it. |
Use tenant_did()’s return value directly in the map path — don’t re-encode it. |
Omitting baseUrl when constructing a TenantClient |
TenantClient’s config accepts baseUrl as an optional field, but calls can still fail at request time without it, even after calling setEnvironment(). This is specific to TenantClient — T3nClient (used for tenant, agent, and user sessions in the walkthrough) doesn’t take a baseUrl at all; it’s always resolved from the active environment. |
When constructing a TenantClient, always pass baseUrl: getNodeUrl() explicitly rather than relying on it being inferred. |
Authentication & wallet linking
These come from the user/session contract during sign-in and addAuthMethod, with the code at the front of detail:
Code (prefix of detail) |
When |
|---|---|
eth_authenticator_limit |
Hit the cap on wallets linked to one DID (e.g. trying to add an 11th) |
eth_auth_map_conflict |
The wallet is already linked to a different DID — resolve via account merge |
email_not_verified |
A profile upsert ran before the email OTP was verified |
user_not_found |
The DID has no profile yet |
legacy_field |
A pre-2.0.0 dispatch field was sent (e.g. otp_code on user-upsert) |