Tether
DocsGitHub

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

CredentialSigns 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

  1. A person enters their work address. Your app calls POST /v1/user-sessions/sign-in.
  2. 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.
  3. Tether answers 202 with 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.
  4. The person opens one. The link points at your app's /api/session/verify?link=tsl_…; your app calls POST /v1/user-sessions with it. Tether spends the link (once, whichever instance of your app asks) and opens a session.
  5. Your app sends that session as X-Tether-User-Session with 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.

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:

RouteFor
GET /v1/actionsThe queue, and ?status=executed for what verification found
GET /v1/tools, GET /v1/policyThe declared risk and the rule that explains who may approve
GET /v1/auditThe business's own trail
POST /v1/actions/{id}/confirm, POST /v1/actions/{id}/rejectDeciding, 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 pathWhat it does
POST /v1/user-sessions/sign-inBody { "email": "…" }. Tether mails one link per business the address is registered at, valid 10 minutes. Answers 202
POST /v1/user-sessions/linksBody { "email": "…" }. The same links, answered to a secret or provider key that delivers them itself. Not an inbox key
POST /v1/user-sessionsBody { "link": "tsl_…" }. Spends the link and opens a session, valid 12 hours
GET /v1/user-sessions/currentWith X-Tether-User-Session: who it acts for now
DELETE /v1/user-sessions/currentWith 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

StatusCodeMeaning
400invalid_requestemail is missing, has no @, or is longer than 200 characters
503mail_not_configuredPOST /v1/user-sessions/sign-in on a deployment that sends no mail (Mail:Transport is none). The same for every address
401invalid_sign_in_linkThe link did not open. error.reason is spent, expired, unknown, malformed or user_inactive
401invalid_user_sessionOn any route: the session acts for nobody. error.reason is closed, expired, unknown, malformed or user_inactive
403insufficient_scopeA 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