get a key
steadybase-$ docs get a key
routes
routestatusat build
GET /v1/accounts 500 deployed, failing
GET /v1/whats_new 500 deployed, failing
GET /accounts/bulk 404 deployed
POST /accounts/resolve 404 deployed

This page is generated from the document that specifies these routes. A status above that is not 200 is what the route itself returned when this page was built.

1. How a key is minted, rotated and revoked today

Three scripts, all in the fin45 intelligence-ingest repo, all run from the box in the venv.

Mint: scripts/issue_tenant_key.py

For a new tenant, or an extra key on a tenant that already has one.

.venv\Scripts\python.exe -m scripts.issue_tenant_key ^
    --tenant-slug andrew_lab --tenant-name "Andrew lab" ^
    --book book:the tenant --book PL,ROMA,AMBQ ^
    --entitlement navan_read ^
    --board-prefix app/tenants/andrew_lab ^
    --rate-per-min 60 ^
    --secret-name gtmsignals/tenant-keys/andrew-lab ^
    --dry-run

Swap --dry-run for --apply to write. One apply does six things, in order:

  • Refuses a board prefix that equals, sits inside, or contains another live tenant's prefix. Nesting is how a list, a sync or a lifecycle rule over one tenant's namespace reaches another tenant's objects. The check runs against the live gtms-keys table, so it catches a typo before the tenant exists.
  • Mints gtms_ plus secrets.token_urlsafe(21). Same format as provision_tenant.mint_key and rotate_tenant_key, so there stays one format.
  • Writes the gtms-keys row: key_hash, tenant_id, tenant_name, tickers, board_prefix, rate_per_min, lane, entitlement, revoked=false, created_at. Conditional on attribute_not_exists(key_hash), so a re-run cannot clobber a live row.
  • Stores the value in Secrets Manager under --secret-name.
  • Writes a ledger row to Postgres app.api_key_issues, after making sure the app.tenants row its foreign key needs exists.
  • Prints a receipt: tenant id, secret name, key hash, board prefix, entitlement, book size, and the one command that reads the value. It does not print the key and there is no switch that makes it. The printer refuses any line containing the key before it prints anything, so a future edit that leaks the value fails loudly instead of quietly working.

--verify adds one live GET https://api.steadybase.io/v1/accounts?limit=1 with the new key and prints only the HTTP status.

The ledger is app.api_key_issues and not app.api_key_rotations on purpose. The rotations table is read every morning by the revoker, which revokes the key named by old_key_hash on any row past its revoke_after. An issuance has no old key, so putting one in that table means either a NULL the revoker dereferences or old_key_hash set to the new hash, which is the daily job revoking the key it just issued.

Rotate: scripts/rotate_tenant_key.py

Use it when a key has leaked: committed, pasted into a doc, printed into a transcript. The policy is rotate first, revoke later.

.venv\Scripts\python.exe -m scripts.rotate_tenant_key --tenant-slug the tenant ^
    --old-hash <sha256 of the exposed key> --note "leaked in ..." --revoke-after-days 7

You give it the sha256, never the key. Get the hash without printing the value:

python -c "import hashlib,sys;print(hashlib.sha256(sys.stdin.read().strip().encode()).hexdigest())" < key.txt

It mints a replacement with identical scope, stamps the old row with superseded_by and revoke_after, updates the Secrets Manager secret to the new value, and writes the rotation to app.api_key_rotations. Nothing is revoked. The old key keeps working for seven days so a customer who has it wired into an MCP client is not cut off with no warning.

This script does print the new key once at the end. That is the one place in the system that does, it is meant for a person at a terminal, and it is why issue_tenant_key.py exists in the shape it does.

Revoke: scripts/revoke_rotated_keys.py

Scheduled task GTMSignals-KeyRevoke, 07:30 daily.

