Skip to main content

AGENTS.md: Migrating from GoPlus to Tok{In}

This file gives AI coding agents (Claude Code, Cursor, Copilot, etc.) the context they need to migrate an existing GoPlus token-security integration over to Dedaub's Tok{In} API with minimal code changes.

Source of truth: https://docs.dedaub.com/docs/token_safety/tokin/

If you are an AI agent helping a developer migrate, read this file end-to-end before editing their code. The sections are ordered so that the first three give you everything needed for a one-line drop-in swap; the rest covers field-level differences, edge cases, and known gaps.


1. TL;DR: the one-line swap​

GoPlus's token_security endpoint can be replaced by Tok{In} with three changes:

  1. Base URL → https://tokin-api.dedaub.com
  2. Path → /token/{chain}/{token_address} (chain is a slug such as ethereum or its EVM chain id such as 1; see §6)
  3. Auth → X-API-Key: <YOUR-API-KEY> header (instead of GoPlus's ?api_key= query param or app-id/secret signing)

Append ?response_format=goplus to receive field values in GoPlus's schema with "1"/"0" string-boolean encoding. The outer envelope is different (see §2.1): fields live under features, not at the top level, so a literal drop-in is rarely possible without one small parser change.

# Before (GoPlus)
curl 'https://api.gopluslabs.io/api/v1/token_security/1?contract_addresses=0xABC...'

# After (Tok{In}, GoPlus-compatible response)
curl -H 'X-API-Key: YOUR-API-KEY' \
'https://tokin-api.dedaub.com/token/ethereum/0xABC...?response_format=goplus'

2. Endpoint reference​

GET https://tokin-api.dedaub.com/token/{chain}/{token_address}
GET https://tokin-api.dedaub.com/token/{chain}/{token_address}?response_format=goplus

Headers:
X-API-Key: <required>
Query paramValuesDefaultEffect
response_formatdedaub | goplusdedaubgoplus reshapes the body to GoPlus's schema and string-boolean encoding.
include_token_pricetrue | falsefalsetrue fills the four price fields of the envelope (§2.3).
refreshtrue | falsefalsetrue computes the state part of the analysis again (pools, state features, source analysis). It needs an enterprise plan or an admin key. If not, the API answers 403.
redecompiletrue | falsefalsetrue runs the decompiler again. It needs an enterprise plan or an admin key. If not, the API answers 403.

The native dedaub schema is richer (typed booleans, additional fields, structured tax info). Use it for greenfield code; use goplus only when you are migrating existing GoPlus parsers and want to defer schema changes.

2.1 Response envelope (read this before parsing)​

A /token response uses this envelope, regardless of response_format:

{
"chain": "ethereum",
"contract_address": "0xA0b8...eB48",
"features": { /* SafetyFeatures (dedaub) | GoPlusMappedFeatures (goplus) */ },
"error": "",
"error_code": null,
"token_usd_price": null,
"market_cap_usd": null,
"price_source": null,
"price_age_seconds": null
}

The four price fields are null unless you ask for a price (§2.3).

This differs from GoPlus's { "code": 1, "message": "OK", "result": { "0xABC...": { ... } } } shape in two ways that matter to a migrating client:

  1. Fields are nested under features, not under result["<address>"]. Update every accessor: body.is_open_source → body.features.is_open_source.
  2. Errors come via HTTP status code + error_code, not an in-body code/message pair. On a refusal about the token (422, 500), features is null, error gives the reason as prose, and error_code gives it as a stable code. A refusal about the caller (401, 402, 429) has a different body. See §7.

Full response schema: see tokin.md.

2.2 Getting a key when you cannot sign in​

Every /token route needs an X-API-Key header. A person gets one by signing in at https://tokin.dedaub.com/login and creating it in the dashboard.

If you are an agent running without a person present, you cannot do that: sign-in needs a mailbox. Request a capped trial key instead.

curl -X POST 'https://tokin-api.dedaub.com/agent-keys' \
-H 'Content-Type: application/json' \
-d '{"reason": "LLM agent screening tokens before a user buys, ~200 scans/day",
"contact": "[email protected]"}'
{
"api_key": "...",
"plan": "agent",
"api_calls_total": 100,
"api_calls_per_day": 25,
"rate_limit_per_minute": 20,
"expires_at": "...",
"next_step": "Store this key; it is shown once. Send it as the X-API-Key header on every request, ..."
}

