API reference
Signup
POST /v1/signup issues a development tenant and its first key with no conversation and no
credential. It is the only endpoint that answers without one.
Request
No X-Api-Key. That is the point of it.
curl -X POST $TETHER_URL/v1/signup \
-H "Content-Type: application/json" \
-d '{ "email": "dev@example.com", "name": "Example Co" }'
| Field | Type | Notes |
|---|---|---|
email | string | Required. How you are reached, and what the per-address limit counts. |
name | string | Required. 2–120 characters. The tenant's display name. |
language | string | Optional. ka writes the confirmation mail in Georgian; anything else is English. |
There is no plan field, and there will not be one. The plan is written by the service, not
read from the request — a plan a caller could name would put a paid tier one anonymous call
away.
Response
200, once, at once. The workspace exists and its key works before anyone opens their inbox:
{
"tenantId": 42,
"name": "Example Co",
"plan": "free",
"apiKey": "tth_…",
"keyPrefix": "tth_a1b2c3d4",
"signingSecret": "…",
"emailConfirmed": false,
"confirmation": {
"sentTo": "dev@example.com",
"resend": { "method": "POST", "path": "/v1/account/confirmation" },
"waits": ["public_origin", "third_party_mail", "paid_plan", "provider_grant", "child_tenant"]
},
"exportLines": "export TETHER_URL=https://api.trytether.ge\nexport TETHER_SECRET_KEY=tth_…\nexport TETHER_SECRET=…",
"mcpUrl": "https://api.trytether.ge/mcp",
"apiBaseUrl": "https://api.trytether.ge/v1",
"nextSteps": ["Paste exportLines into your shell.", "…"],
"notice": "Development and testing only: capped usage, no SLA, not a commercial tier."
}
emailConfirmed is false where the instance confirms addresses (below), and confirmation says
where the link went and what waits for it; elsewhere emailConfirmed is true and confirmation
is null.
exportLines is the same credentials as three shell lines, so one paste sets up the
CLI and the generated tool endpoint. apiKey and signingSecret are shown once. Only the key's SHA-256 is stored, so neither
Tether nor a database disclosure can produce the key again. If you lose it, get a new one by
recovering the workspace from the same address.
nextSteps is the short list of what to do with them, ending with how to approve a staged write:
POST /v1/actions/{id}/confirm with your secret key and X-Tether-User-Id set to any positive
integer, until you register approvers.
What the key can and cannot do
It is an ordinary secret key — full API surface for its own tenant, no access to key issuance or tenant creation, exactly like a pilot's. See Authentication.
What differs is the plan. It lands on free, which is development and testing only:
capped spend, no SLA, and not a commercial tier. There is no free production tier. Moving to
bring_your_ai or use_tether_ai is an operator action, so a signup key cannot reach a paid
limit on its own. When the spend cap is reached, turns are refused with
ORG_DAILY_BUDGET_EXCEEDED — see Cost and quotas. A free tenant is also
capped at 200 requests a day on the managed-AI path: over it, 429 plan_quota_exceeded with a
Retry-After header.
Confirming the address: what "live" means
An operator can require the address to be confirmed (Signup:VerifyEmail). The workspace is
created at once either way; the confirmation link is queued on the outbox and mailed in the
background, so nothing waits on it. Until the link is opened the workspace works — import, declare,
test against your real API, stage a write and approve it with your secret key, run the widget on
localhost — but it is not live. Going live is exactly five things, each refused with
403 email_unconfirmed and a reason until the address is confirmed:
reason | What waits |
|---|---|
public_origin | An allowed origin on the public internet (PUT /v1/origins). localhost, 127.0.0.1, private and link-local addresses, single-label hosts and names under .localhost, .test, .example, .invalid, .local, .internal, .lan and .home.arpa work now, so the widget runs on your machine but not on a public site. |
third_party_mail | Mail to anyone but the signup address (aliases of it count as it). An approval notice to an approver at another address is held, not dropped, and goes out when you confirm; a sign-in link for another address (POST /v1/user-sessions/sign-in) is refused. |
paid_plan | Any plan but free, so the strictest free caps hold. |
provider_grant | Provider authority (POST /v1/tenants/{id}/provider). |
child_tenant | A provider creating a business (POST /v1/provider/tenants), for a provider whose own address became unconfirmed by changing it. |
{
"error": {
"code": "email_unconfirmed",
"reason": "public_origin",
"message": "https://shop.example.ge is a public site, and the widget goes live there only after the workspace's address is confirmed. …",
"resend": { "method": "POST", "path": "/v1/account/confirmation" }
}
}
The link opens the onboarding console, in any browser, which spends it with POST /v1/signup/confirm; no credential is needed,
because the sealed token is the proof:
curl -X POST $TETHER_URL/v1/signup/confirm \
-H "Content-Type: application/json" \
-d '{ "token": "…" }'
That answers 200 { "confirmed": true, "tenantId": 42, "name": "Example Co", "email": "dev@example.com" }
and lifts all five at once, with nothing to redo: the held notices are sent, and every refused call
works when made again. The link:
- is sealed (AES-GCM under a key derived from
Signup:IpHashSalt), so the address in it cannot be read or changed and it cannot be pointed at another workspace; - expires after
Signup:LinkLifetimeMinutes(an hour); - works once, and only the newest link sent works;
- is sent at most
Signup:MaxLinksPerEmailPerHour(three) times an hour to one workspace, the first included, and asked for again at mostSignup:MaxLinksPerIpPerDay(ten) times a day from one caller.
Your secret key reads where the address stands and asks for the link again:
curl $TETHER_URL/v1/account/confirmation -H "X-Api-Key: $TETHER_SECRET_KEY"
# { "required": true, "confirmed": false, "confirmedAt": null, "email": "dev@example.com",
# "heldMail": 1, "waits": ["public_origin", …], "resend": { … } }
curl -X POST $TETHER_URL/v1/account/confirmation -H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" -d '{ "language": "ka" }'
# 202 { "confirmed": false, "sent": true, "email": "dev@example.com", … }
POST /v1/account/confirmation with an email unlike the one on file changes the address,
after the same checks signup makes (shape, disposable domains, one workspace per address), and
restarts confirmation: every earlier link stops working, and a confirmed workspace is unconfirmed
until the new address is. A workspace already confirmed with nothing to change answers 409 already_confirmed.
An instance that requires confirmation and cannot send mail refuses with 503 signup_unavailable rather than creating tenants it could never confirm. Every API instance that
shares a database must share Signup:IpHashSalt and Signup:VerifyUrl, because any instance's
outbox worker may seal a link and any may be asked to open one. An instance may also send its
operator a short notice for every new workspace; the notice never carries a key.
Where the instance does not confirm addresses, every signup is live at once, as before. A free tenant still cannot reach a paid limit or move off the free plan on its own. The address counts one development tenant per person, which is why disposable domains are refused and aliases are folded together.
A tenant an operator or a provider creates is confirmed from the start, and so is every tenant created before addresses were confirmed this way.
A signup link mailed by an earlier version, which created the workspace when opened, still works
for its lifetime at POST /v1/signup/verify.
Recovering a lost key
A workspace created here belongs to the address that confirmed it, so that address can get back
in. It works wherever confirmation links can be sent (Signup:VerifyUrl and a Mail
transport), whether or not new signups are open.
curl -X POST $TETHER_URL/v1/signup/recover \
-H "Content-Type: application/json" \
-d '{ "email": "dev@example.com" }'
The answer is 202 and the same whether or not the address has a workspace:
{
"accepted": true,
"email": "dev@example.com",
"message": "If dev@example.com has a workspace, a recovery link is on its way. It works once, for 15 minutes."
}
The mail holds a link to the onboarding console, Signup:VerifyUrl?recover=<token>, and nothing
else. A key is never sent by mail. The console spends it, or you can:
curl -X POST $TETHER_URL/v1/signup/recover/redeem \
-H "Content-Type: application/json" \
-d '{ "token": "…" }'
{
"tenantId": 42,
"name": "Example Co",
"apiKey": "tth_…",
"keyPrefix": "tth_e5f6a7b8",
"revokedKeys": 1,
"signingSecret": null,
"exportLines": "export TETHER_URL=…\nexport TETHER_SECRET_KEY=tth_…",
"note": "Every earlier secret key on this workspace is revoked (1). Publishable, agent and inbox keys keep working, and the tool signing secret is unchanged. …"
}
- The new key is shown once. Every earlier secret key stops working, so a key someone else found is useless after you recover.
- Publishable, agent and inbox keys, the tool signing secret and everything you configured stay
as they are, so your widget and approvals keep running. Send
"rotateSigningSecret": trueto replace the signing secret as well; it is then in the answer, once. - The link works once and for 15 minutes (
Signup:RecoveryLinkLifetimeMinutes). It is also void once a newer secret key exists. - The address is told afterwards, so a recovery you did not make does not go unnoticed. If that
happens, recover again from the address and then call
POST /v1/account/compromisewith the new key. - An erased workspace cannot be recovered, and its address cannot sign up again.
If you suspect a key leaked rather than went missing, use
POST /v1/account/compromise with any key you still hold: it revokes every
key, not only secret ones.
Errors
| Status | Code | Meaning |
|---|---|---|
404 | signup_disabled | Self-serve signup is not open on this instance. |
400 | invalid_email | The address is missing or not shaped like one. |
400 | disposable_email | The address is on a disposable-mail domain, or one this instance blocks. Use an address on your own domain. |
400 | invalid_name | The name is missing, or outside 2–120 characters. |
409 | email_already_used | That address already has a development tenant. +tags and Gmail dots count as the same address. |
400 | invalid_link | The confirmation link was changed or is not one this instance issued. |
400 | link_expired | The confirmation link is older than its lifetime. Ask for a new one. |
400 | link_used | The confirmation link was already used, or a newer link or a changed address replaced it. |
409 | already_confirmed | POST /v1/account/confirmation: the address is confirmed and none other was given. |
403 | email_unconfirmed | Going live waits for the confirmation; reason says which part (table above). |
429 | rate_limited | Too many signups, or confirmation links, recently. |
503 | signup_unavailable | Enabled but not correctly configured, or confirmation is required and mail cannot be sent. |
429 is deliberately one code for several limits — per address, per hour across everyone.
Telling a caller which ceiling they hit tells them which one to work around.
Privacy
The address that signed up is stored as a salted SHA-256 and only ever compared, never reversed. Salted because an unsalted IPv4 hash is a lookup table away from being an IP address: the whole v4 space is 2³², which is minutes of work. An instance with signup enabled and no salt configured refuses to accept signups rather than store a reversible hash.
Related
- Authentication — what the key can reach
- Cost and quotas — the caps the free plan lands under
- Quickstart — what to do once you have a key