API reference
User sessions
An app that shows a business's people their approvals, such as the approval inbox, has to know who is signed in and must not let a sign-in link work twice. User sessions keep both in Tether, so any number of instances of that app agree, and a provider can run one app for every business it serves without holding a key per business.
Who may call it
| Credential | Signs in |
|---|---|
An inbox key, tib_, a provider issued (POST /v1/provider/keys) | The registered users of the businesses that provider created |
An inbox key, tib_, a business issued for itself (POST /v1/account/keys) | That business's own registered users |
A business's secret key, tth_ | That business's own registered users |
A provider key, tpv_ | The registered users of the businesses that provider created |
An app that only shows people their approvals should run on an inbox key: it reaches these routes
and, with a session, the approval routes below, and nothing else, so the host that runs it holds no
key that can create a business, issue a key or change a catalog or policy. Nothing else reaches
/v1/user-sessions.
The flow
- A person enters their work address. Your app calls
POST /v1/user-sessions/sign-in. - Tether mails one message for each business where that address is an active
registered user: each names the business, is written in the business's
language (its domain pack's
language), and carries its own link. A person registered at two businesses gets two messages and opens the one they mean. An address nobody registered gets nothing. - Tether answers
202with the same body either way, and your app tells the person the same, so a stranger learns nothing about which addresses or businesses exist. Your app never sees a link. - The person opens one. The link points at your app's
/api/session/verify?link=tsl_…; your app callsPOST /v1/user-sessionswith it. Tether spends the link (once, whichever instance of your app asks) and opens a session. - Your app sends that session as
X-Tether-User-Sessionwith the same credential on every call it makes for that person.
curl https://api.trytether.ge/v1/user-sessions/sign-in \
-H "X-Api-Key: tib_…" -H "Content-Type: application/json" \
-d '{ "email": "host@example.com" }'
{ "accepted": true, "message": "If that address is registered, a sign-in link is on its way. Asking again is limited, so if a few minutes pass, check spam and use the newest link you have." }
The link is minted when the message is sent, so it is only ever in the message: not in the response, not in the mail delivery log, not in any log line. Where it points is the deployment's configuration (Mail), never the request, so whoever holds the inbox key cannot send a person's link somewhere else.
curl https://api.trytether.ge/v1/user-sessions \
-H "X-Api-Key: tib_…" -H "Content-Type: application/json" \
-d '{ "link": "tsl_…" }'
{
"session": "tus_…",
"header": "X-Tether-User-Session",
"expiresAt": "2026-10-15T00:00:00Z",
"tenant": { "tenantId": 41, "name": "Venue One" },
"user": { "id": 17, "userId": 502, "role": "host", "email": "host@example.com", "displayName": "Venue host" }
}
user.id is the id Tether gave the person, which everyone registered has; userId is the one you
gave them, null for a person invited by email whose id you have
not linked. Key a signed-in person on id.
From an invitation
An invitation carries the same kind of link, pointing at the same
/api/session/verify of your app, good once for 24 hours (UserSessions:InvitationLinkLifetimeMinutes, 1440) instead of 10 minutes, because it is read
when the person gets to it. It is issued for the credential that serves the business's inbox: its
provider's (the provider key or its inbox key) when a provider created the business, else the
business's own (its secret key or its inbox key). Opening it is step 4 above.
Delivering the links yourself
A business's own backend (its secret key) or a provider's (its provider key) can instead ask for the
links and deliver them itself, with POST /v1/user-sessions/links. An inbox key cannot: an app that
spends links is never handed one it was not mailed.
{
"count": 2,
"links": [
{ "tenantId": 41, "tenantName": "Venue One", "link": "tsl_…", "expiresAt": "2026-10-14T12:10:00Z" },
{ "tenantId": 42, "tenantName": "Venue Two", "link": "tsl_…", "expiresAt": "2026-10-14T12:10:00Z" }
]
}
What a session reaches
A request carrying X-Tether-User-Session acts for the user the session names, in their business,
with the role Tether holds for them. It reaches these routes and nothing else:
| Route | For |
|---|---|
GET /v1/actions | The queue, and ?status=executed for what verification found |
GET /v1/tools, GET /v1/policy | The declared risk and the rule that explains who may approve |
GET /v1/audit | The business's own trail |
POST /v1/actions/{id}/confirm, POST /v1/actions/{id}/reject | Deciding, as that person |
X-Tether-User-Id and X-Tether-User-Role are not read on a session: the session is the identity.
An action in another business answers 404, as an unknown action does.
The session is checked again on every request. Deactivating the user (PUT /v1/users with "active": false), deactivating the business, or closing the session stops it on the next call.
Why a provider key can do this and still approve nothing
A provider key on its own still gets 403 insufficient_scope on every business route. With a
session it reaches one business's approval routes, as one of that business's own users, who signed
in with a link sent to their own address. The approval is that person's, checked against the
business's policy with the role the business registered. This is how the Providers
rule that a provider sees none of a business's actions or audit holds: the provider's own authority
reaches none of it, and what an approval app shows a signed-in person is shown on that person's
authority.
Routes
| Method and path | What it does |
|---|---|
POST /v1/user-sessions/sign-in | Body { "email": "…" }. Tether mails one link per business the address is registered at, valid 10 minutes. Answers 202 |
POST /v1/user-sessions/links | Body { "email": "…" }. The same links, answered to a secret or provider key that delivers them itself. Not an inbox key |
POST /v1/user-sessions | Body { "link": "tsl_…" }. Spends the link and opens a session, valid 12 hours |
GET /v1/user-sessions/current | With X-Tether-User-Session: who it acts for now |
DELETE /v1/user-sessions/current | With X-Tether-User-Session: closes it. Answers { "closed": true } |
A link and a session work only with a credential of the tenant that asked for the link, and of the same reach: a link a provider (or its inbox key) asked for cannot be opened or relayed with the business's own secret key, nor with the provider's own tenant's secret key, and the other way round.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | invalid_request | email is missing, has no @, or is longer than 200 characters |
503 | mail_not_configured | POST /v1/user-sessions/sign-in on a deployment that sends no mail (Mail:Transport is none). The same for every address |
401 | invalid_sign_in_link | The link did not open. error.reason is spent, expired, unknown, malformed or user_inactive |
401 | invalid_user_session | On any route: the session acts for nobody. error.reason is closed, expired, unknown, malformed or user_inactive |
403 | insufficient_scope | A session on a route outside the list above. For an inbox key, error.reason is user_session_required on an approval route without a session and not_an_inbox_route on every other route |