The numbers are the trial limits on 2026-10-03. The trial also allows only 5 calls in 10 seconds, and the response does not show this limit. Read the other limits from the response. api_calls_total is a lifetime total, and expires_at is the time when the key stops working (14 days after the request on 2026-10-03).

Every response to a key with a deadline has the header X-API-Key-Expires-At. In the last 7 days, the response also has X-API-Key-Expires-In and a Warning header.

reason is required and contact is optional. The reason is recorded so a person at Dedaub can see who uses this and for what. It is not verified and it grants nothing — the allowance is what limits you, not what you write.

The API issues one trial key for each client network (your IP address, or your IPv6 block), and it shows the key one time. A second request from the same client network gets 409 with error_code: agent_trial_already_issued, and the body has no key. So requesting another key is not a way to get more calls.

Five things to do with this, in the order they will bite:

  1. Store the key when you get it. The API does not show it again. If you lose it, ask the developer you are working for to sign in and create a full key.
  2. Treat the trial as a trial. The allowance is small on purpose — enough to build and demonstrate an integration against a handful of tokens, and not enough to run a product on. Ask the developer you are working for to sign in and create a full key before shipping.
  3. Do not retry a 402. See §7.1. A spent allowance does not refill, and asking for another key will not produce one.
  4. Do not retry a 403 or a 409 from POST /agent-keys. 403 agent_trial_expired means that the trial of this client network has ended, and it does not renew. 409 agent_trial_already_issued means that this client network already has a key. Only 429 agent_registration_throttled clears with time: wait for the Retry-After header.
  5. Do not embed a trial key in code you hand to a user. It is bound to your allowance, not theirs, and the first thing that happens at scale is that it runs out for everyone at once.

2.3 Optional price fields (?include_token_price=true)​

