API reference
Tether sends two kinds of message to a business's registered people: the sign-in link an approval app asks for, and a notice that an action is waiting for their approval. Both are queued with the change that caused them and sent outside the request, with retries, and every attempt is listed for the business. Secret key only for the list.
What is sent, and to whom
| Message | Sent when | To |
|---|---|---|
| Sign-in link | An approval app calls POST /v1/user-sessions/sign-in | Each active registered user with that address, one message per business, each with its own link to the app's /api/session/verify |
| Approval waiting | A write is staged and waits for a person | Each active registered user of the business, with an address, whose role the business's current policy lets approve this action, its conditions evaluated over the staged arguments |
Nothing is ever sent to an address that is not registered for an active user of that business, but
the embed instructions below, to the one address the owner typed.
A message is written in the business's language, its domain pack's
language (ka for Georgian, otherwise English), and names the business, so a person registered
at two businesses can tell them apart. An approval notice names the action by its capability, says
when it expires, and links to the approval app; it carries no sign-in link and none of the
action's arguments.
Who receives a message is read again when it is sent. A notice for an action that no longer waits, or to a person whose role may no longer approve it, is skipped. A sign-in link is minted when its message is sent, so it exists only in that message.
An invitation (kind invitation) goes to a person a business invited by
email. It names the business, says they were added as someone
who approves its actions, and carries a sign-in link to the approval app, minted when it is sent and
good once for 24 hours. At most Mail:MaxInvitationsPerHour (20) invitations a business an hour.
The embed instructions (kind embed_instructions, console loop O7) are the one message sent to
an address that is not a registered user: the person the owner named under "Send to my developer"
(POST /v1/embed/instructions). Tether writes it: the
snippet with the workspace's widget key (publishable, read when the message is sent), where to paste
it on the common site builders, and the addresses it answers on. It carries no secret. It waits for
the confirmed address like any mail to someone else, and a business may ask for it at most
Mail:MaxEmbedInstructionsPerHour (5) times an hour.
A self-serve workspace whose address is not confirmed yet mails nobody but that address. An
approval notice or an invitation to anyone else is held, and sent when the address is confirmed
(a notice only if the action still waits); a sign-in link for anyone else is refused with 403 email_unconfirmed. Held messages
are not attempts, so they are not listed here; GET /v1/account/confirmation counts them
(heldMail). The confirmation link itself is a message too (kind confirm_address), sealed when
it is sent. See Signup.
Where it comes from
Mail is configured by whoever runs Tether, never by a business or a request. One transport:
Mail:Transport | Effect |
|---|---|
none (default) | Nothing is sent or queued. POST /v1/user-sessions/sign-in answers 503 mail_not_configured |
console | Development only: each message, link included, is written to standard output. Refused outside Development |
smtp | Any SMTP relay |
| Setting | For smtp |
|---|---|
Mail:From | Required. The sender address |
Mail:FromName | Optional display name |
Mail:Smtp:Host, Mail:Smtp:Port | Required |
Mail:Smtp:Security | Required: starttls (refuses a server that does not offer it), tls (implicit TLS) or none |
Mail:Smtp:Username, Mail:Smtp:Password | Together or not at all, and never with Security none |
Mail:Smtp:TimeoutSeconds | Per message, default 30 |
Mail:Smtp:HeloName | Optional: the name sent in EHLO; the machine's name when unset |
Mail:MaxSignInMailsPerWindow | At most this many sign-in messages for one person at one business within one link lifetime, default 5; further requests are answered the same and send nothing |
Mail:MaxInvitationsPerHour | At most this many invitations (POST /v1/users/invitations) for one business within an hour, default 20; beyond it the route answers 429 invitation_limit |
Mail:MaxEmbedInstructionsPerHour | At most this many embed instructions (POST /v1/embed/instructions) for one business within an hour, default 5; beyond it the route answers 429 embed_mail_limit |
Mail:Inbox:BaseUrl | Required unless none: the approval app's address, which every link points at. https, or http to the same machine |
Mail:Inbox:BaseUrls:<tenant id> | Optional: a different address for that tenant and every business it created, for a provider that runs its own approval app |
An unknown transport, a missing setting, credentials over plain text or a link address that is not
https stops Tether when it starts, with the setting named. A mail server's 5xx is final; a 4xx,
a timeout or a lost connection is tried again on the event delivery schedule.
GET /v1/mail/deliveries
Every attempt, newest first. A row names the recipient, never the message.
| Query | Filters to |
|---|---|
kind | sign_in, approval_waiting, confirm_address, invitation or embed_instructions |
status | delivered, retrying, failed or skipped |
actionId | The notices about one action |
messageId | Every attempt at one message. It is also the message's Message-ID |
limit | At most 200, default 50 |
{
"deliveries": [
{
"kind": "approval_waiting",
"to": "manager@example.com",
"actionId": "4a025360-68e2-4e81-a355-8bae07cebbf8",
"status": "delivered",
"replyCode": 250,
"error": null,
"durationMs": 14,
"attemptedAt": "2026-10-14T21:05:03Z",
"messageId": "b0d1…",
"attempt": 1,
"retryAt": null
}
],
"count": 1
}
replyCode is the mail server's answer. error says why an attempt did not arrive: refused,
timeout, unreachable, tls_required, tls_failed, or skipped with the reason. Mail is not
listed by GET /v1/events/deliveries.