For every app.api_key_rotations row with revoke_after <= today and revoked_at IS NULL, it counts requests by the OLD key hash in DynamoDB gtms-usage over the last seven days (both Lambdas log every authenticated call there).

  • Zero usage in seven days: sets revoked=true on the gtms-keys row and revoked_at on the ledger row. The Lambdas honour revoked on the next request. No deploy.
  • Any usage at all: does not revoke. It writes a loud item into the daily brief's Needs-Drew feed saying the customer may not have switched yet. A key still carrying traffic is a person still using it, and cutting them off on a timer is not a security improvement.

Rows not yet due are reported with their usage count, so the switchover can be watched rather than assumed.

To revoke by hand, one row, no script:

aws dynamodb update-item --region us-east-2 --table-name gtms-keys ^
  --key "{\"key_hash\":{\"S\":\"<hash>\"}}" ^
  --update-expression "SET revoked = :t" ^
  --expression-attribute-values "{\":t\":{\"BOOL\":true}}"
Where the two stores are
StoreRegionHolds
AWS Secrets Managerus-east-2the value, at gtmsignals/tenant-keys/<tenant>, shape {"api_key": "..."} plus non-secret metadata
DynamoDB gtms-keysus-east-2the registry: key_hash (sha256), tenant id and name, tickers, board_prefix, rate_per_min, lane, entitlement, revoked. Read by the gtm-api and gtm-mcp Lambdas on every request

DynamoDB gtms-usage holds one row per authenticated call (key_hash, ts, route), which is what makes the conditional revoke possible.

---

2. What a key entitles

The entitlement is a named profile stored on the gtms-keys row. There is one today, navan_read, defined as a data structure in scripts/issue_tenant_key.py (ENTITLEMENT_PROFILES) so a reader can load it rather than parse a paragraph.

navan_read covers:

  • The MCP tool surface at https://mcp.steadybase.io/mcp. the tenant's key is served 51 tools. A key without lane='private' is served 47: the four private_* tools (private_find, private_leads, private_stats, private_whats_new) are the difference. Confirmed live 2026-09-19.
  • The search service tenant routes: /search, /search/all, /why_now, /account_signals, /deals, /future_events, /future_events/changes, /people, /people/search, /people/personas, /company/lookup, /book, /book/summary, /ask, /changes, /bulk/company, /bulk/since, and the five trigger routes.
  • The account bulk pull: /accounts/bulk, /accounts/counts.
  • The account resolver: /accounts/resolve, plus the map routes.
  • The book, which is whatever tickers says on that key's row.

What it can never see: contact data is OFF.

A person comes back as full_name, title, seniority, function, the persona they match, linkedin_url, and the first and last time we saw them. No business email. No phone. LinkedIn URL is the identity you check a person against, and it is a public page.

Enforced today versus declarative today

This split matters and the script prints it on every run, so nobody has to take a doc's word for it.

Enforced by code that already runs:

  • The book. gtms-keys.tickers is read by both Lambdas on every request.
  • The rate limit. gtms-keys.rate_per_min, same readers.
  • Board prefix isolation. Refused at issuance; board reads are scoped to the prefix on the row.
  • Revocation. gtms-keys.revoked is honoured on the next request.
  • No business email. people_search.entitlement() serves a validated email only out of gtm_signals.person_contacts rows owned by the calling tenant. A new tenant owns none, so include_email returns nothing for it. This is enforced by who owns the rows, not by the profile's name.
  • No phone. That route never serves a phone number to anybody, entitlement or not.

Declarative, true only once something reads it:

  • The entitlement name itself. Nothing reads gtms-keys.entitlement yet. It is written now so the Lambdas can start honouring it later without a backfill.
  • The tool allowlist. The MCP subsets its tools by the key's lane, not by this profile. The list in the profile describes the surface; it does not gate it.
  • The route allowlist. No router consults the profile.
  • read_only. No writer checks it.

Do not describe anything in the second list as enforcement.

---

3. Wiring a client

The four snippets below are the ones from docs/MCP_NAVAN.md section 2. The endpoint is https://mcp.steadybase.io/mcp, Streamable-HTTP, JSON-RPC 2.0, stateless. Auth is Authorization: Bearer gtms_... on every request. A missing or invalid key comes back as JSON-RPC -32001 inside a normal envelope, so a client surfaces it as a tool error and not a dead transport.