The price fields are off by default, because a price costs an extra upstream call. When you ask for a price, the four price fields of the envelope get values: token_usd_price, market_cap_usd (recomputed from our own circulating supply, not the source's), price_source, and price_age_seconds.

The API tries the sources in this order. price_source names the source that answered:

ValueMeaning
defillamalive cross-venue aggregate, the preferred source
network_token_infoour warehouse's last observed trade price; may be stale, so read price_age_seconds
pool_spotspot price of the token's deepest pool that simulated a real buy, converted through that pool's Chainlink-priced side
stablecoin_pin$1, assumed for the chain's stable anchors

All four fields are null on a full miss. Treat a missing price as unknown, not as zero. price_age_seconds is only ever set for network_token_info. The API reads the other sources live. market_cap_usd is also null when the result is not plausible.

2.4 The features.dex[] array​

Every pool the token trades in, sorted by USD liquidity descending. GoPlus's dex array is a subset of this. It lists only standard AMM pools.

liquidity_type tells you which kind of venue a pool is:

ValueVenue
UniV2 / UniV3 / UniV4Uniswap and its forks, by version
Solidly / SolidlyV3Solidly-family AMMs
flap, four.meme, trench.todayBonding curves: the token has not graduated to an AMM yet

Four properties matter when parsing:

  1. Bonding-curve pools are pools. A token still on its launchpad curve reports liquidity here with is_launchpad: true, and can also have an AMM pool at the same time: early dust pools are common, so do not assume the presence of an AMM pool means the curve is closed.
  2. pair is not unique for UniV4. Uniswap V4 keeps every pool on a chain inside one singleton contract, so all V4 pools share a pair address. pool_id (a 32-byte hex id) is the unique key. Deduplicating on pair collapses a token's entire V4 set into one.
  3. The array can be very long. V4 lets anyone open a pool at any fee/tick-spacing/hook combination, so popular tokens accumulate many near-empty ones. USDC on Ethereum had more than 40,000 entries on 2026-10-03. The API sorts the array by liquidity, so the deepest pools come first.
  4. liquidity is the whole pool, both sides. It is not the depth that you can trade. A pool whose positions all sit outside the current tick reports its full value here, but the API does not simulate it: buy_success and sell_success are null, with errorCode: "no_in_range_liquidity". Read buy_success / sell_success and errorCode before you treat a number here as a market that you can trade in.

3. Migration steps for an AI agent​

When asked to migrate a codebase from GoPlus to Tok{In}, follow this sequence:

  1. Locate the GoPlus call sites. Grep for gopluslabs.io, token_security, goplus, and known SDK imports (e.g. goplus-sdk-py, @goplus/sdk-node).
  2. Identify the auth mechanism in use. GoPlus integrations vary: anonymous, ?api_key=, or app-id/secret signed access tokens. All three collapse to a single X-API-Key header in Tok{In}.
  3. Keep or translate the chain identifier. The path accepts the numeric chain id (/token/1/...) and the slug (/token/ethereum/...). The response always names the slug in chain. See §6 for the full mapping.
  4. Update the URL builder and add the X-API-Key header. Append ?response_format=goplus if the existing parser relies on GoPlus field names and "1"/"0" strings; otherwise migrate to the native dedaub schema in the same change.
  5. Audit field reads. Cross-reference every field the code reads against §4. Pay attention to:
    • Computed fields (transfer_pausable, is_anti_whale, etc.): these are derived, not 1:1.
    • Always-null fields (§4): if downstream code branches on them, simplify the branch or wire them to a different signal.
  6. Update error handling. Tok{In} returns standard HTTP codes (§7); GoPlus historically returned 200 with a code field in the body. Replace body-code checks with HTTP status checks.
  7. Update rate-limit handling. Tok{In} emits standard Retry-After plus X-RateLimit-* and X-Quota-* headers (§8). Wire any retry/backoff logic to those headers.
  8. Run the test suite. If there isn't one for token-security calls, add at least one integration test against a known token (e.g. USDC on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) before declaring the migration done.

Do not silently delete GoPlus fields the user's code reads; flag them as TODOs (especially the always-null set in §4) so the developer can decide what to do.


4. Field mapping (Tok{In} dedaub → GoPlus)​

When response_format=goplus, the server applies these mappings. All booleans become the strings "1" (true) or "0" (false).

Direct mappings​

Tok{In} (Dedaub)GoPlusNotes
is_open_sourceis_open_sourcebool → "1"/"0"
is_proxyis_proxybool → "1"/"0"
mint_or_burn_functionis_mintablebool → "1"/"0"
owner_addressowner_addressstring; not a renouncement signal (see below)
can_selfdestructselfdestructbool → "1"/"0"
external_callexternal_callbool → "1"/"0"
is_in_dexis_in_dexbool → "1"/"0"
receive_taxbuy_taxpercentage as string ("1" is 1%)
send_taxsell_taxpercentage as string ("1" is 1%)
cannot_buycannot_buybool → "1"/"0"
tax_can_be_modifiedslippage_modifiablebool → "1"/"0"; not owner-gated
trading_cooldowntrading_cooldownbool → "1"/"0"
creator_addresscreator_addressstring
creator_percentcreator_percentpercentage as string ("0.02" is 0.02%)
is_launchpad_tokenlaunchpad_tokenbool → "1"/"0"

⚠ Tax direction is inverted. send_tax → sell_tax and receive_tax → buy_tax. Make sure any code computing slippage matches the renamed semantic.

⚠ buy_tax / sell_tax are pool-aware. The mapper first walks features.dex[] and uses the first pool with non-null buy_tax / sell_tax, falling back to the contract-level receive_tax / send_tax only if no pool reports a value. The result is usually closer to real swap behavior than GoPlus's contract-level tax, but it can disagree with the contract-level fields in the same response.

⚠ buy_tax, sell_tax and creator_percent are percentages, not the 0–1 scale of GoPlus. "1" is 1%. The GoPlus API sends "0.01" for 1%. Code written for GoPlus reads these values as 100 times too high, so convert them at the boundary. holders[].percent uses the 0–1 scale of GoPlus.

Additional fields populated in goplus mode​

Beyond the GoPlus-spec fields, the response also carries:

  • token_name, token_symbol, total_supply: populated when known.
  • holders[]: array of { address, balance, percent }. Note that holder_count itself is null (see below) but the holders[] array is populated.

Computed mappings (derived, not 1:1)​

GoPlus fieldDerived fromLogic
transfer_pausablecannot_buy, pause_status_can_be_modifiedTrue if either is True. Reads the pause setter after the owner gate
is_blacklistedhas_blacklist_or_whitelistsame value (temporary)
is_whitelistedhas_blacklist_or_whitelistsame value (temporary)
is_anti_whalehas_trading_cap, has_position_capTrue if either is True
anti_whale_modifiabletrading_cap_can_be_modified, position_cap_can_be_modifiedTrue if either is True. Reads both setters after the owner gate
hidden_ownerallowance_bypass, privileged_spendersTrue when the bypass is address specific, so at least one address is named
owner_change_balanceowner_can_change_balancesame value; null while the owner simulation is off (it is off today)

⚠ Blacklist/whitelist ambiguity. Both flags map from the same source, so the API cannot distinguish blacklist-only, whitelist-only, or both. Code that branches on one vs. the other should be reviewed.

⚠ hidden_owner is tri-state underneath. It maps from the allowance-bypass check, and is "1" only in the address-specific case: a universal bypass (allowance_bypass true, privileged_spenders empty) means the token authorises nobody in particular, which is a broken ERC20 rather than a hidden owner, so it maps to "0". It is null when the check could not run, which is not the same as "0". Treat null as unknown, never as safe. In dedaub mode read allowance_bypass and privileged_spenders directly; the latter carries the actual addresses that can drain a holder.

⚠ The owner gate. The API assumes that only the owner can call the pause and cap setters. When ownership_renounced is true, it sends false for pause_status_can_be_modified, trading_cap_can_be_modified and position_cap_can_be_modified. So transfer_pausable: "0" and anti_whale_modifiable: "0" do not mean that the code has no setter. slippage_modifiable is not owner-gated: the tax setter keeps its static result.

⚠ owner_address is not a renouncement signal. It is the answer of an owner getter (owner(), getOwner() or _owner()). For a proxy, the API reads it through the proxy. It is null when no getter answered, and then the verdict of the API can be "renounced" or "not determined". A burn owner can be 0x…dEaD or 0x…0001, not only the zero address (§7 lists the five burn addresses). GoPlus mode does not send the verdict. Read ownership_renounced in dedaub mode.

Currently always null​

These GoPlus fields exist in the response shape but are not yet tracked by Tok{In}. If downstream code reads them, treat the read as a TODO and either remove the dependency or substitute a different signal:

  • cannot_sell_all
  • can_take_back_ownership
  • gas_abuse
  • personal_slippage_modifiable
  • lp_total_supply
  • is_airdrop_scam
  • trust_list
  • holder_count
  • owner_balance
  • owner_percent
  • owner_change_balance (the owner simulation is off today; the field gets a value when the simulation is on)
  • is_honeypot (not listed in upstream public docs but always returns null today)

⚠ is_honeypot is the field GoPlus consumers most often gate trades on. Migration should not treat its absence as "not a honeypot". Derive an equivalent signal from cannot_buy, the per-pool dex[].buy_success/sell_success (in dedaub mode), and tax fields, or block on the field being unavailable.

⚠⚠ buy_success / sell_success are not booleans. They are four-valued: true, false, "pending" (simulation still running) and null (no simulation was attempted for that pool). null is common for bonding-curve pools and for pools under $100 of liquidity. null and "pending" mean unknown and carry no signal. A honeypot check written as if (!pool.sell_success) will flag every healthy token whose pool was not simulated. Test === false explicitly. See tokin.md.

Fields only in dedaub mode​

GoPlus mode has no field for these results. Read them in dedaub mode:

  • ownership_renounced: the ownership verdict. true (renounced), false (live) or null (not determined). null is an answer; never show it as renounced.
  • timebomb_status and timebomb_deadlines: where the time checks of the code stand now. Only passed and unreachable turn timebomb off.
  • funds_can_be_drained: somebody other than the holder can move tokens out of a wallet. null is not false.
  • allowance_bypass and privileged_spenders: the mechanism and the addresses. hidden_owner gives only part of this.

For the full GoPlus mapping (direct, computed, and always-null fields), see tokin.md.


5. Code examples​

Python (requests)​

import os, requests

API_KEY = os.environ["TOKIN_API_KEY"]
BASE = "https://tokin-api.dedaub.com"

def token_security(chain: str, address: str, *, goplus_compat: bool = True) -> dict:
params = {"response_format": "goplus"} if goplus_compat else None
r = requests.get(
f"{BASE}/token/{chain}/{address}",
params=params,
headers={"X-API-Key": API_KEY},
timeout=30,
)
if r.status_code == 429:
retry_after = int(r.headers.get("Retry-After", "60"))
raise RuntimeError(f"rate limited; retry after {retry_after}s")
r.raise_for_status()
return r.json()

TypeScript (fetch)​

const TOKIN_BASE = "https://tokin-api.dedaub.com";

export async function tokenSecurity(
chain: "ethereum" | "binance" | "base" | "arbitrum" | "avalanche" | "robinhood",
address: string,
{ goplusCompat = true } = {},
) {
const url = new URL(`${TOKIN_BASE}/token/${chain}/${address}`);
if (goplusCompat) url.searchParams.set("response_format", "goplus");

const res = await fetch(url, {
headers: { "X-API-Key": process.env.TOKIN_API_KEY! },
});

if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
throw new Error(`rate limited; retry after ${retryAfter}s`);
}
if (!res.ok) throw new Error(`tokin ${res.status}: ${await res.text()}`);
return res.json();
}

