Getting a credential and storing your first anchor
There are two ways to get a key. The console is the one you want; the admin endpoints below it are how an operator provisions things by hand, and how the test suite works.
Every response below was captured from a real run.
The console
Sign up at the console, and you get a workspace immediately. A workspace is one Tether tenant.
Create a project. A project is an application that talks to Tether —
maiden,ontra. Creating one gives you adevand aprodpartition of that project's memory.The name is lowercase letters, numbers and hyphens, and cannot be changed afterwards: it is written into every key issued for the project and checked on every request, so a rename would orphan every live key.
Create a key. Pick the project and the environment. The key appears once, in a dialog that will not let you dismiss it by accident.
That is the only time Tether will show it. Only a SHA-256 of the key is stored. If you lose it, revoke it and make another — there is no way to recover one.
Use it. The key arrives already granted
read,writeanddeleteon its project'sdefaultspace, so it works immediately. Skip to Confirm which scope your key reaches below.
The project page also shows how much of the rate limit that project is using. See Rate limits.
The operator path
/v1/admin/* sits behind a single root credential held as a Worker secret. It
can create tenants, scopes, credentials and grants. It carries no scope and is
bound by no grant, which makes it the most dangerous value in the system.
You need it to provision the first tenant, and the test suite uses it throughout. If you have a console account, you do not need it at all.
It is not a tenant credential, and the two are never interchangeable: a
tenant key presented to an admin route gets 401, and the root token
presented to a tenant route gets 401. The console's own service token is a
third secret again, and is refused by both.
Generate one so it never lands in your shell history or a transcript:
openssl rand -base64 32 > /tmp/tether-admin
cd apps/api && printf 'ADMIN_TOKEN=%s\n' "$(cat /tmp/tether-admin)" > .dev.vars
.dev.vars is gitignored. In production it is a Worker secret:
wrangler secret put ADMIN_TOKEN --env cloud. Never in wrangler.jsonc.
Admin routes are deliberately absent from docs/api/openapi.json, and a
test enforces their absence. So are the console's. An integrator should never
see either.
The four objects
tenant ──< scope a partition: (tenant, environment, app, space)
│
└────< credential ──< grant >──── scope
A credential belongs to one app in one environment, the way a Stripe key belongs to one account in test or live mode. A grant is what connects a credential to a scope and says which operations it may perform there.
The console does all four of these for you: a project is the two scopes, and creating a key writes the credential and its grant together.
1. Tenant
curl -X POST "$TETHER/v1/admin/tenants" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"nynth"}'
{ "id": "ten_01M2Y01J3VN9THSEZ53W5BASTY", "name": "nynth" }
2. Scope
curl -X POST "$TETHER/v1/admin/scopes" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId":"ten_…","environment":"dev","app":"maiden","space":"default"}'
{
"id": "scp_01M2Y01J5AJ6TQ7MN2V13DX0GP",
"tenantId": "ten_01M2Y01J3VN9THSEZ53W5BASTY",
"environment": "dev", "app": "maiden", "space": "default"
}
space is the only scope component a request ever supplies. Tenant, app and
environment come from the credential and cannot be set by a caller (T5).
3. Credential
curl -X POST "$TETHER/v1/admin/credentials" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"tenantId":"ten_…","name":"maiden-dev","app":"maiden","environment":"dev"}'
{
"id": "cred_01M2Y01J68B4Y1J99MV43F0Q4Q",
"prefix": "tth_dev_jPJf_KUn",
"key": "tth_dev_…"
}
key is returned exactly once and never again. Only its SHA-256 is
stored. prefix is safe to display and log; the full key is not.
The prefix encodes the environment, and authentication checks that the key's
prefix agrees with the credential's stored environment — so a tth_prod_ key
carrying environment: "dev" is rejected rather than silently resolving
against dev scopes.
Unlike the console, this endpoint does not grant anything. A credential created here reaches nothing until step 4.
4. Grant
curl -X POST "$TETHER/v1/admin/grants" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"credentialId":"cred_…","scopeId":"scp_…","operations":["read","write","delete"]}'
{ "id": "gr_01M2Y01J8JVF3HXW2M3997MSHR" }
operations is any of read, write, delete. An optional subjectLimit
restricts the grant to a single subject.
Note that forget and delete both require delete, not write. Hiding
a fact is destructive from a reader's point of view, and a credential trusted
to add facts is not automatically trusted to make them disappear.
Confirm which scope your key reaches
curl "$TETHER/v1/whoami" -H "Authorization: Bearer $KEY"
{
"credentialId": "cred_01M2Y01J68B4Y1J99MV43F0Q4Q",
"tenantId": "ten_01M2Y01J3VN9THSEZ53W5BASTY",
"app": "maiden", "environment": "dev"
}
This is the first call to make when a write returns not_found for a space
that looks correct — the key may simply act for a different app or
environment than you assumed.
Store an anchor
curl -X POST "$TETHER/v1/anchors" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: your-unique-key' \
-d '{"space":"default","kind":"decision","content":"We deploy on Tuesdays."}'
{
"id": "anc_01M2Y020PWMAWH9YQMKTEK53FK",
"chainId": "anc_01M2Y020PWMAWH9YQMKTEK53FK",
"version": 1,
"stored": true,
"searchable": false
}
Three things in that response matter.
chainId equals id. A new anchor is the root of its own version chain.
Correct it later with PATCH rather than storing a second anchor.
stored and searchable are separate (T10), and searchable is false.
Indexing is asynchronous: measured against real Vectorize, time-to-searchable
is p50 ~29s and p95 ~45s, and the drain runs on a one-minute cron.
What that means depends on which door you use, and the difference matters:
POST /v1/searchwill not find it yet. It is pure vector search, and there is no vector until the anchor is indexed.POST /v1/contextreturns it immediately, labelledvia: "recent"withscore: null. It reads not-yet-indexed anchors straight out of D1.
So you do not need to poll or sleep after a write if you retrieve through
/v1/context. Guide 05 covers this properly.
Idempotency-Key is required on every mutation. Send the same key with
the same body and you get the same response back, no second anchor:
{ "id": "anc_01M2Y020PWMAWH9YQMKTEK53FK", … }
Send the same key with a different body and you get 409:
{ "error": { "code": "idempotency_conflict", "message": "idempotency_conflict" },
"requestId": "req_d4244c5e-…" }
Keys are scoped per space and expire after 24 hours.
Rate limits
Requests are limited per credential — not per IP and not per endpoint — at 120 requests per 60 seconds. Keying on the credential is deliberate: by IP, every tenant behind one NAT would share a budget; by path, one tenant's traffic would throttle everyone else calling the same endpoint.
Over the limit returns 429:
{ "error": { "code": "rate_limited", "message": "rate_limited" }, "requestId": "req_…" }
Back off and retry; the window is short. Every mutation carries an
Idempotency-Key, so a retry after a 429 cannot double-write.
This is easy to hit accidentally. Writing these guides, a polling loop issuing
one /v1/search per second for 23 seconds exhausted the budget and the next
request — a DELETE — came back 429. If you poll, poll slowly.
Admin and console routes are not rate limited: they carry the root token or the console service token rather than a tenant credential, so there is no credential to key on.
Seeing it before you hit it
429s are not the only signal, and waiting for them is the expensive way to
find out. Every authenticated request is counted per credential per minute,
and the console's project page reports:
- peak requests in any single minute, against the 120 ceiling — headroom, measured on the worst minute rather than an average, because an average over a day reports a key that pinned the limit at noon as using a fraction of a percent;
- how many requests were refused in the window;
- when each key was last used, which is how you find the key still in production that you thought was retired.
Note what the console does not show: a live "37 of 120 used right now". Cloudflare's rate limiter binding answers pass or fail and nothing else, so that number is not obtainable, and the console reports peak observed load rather than inventing it.
Read it back
curl "$TETHER/v1/anchors/$ID?space=default" -H "Authorization: Bearer $KEY"
{
"id": "anc_01M2Y020PWMAWH9YQMKTEK53FK",
"chainId": "anc_01M2Y020PWMAWH9YQMKTEK53FK",
"version": 1, "state": "current",
"kind": "decision", "content": "We deploy on Tuesdays.",
"subjectId": null, "isInference": false,
"assertedBy": "cred_01M2Y01J68B4Y1J99MV43F0Q4Q",
"validFrom": null, "validTo": null,
"recordedAt": 1789860578012
}
Direct reads by id work immediately — they hit D1, not the index. It is only search that waits.
When a space is not yours
curl -X POST "$TETHER/v1/anchors" … -d '{"space":"someone-elses-space", …}'
{ "error": { "code": "not_found", "message": "not_found" }, "requestId": "req_…" }
not_found, not forbidden — and deliberately so (T7). A space you hold no
grant for is indistinguishable from one that does not exist. If the two
differed, any valid key could enumerate other tenants' space names.
The same rule applies to anchor ids: an id belonging to another tenant returns
not_found, identically to an id nobody ever issued.