Tether
DocsGitHub

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" }'
FieldTypeNotes
emailstringRequired. How you are reached, and what the per-address limit counts.
namestringRequired. 2–120 characters. The tenant's display name.
languagestringOptional. 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:

reasonWhat waits
public_originAn 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_mailMail 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_planAny plan but free, so the strictest free caps hold.
provider_grantProvider authority (POST /v1/tenants/{id}/provider).
child_tenantA 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 most Signup: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": true to 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/compromise with 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

StatusCodeMeaning
404signup_disabledSelf-serve signup is not open on this instance.
400invalid_emailThe address is missing or not shaped like one.
400disposable_emailThe address is on a disposable-mail domain, or one this instance blocks. Use an address on your own domain.
400invalid_nameThe name is missing, or outside 2–120 characters.
409email_already_usedThat address already has a development tenant. +tags and Gmail dots count as the same address.
400invalid_linkThe confirmation link was changed or is not one this instance issued.
400link_expiredThe confirmation link is older than its lifetime. Ask for a new one.
400link_usedThe confirmation link was already used, or a newer link or a changed address replaced it.
409already_confirmedPOST /v1/account/confirmation: the address is confirmed and none other was given.
403email_unconfirmedGoing live waits for the confirmation; reason says which part (table above).
429rate_limitedToo many signups, or confirmation links, recently.
503signup_unavailableEnabled 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.