Go (net/http)​

Idiomatic client with context.Context, wrapped errors, and a typed ErrRateLimited so callers can branch on errors.As and respect Retry-After:

package tokin

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
)

// ErrRateLimited is returned when Tok{In} responds with HTTP 429.
// Callers should sleep for RetryAfter before retrying.
type ErrRateLimited struct{ RetryAfter time.Duration }

func (e *ErrRateLimited) Error() string {
return fmt.Sprintf("tokin rate limited; retry after %s", e.RetryAfter)
}

// Client is a minimal Tok{In} HTTP client. Zero value is not usable; call NewClient.
type Client struct {
APIKey string
BaseURL string
HTTP *http.Client
}

// NewClient builds a Client with sensible defaults. APIKey is read from TOKIN_API_KEY.
func NewClient() *Client {
return &Client{
APIKey: os.Getenv("TOKIN_API_KEY"),
BaseURL: "https://tokin-api.dedaub.com",
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}

// TokenSecurity fetches token-safety analysis for the given chain and address.
// When goplusCompat is true, the response is reshaped to GoPlus's schema with
// "1"/"0" string booleans.
func (c *Client) TokenSecurity(ctx context.Context, chain, address string, goplusCompat bool) (map[string]any, error) {
u, err := url.Parse(fmt.Sprintf("%s/token/%s/%s", c.BaseURL, chain, address))
if err != nil {
return nil, fmt.Errorf("build url: %w", err)
}
if goplusCompat {
q := u.Query()
q.Set("response_format", "goplus")
u.RawQuery = q.Encode()
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("X-API-Key", c.APIKey)

resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusTooManyRequests {
secs, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
if secs <= 0 {
secs = 60
}
return nil, &ErrRateLimited{RetryAfter: time.Duration(secs) * time.Second}
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("tokin %d", resp.StatusCode)
}

var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode body: %w", err)
}
return out, nil
}

For typed access, replace map[string]any with a generated struct that mirrors the response shape documented in tokin.md.

Rust (reqwest + tokio)​

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
use std::env;
use std::time::Duration;

use reqwest::{Client, StatusCode};
use serde_json::Value;
use thiserror::Error;

const BASE: &str = "https://tokin-api.dedaub.com";

#[derive(Debug, Error)]
pub enum TokinError {
#[error("rate limited; retry after {0:?}")]
RateLimited(Duration),
#[error("tokin {status}: {body}")]
Http { status: u16, body: String },
#[error(transparent)]
Request(#[from] reqwest::Error),
#[error("missing TOKIN_API_KEY")]
MissingKey,
}

/// Fetch token-safety analysis. When `goplus_compat` is true, the response uses
/// GoPlus's schema with `"1"`/`"0"` string booleans.
pub async fn token_security(
chain: &str,
address: &str,
goplus_compat: bool,
) -> Result<Value, TokinError> {
let api_key = env::var("TOKIN_API_KEY").map_err(|_| TokinError::MissingKey)?;
let url = format!("{BASE}/token/{chain}/{address}");

let mut req = Client::new()
.get(&url)
.header("X-API-Key", api_key)
.timeout(Duration::from_secs(30));
if goplus_compat {
req = req.query(&[("response_format", "goplus")]);
}

let resp = req.send().await?;

if resp.status() == StatusCode::TOO_MANY_REQUESTS {
let secs = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60);
return Err(TokinError::RateLimited(Duration::from_secs(secs)));
}

let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(TokinError::Http { status: status.as_u16(), body });
}

Ok(resp.json().await?)
}