Read the value once, into an environment variable:

aws secretsmanager get-secret-value --region us-east-2 --secret-id gtmsignals/tenant-keys/andrew-lab --query SecretString --output text

That command prints the value to your terminal. It goes into an environment variable and nowhere else. Not into a file, not into a commit, not into a chat, not into a ticket, not pasted to an agent. If it lands in any of those, rotate.

On Windows, without the value ever touching a file:

for /f "delims=" %i in ('aws secretsmanager get-secret-value --region us-east-2 --secret-id gtmsignals/tenant-keys/andrew-lab --query "SecretString" --output text ^| python -c "import json,sys;print(json.load(sys.stdin)[\"api_key\"])"') do setx GTM_SIGNALS_KEY %i

Open a new shell after setx. On macOS or Linux:

export GTM_SIGNALS_KEY=$(aws secretsmanager get-secret-value --region us-east-2 \
  --secret-id gtmsignals/tenant-keys/andrew-lab --query SecretString --output text \
  | python -c 'import json,sys;print(json.load(sys.stdin)["api_key"])')

In every snippet below, GTM_SIGNALS_KEY is the placeholder. The literal key never goes in the file.

Claude Code

From the project directory:

claude mcp add --transport http gtm-signals https://mcp.steadybase.io/mcp \
    --header "Authorization: Bearer $GTM_SIGNALS_KEY"

Or, in the project's .mcp.json:

{
  "mcpServers": {
    "gtm-signals": {
      "type": "http",
      "url": "https://mcp.steadybase.io/mcp",
      "headers": { "Authorization": "Bearer ${GTM_SIGNALS_KEY}" }
    }
  }
}

Check it with /mcp in the session. the tenant's key lists 51 tools; a key without the private lane lists 47.

Codex

~/.codex/config.toml:

[mcp_servers.gtm-signals]
url = "https://mcp.steadybase.io/mcp"

[mcp_servers.gtm-signals.headers]
Authorization = "Bearer ${GTM_SIGNALS_KEY}"
Cursor

.cursor/mcp.json in the project, or the global one in ~/.cursor/:

{
  "mcpServers": {
    "gtm-signals": {
      "url": "https://mcp.steadybase.io/mcp",
      "headers": { "Authorization": "Bearer ${GTM_SIGNALS_KEY}" }
    }
  }
}

Cursor shows the tool list under Settings, MCP. If it shows zero tools the key is missing or revoked: the server answers 401 to a keyless probe.

Clay

Clay calls it as an HTTP API rather than as a local MCP process. In a Clay table, add an HTTP API enrichment:

  • Method POST, URL https://mcp.steadybase.io/mcp
  • Headers: Authorization: Bearer <the key>, put in Clay's own secret store and not in the cell, plus Content-Type: application/json
  • Body, with the column you are enriching from substituted in:
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
 "params": {"name": "pg_account_signals",
            "arguments": {"ticker": "{{Ticker}}", "signal_type": "cost", "q": "travel", "limit": 5}}}
  • The answer is JSON-RPC: the tool's own JSON is a string inside result.content[0].text, so parse that field in Clay before mapping columns. Map rows[].SUMMARY to the quote column and rows[].SOURCE_URL to the receipt column, and keep them next to each other. A quote shipped without its receipt is the thing this product exists not to do.

One call per row is the pattern that works. For a whole-book pull use bulk_since on a cursor instead of a row-by-row loop.

---

4. Day one: loading a book, and proving the key works
What a book is

A book is the list of accounts a tenant cares about. It lives in Postgres as gtm_signals.tenant_book, one row per account per tenant: tenant_id, book_account_key, account_id, display_name, primary_ticker, domain, status (target, customer, watch, ignore), source, extras_json. the tenant's book is 4,614 rows. the tenant's is 25.

