Tables
Workspace tables: lists every agent shares between sessions, merged by identity, with a suppression list and a repeat-send guard on every send, and enrichments saved into rows never bought twice.
A table is a list your workspace keeps between sessions: the leads a campaign enriches, the creators you are about to message, the companies you monitor. Every agent on the workspace reads and writes the same rows (Claude Code, Codex, a claude.ai or ChatGPT connector, a teammate's agent, a scheduled routine), so a routine can pick up where the last session stopped without a CSV on anyone's disk.
A row is JSON you shape. RouterGrowth adds four things on top:
- Identity. Every row is keyed on a person (email, then LinkedIn URL, phone, or a handle with its platform, such as
instagram:janedoe) or, in a company table, a domain. Writing the same person twice merges into one row, whichever identity you name them by. - A suppression list. Anyone on it is never sent to again, on any channel a row ties them to. The check runs on the server before every send.
- Touches. Every successful send is recorded and stamps the rows of the people it reached (
touches,last_touch_at,last_touch_channel). - Saved results. An enrichment saved into a row is not bought again: a repeat of it in the last 30 days is answered from the row for free.
Tables are free to use. A workspace holds up to 25,000 rows and 20 tables pay per call, 100,000 on Plus, 500,000 on Pro and 2,000,000 on Scale, with at most 50,000 rows and 16 KB of data per row.
Turn tables on
Tables are off until a workspace turns them on, and a workspace with them off works exactly as before: no table tools, no checks on its sends. Turning them on changes how sends behave (suppressed people and repeat first messages are blocked), so it is a choice the workspace makes.
GET /v1/features {"tables": {"enabled": false, ...}}
POST /v1/features {"tables": true} on; false turns them off again
Or routergrowth features --enable tables, or the switch on the dashboard's Billing page. Turning them on also indexes the workspace's earlier sends, so the repeat guard knows who was already contacted. Turning them off keeps your tables and suppression list but stops using them. Until tables are on, every route below answers 403 forbidden, and a run that passes record or into is refused before anything is spent.
Nobody has to ask for a table
Agents create them as a side effect of the job. The rules they follow (they ship in the MCP server instructions and in SKILL.md):
- A list you will enrich, contact, monitor or hand over goes in a table. A one-off lookup does not.
- Name it after the job,
skincare-creators-fr, with a one-line description, and tell the user the name once. - The first write creates it: a run with
into, or an upsert. - Start every session with
tables(free) and resume an existing table instead of starting a new one.
Write rows
POST /v1/tables/{name}/rows
curl -X POST https://api.routergrowth.com/v1/tables/skincare-creators-fr/rows \
-H "Authorization: Bearer $ROUTERGROWTH_API_KEY" -H "Content-Type: application/json" \
-d '{"description": "French skincare creators, 10k to 100k followers",
"rows": [{"platform": "instagram", "handle": "janedoe", "followers": 22000, "status": "found"},
{"name": "Lee Park", "email": "lee@acme.com", "linkedin_url": "https://www.linkedin.com/in/leepark"}]}'
Up to 500 rows per call. status and next_at are row fields, everything else is data. New values win, and empty ones never erase what a row holds ("mode": "replace" swaps the whole object instead). A top-level status in the body applies to the rows this call creates.
{"table": "skincare-creators-fr", "created": true, "inserted": 2, "updated": 0, "unchanged": 0,
"conflicts": [], "refused": [],
"keys": {"inserted": ["instagram:janedoe", "lee@acme.com"], "updated": []},
"also_in": [{"table": "beauty-q3", "count": 1, "by_status": {"sent_j0": 1}, "contacted": 1}],
"hints": ["1 of these are already in 'beauty-q3', 1 of them contacted."]}
also_in names the other tables that already hold the same people. refused rows carry no identity the table keys on. conflicts are rows whose identities point at two different existing rows: nothing is written for them.
Query rows
GET /v1/tables/{name}/rows?status=approved&due=true&fields=email,first_name&limit=50
POST /v1/tables/{name}/rows/query
{"status": ["sent_j0"], "due": true,
"where": {"email_status": "valid", "followers": {"gte": 10000}, "phone": {"exists": false}},
"fields": ["email", "first_name", "j0_message_id"], "order": "-followers", "limit": 50}
where takes a value for equality or {op: value} with eq, ne, in, nin, exists, empty, contains, gt, gte, lt, lte. Numbers compare as numbers, even when stored as strings; dotted paths reach nested fields (profile.followers). The row fields status, next_at, touches and last_touch_at filter the same way, with times as ISO or relative (-7d). due: true keeps rows whose next_at has passed. Pages are 50 rows by default and 500 at most; pass next_cursor back as cursor. Keep fields short: a routine polling a table should not pull every column each time.
GET /v1/tables every table with row counts by status, and the quota
GET /v1/tables/{name} counts by status, rows due, columns with fill rates, the latest rows
Update, claim and delete
PATCH /v1/tables/{name}/rows
{"status": "approved", "limit": 30, "if_status": "approved", "set": {"status": "sending"}}
set holds status, next_at (ISO or +4d, +12h) and data fields; a null removes a field. Name rows with keys (a row's key, or any email, URL or platform:handle it carries), or with a filter and a limit. With if_status the status change is a claim: only rows still in that status change, and the answer lists exactly those, so two routines running at once never take the same rows.
POST /v1/tables/{name}/rows/delete {"keys": [...]} or a filter with limit
DELETE /v1/tables/{name}/rows/{key}
DELETE /v1/tables/{name}?confirm=true the table and its rows, for good; suppressions stay
PATCH /v1/tables/{name} title, description, columns, rename
POST /v1/tables create up front: {"name", "description", "key": "auto|domain|email|linkedin|phone|handle|field:<name>"}
Link runs to rows
Two fields on run (and batch_run over MCP) connect the paid calls to the table.
into lands a list result as rows. Posts and comments become their authors, one row per creator with the matching posts kept under signals; people and companies merge by identity, so a row already there keeps its status. Watches take into too: every refresh lands its new records.
{"capability": "social.search", "input": {"platform": "instagram", "query": "routine skincare", "limit": 50},
"routing": {"max_cost": "0.60"}, "into": {"table": "skincare-creators-fr", "status": "found"}}
record links a run to one row. On success set updates the row and save copies result fields into it ("all", or {"result field": "row field"}); on a miss on_miss applies. An unknown table or key fails before anything runs. A send linked to a row is also checked against every identity the row carries.
{"capability": "email.send",
"input": {"inbox_id": "inb_...", "to": ["lee@acme.com"], "subject": "...", "text": "..."},
"routing": {"max_cost": "0.01"}, "idempotency_key": "skincare-creators-fr:lee@acme.com:j0",
"record": {"table": "skincare-creators-fr", "key": "lee@acme.com",
"set": {"status": "sent_j0", "next_at": "+4d"},
"save": {"message_id": "j0_message_id", "thread_id": "j0_thread_id"}}}
Give every send tied to a row the idempotency key <table>:<key>:<step>: a routine retried after a crash replays the send instead of sending twice. The run's answer carries a table object with what was written.
Enrichments are not bought twice
When an enrichment (contact.find, contact.phone, person.enrich, company.enrich, company.funding, company.technologies, social.profile) was saved into a row of the workspace with record or into in the last 30 days, the same enrichment for the same subject is answered from that row: status succeeded, charged nothing, no provider called, and a source naming the table, the row and the run it came from. It works across agents and tables, and for the subject rather than the exact input (Jane, jane and https://acme.com are the same contact.find). Pass routing.fresh: true to buy it again, or pin a provider. Results are never reused across sandbox and live keys.
Suppressions
GET /v1/suppressions?kind=email&reason=bounce
POST /v1/suppressions {"subjects": ["jane@acme.com", "spam.io", "instagram:bob"], "reason": "unsubscribe"}
POST /v1/suppressions/remove {"subjects": ["jane@acme.com"]}
Subjects are emails, domains (every address on them), LinkedIn URLs, phones and platform:handle (or @handle with platform). Reasons: unsubscribe, bounce, complaint, not_interested, manual. A send to a suppressed person comes back 403 suppressed, is kept as a blocked run, and charges nothing. When a row ties an email to a LinkedIn profile, suppressing either blocks both. Remove a suppression only when the person asked to hear from you again.
Repeat first touch
A new conversation with someone the workspace already contacted on the same channel in the last 30 days comes back 409 repeat_touch, blocked and not charged, naming the earlier run. Replies in a thread pass (in_reply_to for email, chat_id for LinkedIn, Instagram and WhatsApp), and so do your own addresses, where test sends go. With the user's say-so, routing.allow_repeat: true sends anyway.
GET /v1/outbound-guard
POST /v1/outbound-guard {"repeat_touch_days": 14} 0 turns it off
CSV
GET /v1/tables/{name}/export.csv
POST /v1/tables/{name}/import?status=new the CSV as the body, first line the column names
Import upserts with the same rules as a write, 1 MB at most (about 5,000 rows), so a leads.csv from an earlier campaign becomes a table in one call.
Tools
Over MCP, once tables are on (the tools are not listed before): tables reads (no arguments to list, table to describe, filters to query rows, suppressions: true for the list) and table_write writes (action: upsert, update, delete, create, edit, drop, suppress, unsuppress). Both are free.
Reading this as an agent? This page as markdown: /docs/api/tables.md ยท every page: /docs/llms.txt