For typed access, replace serde_json::Value with a #[derive(Deserialize)] struct mirroring the GoPlus schema (or the native Dedaub shape documented in tokin.md).

Chain-id translation helper​

This helper is optional. The path also accepts the chain id.

If existing GoPlus code carries numeric chain ids:

const CHAIN_ID_TO_NAME: Record<number, string> = {
1: "ethereum",
56: "binance",
8453: "base",
42161: "arbitrum",
43114: "avalanche",
4663: "robinhood",
};

6. Supported chains​

Name (use in path)Chain ID
ethereum1
binance56
base8453
arbitrum42161
avalanche43114
robinhood (New)4663

Any other chain GoPlus supports (Polygon, Optimism, Solana, etc.) is not currently available on Tok{In}. Surface this as a blocker rather than silently dropping the call.

GoPlus also serves Robinhood Chain (4663). The path accepts the chain id as well as the slug: /token/4663/... and /token/robinhood/... give the same analysis, and the chain field of the response is always the slug. If the codebase lists supported chains as a closed list (union type, enum, allowlist), add Robinhood Chain (robinhood or 4663) to it. Do not let 4663 fall through to the unsupported-chain path.


7. HTTP response codes​

CodeMeaning
200Success
401Missing, unknown or expired API key
402A trial allowance is spent (§2.2). Not retryable — see below
422The address is not a token that the API can analyse. Reasons: a malformed EVM address, a burn address, no code at the address, or code that does not answer the ERC-20 calls. error_code tells you which (§7.1). An unknown chain or a bad query value also gives 422, with a FastAPI detail list and no error_code
429Rate limit or quota exceeded
500Internal server error (or upstream analysis failure)