The book is what "my book" questions answer out of. The tenant comes from the key, never from an argument a caller can set. Nothing about the shared facts is copied per tenant: the tenant views read the same accounts, signals and transcripts through the tenant's own lens.

The key's tickers scope and the tenant's book are two different things. The tickers list on the gtms-keys row says which accounts that key may read. The tenant_book rows say which accounts are that tenant's book. Set both, and keep them consistent.

Three ways to load one
  • A folder, which is the normal route. Put book.csv in tenants/<tenant_id>/ next to icp.json and prefs.json. It needs a ticker and/or domain column (aliases symbol, website, company, name); optional account_key, display_name, status, source, account_id, extras_json; anything else rides in EXTRAS_JSON. Lines starting with # are skipped. Then:

python -X utf8 tools\onboard_tenant.py onboard <tenant_id> (dry run) python -X utf8 tools\onboard_tenant.py onboard <tenant_id> --apply

Every row is resolved to an account_id by ticker first, then domain. A row that resolves through neither is written with a null account_id and listed in the receipt. Nothing is invented.

  • The customer's own CRM export. scripts/import_tenant_accounts.py takes a Salesforce report, a HubSpot export or a spreadsheet, matches every row through scripts/account_resolver.py, writes exact matches into app.tenant_account_map and queues everything else into app.tenant_account_map_review with the candidates that made it a question. It reports the match rate by method, so the number can be argued with. Two candidates is ambiguity, never a match.
  • Straight into Postgres. the tenant and demo_crm live in Postgres (gtm_signals.tenant_icp, tenant_signal_prefs, tenant_book, tenant_score_rules) and reach Snowflake through publish_from_postgres.py --publish --tables TENANT_BOOK .... The publisher is tenant scoped: it deletes and reinserts only the tenant ids the staged rows contain, so a tenant Postgres does not carry is never touched.
The first three calls a new agent should make

Run these in order. They go from "is the key alive" to "is this tenant actually onboarded", which is the order things break in.

1. initialize, or tools/list. Proves the transport and the key.

{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}

Expect a tool array. 51 tools with the private lane, 47 without. A 401 means the key is missing, wrong or revoked.

2. list_lanes. Proves the shared corpus is readable. It needs no tenant mapping and no book, so it is the cleanest "the backend is up" call there is.

{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "list_lanes", "arguments": {}}}

Expect lane counts: sec_filing, property, web_hiring and the rest.

3. resolve_entity, then one book call. resolve_entity takes query and proves the identity layer answers for this key:

{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
 "params": {"name": "resolve_entity", "arguments": {"query": "Crocs"}}}

Expect resolved: true with an entity_id and ticker. Then try a book call such as pg_why_now or search.

What a brand new tenant sees on day one, and why. Checked on the andrew_lab key the day it was issued:

  • tools/list, list_lanes, resolve_entity, company_signals and upcoming_calendar all answer. These read the shared corpus and the public company directory.
  • get_contacts answers no contacts file published for this tenant yet. That is the right answer, and it is also what contact data OFF looks like from the outside: an abstention in words, not an empty field you have to interpret.
  • search, find_companies and every pg_* or sf_* tool answer this key is not mapped to a Snowflake tenant; ask for the tenant to be onboarded. Minting a key does not onboard a tenant. Run tools/onboard_tenant.py (section 4) before expecting a book answer.
  • The REST board routes (GET /v1/accounts, GET /v1/whats_new on https://api.steadybase.io) return HTTP 500 while the board prefix is empty. The Lambda fetches <board_prefix>/targets.json and raises NoSuchKey instead of returning an empty board. The key is fine; the handler is wrong. That is a bug, it is filed, and until it is fixed a new tenant's first REST call looks like an outage when it is really an unpublished board.

So: mint the key, load the book, onboard the tenant, publish the board. In that order. The key is step one of four and on its own it reads the shared corpus.

---

sourceGET_A_KEY.md
sha256526383abeb4fc068cf5ba9c49a8f769f
generated2026-09-20 02:19:47 UTC
docs/docs/
steadybase-$