The five burn addresses are 0x0000000000000000000000000000000000000000, 0x000000000000000000000000000000000000dEaD, 0x0000000000000000000000000000000000000001, 0xdead000000000000000042069420694206942069 and 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead. The match is exact and ignores letter case. The same set gives burned_supply, burn_addresses and the true value of ownership_renounced for a burn owner. It has five addresses since 2026-10-03, so burned supply figures can be higher than in an older response.

On a 422 or a 500 with an error_code, the envelope is still {chain, contract_address, features: null, error: "<reason>", error_code: "<code>"}. error_code is the stable reason; the error string is prose and can change.

GoPlus's habit of returning 200 with an in-body code field does not apply here. Branch on HTTP status and error_code instead.

7.1 error_code, and why 402 is not 429​

Most refusals have a machine-readable error_code. Some do not: an unknown key (401 {"detail": "Invalid API Key"}), a rate-limit 429 (only detail and the headers of §8), an unknown chain or a bad query value (422 with a FastAPI detail list), refresh or redecompile without the plan (403 with detail), and a 503 from POST /agent-keys. When error_code is absent, branch on the HTTP status.

A refusal about the token is in the envelope of §2.1. A refusal about you has error_code at the top level of a different body:

{
"error_code": "api_key_required",
"error": "API Key is missing. Send it as the X-API-Key header.",
"detail": "API Key is missing. Send it as the X-API-Key header.",
"next_step": "Humans: sign in and create a key. Automated callers: POST /agent-keys with a `reason` for a free trial key."
}
error_codeStatusWhat to do
invalid_address422Fix the address. Do not retry it.
burn_address422A burn address is not a token. Do not retry it.
not_a_contract422No code at the address on this chain. Check the chain. Do not retry the same pair.
not_a_token422The contract does not answer the ERC-20 calls. Do not retry it.
invalid_token_data422The analysis gave a result that is not valid. A retry can give the same result.
analysis_failed500A fault on our side. Retry later, with a backoff.
api_key_required401Get a key (§2.2). Do not retry without one.
api_key_expired401The key is past its expires_at. A trial key does not renew. Sign in for a full key.
free_quota_exhausted402Stop. The allowance does not refill. Sign in for a full account.
agent_trial_already_issued409POST /agent-keys only. This client network already has a trial key, and the API does not show it again.
agent_trial_expired403POST /agent-keys only. The trial of this client network has ended. It does not renew.
agent_registration_throttled429POST /agent-keys only. Wait for Retry-After, or sign in.

Branch on error_code, never on the prose, which gets reworded.

The 402 is deliberately not a 429, and the distinction matters most to you. A 429 means "wait and try again", and a correct client sleeps and retries. A spent trial allowance never becomes available again, so the same retry loop runs forever and never succeeds. If your client has one retry policy for all 4xx, give 402 its own branch that stops.


8. Rate limits & quotas​

There are four independent enforcement tiers, each emitting its own header set on 429:

TierHeaders on 429Retry-After?
Burst (10 s)Retry-After, X-RateLimit-Burst-Limit, X-RateLimit-Burst-Window, X-RateLimit-Remainingyes
Per-minute QPSRetry-After, X-RateLimit-Limit, X-RateLimit-Remainingyes
Daily quotaRetry-After, X-Quota-Daily-Limit, X-Quota-Daily-Usedyes
Monthly quotaX-Quota-Monthly-Limit, X-Quota-Monthly-Usedno

Two practical implications:

  • Retry strategy must branch on which header set is present. Burst, per-minute and daily 429s have a Retry-After you can sleep on; a monthly-quota 429 means "wait until next billing period" and there is no header telling you when. Treat that case as fatal for the current period.
  • Rate-limit and quota headers are emitted only on the failing 429, not on successful responses. Clients cannot proactively monitor remaining budget, only learn about it on rejection. Pace requests defensively rather than racing the budget.

Trial keys (§2.2) are a separate case and behave differently. All four tiers above still apply. A trial key also has a lifetime total (api_calls_total), which the API checks before the daily quota. When the total is spent, the API answers 402 with error_code: free_quota_exhausted, not 429. It is the one quota refusal that does not clear with time. It has its own status so that a retry loop can tell it apart from a wait.


9. Common migration pitfalls​

  • Reading fields at the wrong nesting level. GoPlus puts fields under result["<address>"]; Tok{In} puts them under features (see §2.1). This is the most common silently-wrong outcome of an unreviewed AI migration.
  • Trusting is_honeypot as a kill switch. It always returns null today (§4). GoPlus integrations that gate trades on is_honeypot === "1" will treat every token as safe after migration. Replace with a derived signal (cannot_buy, per-pool dex[].buy_success/sell_success, tax checks) or hard-block when the field is unavailable.
  • Treating a per-pool null as a failed trade. dex[].buy_success / sell_success return null when a pool was never simulated and "pending" while one is running; neither is a "no". Falsy checks turn both into "cannot sell", which is the inverse of the truth for a perfectly tradeable token (§4).
  • Treating "1"/"0" as truthy/falsy in JS. The string "0" is truthy. Use explicit === "1" checks or convert at the boundary.
  • Reading always-null fields without a guard (see §4). If a GoPlus integration relies on holder_count or owner_percent, they must be sourced elsewhere or the feature degraded.
  • Reading hidden_owner === "0" as "no hidden owner". It is null whenever the allowance-bypass check could not run, and "0" for a universal bypass, which is worse rather than better (§4). Anything gating on drain risk should read allowance_bypass and privileged_spenders in dedaub mode.
  • Forgetting the tax inversion. send_tax/receive_tax (Tok{In} native) are not the same direction as sell_tax/buy_tax until the GoPlus mapper renames them. Note also that buy_tax/sell_tax may come from per-pool DEX data (§4) and disagree with the contract-level tax.
  • Reading taxes in goplus mode on the GoPlus scale. buy_tax, sell_tax and creator_percent are percentages: "1" is 1%, not 100% (§4).
  • Deciding renouncement from owner_address. Read ownership_renounced in dedaub mode. A burn owner can be 0x…dEaD or 0x…0001, not only the zero address, and owner_address: null can mean "renounced" or "not determined" (§4).
  • Using a fallback when the API sent ownership_renounced: null. null is an answer: "not determined". Show it as unknown, never as renounced. Use your own fallback only when the key is absent, for example in a response that you stored before 2026-08-24.
  • Reading timebomb: true as "a trigger is pending". timebomb stays true for the statuses armed, resettable and unknown. Only armed has a deadline that is still ahead. Read timebomb_status.
  • Comparing a block deadline with the L2 block number. On Arbitrum and Robinhood Chain, a block value in timebomb_deadlines is an Ethereum block number. Use its passed field. Do not use estimated_unix_time of a block for a decision. It is an estimate, and on Binance Smart Chain it can move by minutes between two responses.
  • Reading transfer_pausable: "0" as "the code has no pause". It reads the pause setter after the owner gate (§4). The code can still have a pause mechanism (has_pause_functionality in dedaub mode).
  • Retrying a burn_address refusal. A 422 with error_code: burn_address, invalid_address, not_a_contract or not_a_token is a final answer about the address (§7.1). Do not retry it.
  • Using a chain that Tok{In} does not serve. An unknown chain gives 422 with a FastAPI detail list, not 404.
  • Expecting a second registration to yield a second allowance. The API issues one trial key for each client network (§2.2). A second request gets 409 agent_trial_already_issued and no key. A client that reacts to a 402 by registering again never gains a call.
  • Retrying a 402 as though it were a 429. The allowance does not refill (§7.1). A shared 4xx retry policy turns this into an infinite loop that never succeeds and looks, from the outside, exactly like abuse.
  • Confusing response_format=goplus with the data being GoPlus's data. The schema is GoPlus-shaped, but the underlying analysis is Dedaub's (Gigahorse-based bytecode analysis). Detection coverage and false-positive characteristics will differ.

10. When to drop GoPlus compatibility mode​

response_format=goplus is a migration aid, not the long-term recommendation. Once the rest of the integration is stable, prefer the native dedaub schema for:

  • typed booleans (no "1"/"0" parsing),
  • richer tax structure,
  • access to fields not representable in the GoPlus shape (e.g. structured cap modification flags, and privileged_spenders, the actual addresses that can drain a holder, which hidden_owner flattens to a single boolean),
  • the ownership verdict and the timebomb result (ownership_renounced, timebomb_status, timebomb_deadlines), and funds_can_be_drained (§4),
  • forward compatibility as new detectors land.

The native response shape and field reference are documented in tokin.md.