Tether
DocsGitHub

API reference

Approvers and policy

Two registrations turn "someone confirmed it" into "the right person confirmed it": the people your tenant knows, and the rules saying which of them may approve what. Both need the tenant secret key.

Until you register them

With no users and no policy, a confirm must still name someone: X-Tether-User-Id has to be a positive integer, and it is accepted as your server states it. A confirm with no id is refused with ApproverUnknown. That is enough to try the flow and it is not access control, because anyone holding the secret key can approve as anyone.

Registering a policy changes that. From then on the person must be an active registered user, and the rule that matches the write decides who may approve it.

Register users

PUT /v1/users
curl -X PUT $TETHER_URL/v1/users \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    { "userId": 41, "role": "finance", "email": "finance@yourshop.com", "displayName": "Finance" },
    { "userId": 42, "role": "support" }
  ]'
FieldNotes
userIdRequired, unless the entry names a person by id. A positive integer you choose. It is what you send as X-Tether-User-Id, and registering the same id again updates that person rather than adding a second one.
idOptional. The id Tether gave a person (see Invite by email), to update or deactivate someone who has no userId, or to link one.
roleRequired. A name you choose, matched against the roles in a policy rule without regard to case.
email, displayNameOptional. The approval inbox signs people in by email.
activeOptional, true by default. Set false to remove someone's right to approve without deleting them.
{ "registered": true, "created": 2, "updated": 0, "linked": 0, "count": 2 }

GET /v1/users lists everyone registered, with id (the id Tether gave them), userId (yours, null for a person invited by email and not linked), role, email, displayName, active and timestamps. A body that cannot be read answers 400 with registered: false and a message naming the entry and what it sent, such as entry 1 has no positive userId — it must be a whole number above zero, not "U024BE7LH".

A team whose people are keyed on Slack ids, UUIDs or email addresses keeps its own table from that id to an integer, registers the integer here, and sends the integer as X-Tether-User-Id. Tether does not store your original id, so the table is yours. Accepting string ids directly would change every place an approver is recorded, and is not offered.

Tether does not authenticate these people. Your API key does, and your server says who is acting by sending X-Tether-User-Id. Registering them lets that claim be checked against a list you control. A person who signs in to the approval inbox is the exception: Tether opened that session from a link it mailed to their own address, so the session says who they are, and no header is read.

Invite by email

POST /v1/users/invitations

The person who approves often has no account in your product at all. Invite them by address:

curl -X POST $TETHER_URL/v1/users/invitations \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "manager@yourshop.com", "role": "approver", "displayName": "Shop manager" }'
{
  "invited": true,
  "created": true,
  "user": { "id": 12, "userId": null, "role": "approver", "email": "manager@yourshop.com", "displayName": "Shop manager", "active": true },
  "invitation": { "status": "queued", "reason": null, "message": "An invitation is on its way to manager@yourshop.com." }
}

They are registered at once, with the role you give and no userId, and may approve exactly what that role may under your policy. Tether mails them an invitation with a sign-in link to the approval inbox, good once for 24 hours (after that they sign in there with the same address); it opens a session as that person. email and role are required, displayName is optional, and userId is not taken here.

invitation.statusMeans
queuedOn its way.
heldYour workspace has not confirmed its own address yet, so mail to anyone else waits (reason is third_party_mail, see Going live). It is sent when you confirm, with nothing to redo.
not_sentThis deployment sends no mail. The person is registered; sign them in another way.

Inviting an address that one registered person already has updates their role (and name, when you give one) and invites them again; it never adds a second person. An address that two registered people share answers 409 with user_email_ambiguous. A workspace sends at most 20 invitations an hour (Mail:MaxInvitationsPerHour), 429 invitation_limit beyond.

If your own backend will approve as this person with X-Tether-User-Id, link your id to them with PUT /v1/users and their email: the entry links the invited person ("linked": 1) instead of adding a second one, and a session they already opened keeps working. You can also name them by id. If your backend never approves as them, never link anything: the inbox is enough.

Linking never makes two people one. Each of these answers 409 with user_link_conflict and the entry it refuses, and the whole request changes nothing: a userId that is already another person's while the email belongs to a person invited and not linked; one address linked by two entries; a person already linked to a different userId; an id your workspace does not have.

{ "registered": false, "code": "user_link_conflict", "error": "User list is refused — userId 7 is already registered as another person, and b@yourshop.com belongs to a person invited by address who has no id yet; linking would make them one person. Give the invited person their own id, or change this entry's email. Nothing was changed." }

Register a policy

PUT /v1/policy
curl -X PUT $TETHER_URL/v1/policy \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rules": [
    { "tool": "refund_order", "risk": "*", "roles": ["finance"], "approvers": 2 },
    { "tool": "*", "risk": "low", "roles": ["support", "finance"], "autoApprove": true },
    { "tool": "*", "risk": "*", "roles": ["support", "finance"] }
  ] }'
{ "registered": true, "version": 1, "ruleCount": 3 }

Each registration is a new version that replaces the last; GET /v1/policy returns the one in force with its rules, and people: every active registered person whose role a rule permits, by id, userId, displayName, email and role, with rules, the indexes of the rules that permit them. people is not part of a rule, so the body registers again unchanged. POST /v1/policy/validate takes the same body and registers nothing, so it can run in CI.

Earlier versions are kept. GET /v1/policy/versions lists them, newest first, and GET /v1/policy/versions/{version} returns one as it was registered, under policy. POST /v1/policy/versions/{version}/restore registers that body again as a new version. It is checked against the current catalog first, so a policy naming a tool that has since gone answers 400 with the reason, and you can adjust it and PUT it. An unknown version answers 404 unknown_version.

FieldDefaultNotes
tool*A tool name from your registered catalog, or * for every tool. A rule naming a tool you have not registered is refused.
risk*low, elevated, high, or *. The risk the tool was registered with.
roles—Required and non-empty. Roles that may approve. * means any role.
approvers1How many different people must confirm, 1 to 10.
autoApprovefalseStage-and-run with no human. Allowed only on a rule with risk: "low", and not together with more than one approver.
whennoneA condition on the action's own arguments. See Conditions on arguments.

A rule takes only these six keys. Any other key is refused by name, for example rule 1 has a key 'condition' that a rule does not take — a rule takes only tool, risk, roles, approvers, autoApprove and when, so a condition you misspelled is never stored and silently ignored. "when": null is the same as no when, so a policy read back with GET /v1/policy registers again unchanged.

How a rule is chosen

Of the rules that match a write's tool, its risk and (where a rule has one) its condition, the most specific decides:

  1. a rule naming the tool, with a when
  2. a rule naming the tool
  3. a * rule, with a when
  4. a * rule

Rules at the same level are tried in the order you declared them, so where two conditioned rules for one tool both hold, the first one listed decides. Before conditions existed the first matching rule in declared order decided; a policy that lists every tool-specific rule above the * rules, as the example does, decides exactly as it did then.

A write that no rule matches is not blocked. It falls through to a default that any active registered user may approve, with one approver. If you want an unlisted tool to need a particular role, end the list with a * rule that names it.

Conditions on arguments

"A refund needs finance" is usually too coarse. What a business means is "a refund over 100 needs finance". A rule's when says that, over the arguments of the staged action:

{ "rules": [
  { "tool": "refund_order", "risk": "high", "roles": ["support", "finance"], "when": { "amount": { "lte": 100 } } },
  { "tool": "refund_order", "risk": "high", "roles": ["finance"] }
] }

A refund of 40 is approved by support or finance; a refund of 250 needs finance, and a support confirm answers 403 ApprovalForbidden with Role 'support' may not approve 'refund_order' at high risk; it needs finance.

when names arguments of the action, and gives each one or more comparisons, every one of which must hold:

ComparisonTakesHolds when the argument
gt, gte, lt, ltea numberis a number greater than, at least, less than, or at most it
equalsa string, number or booleanis that value. Strings compare exactly, case included; numbers by value, so 2 equals 2.0
ina non-empty list of themequals one of them

{ "partySize": { "gt": 6, "lte": 12 } } is a range; { "reason": { "in": ["duplicate"] }, "amount": { "lt": 50 } } needs both. Nothing richer is offered: no or, no paths into nested objects, no comparison between two arguments.

The arguments are the ones the action was staged with, which the confirm cannot change, and they were checked against the tool's schema before it was staged.

A missing argument gets the stricter rule

A condition holds only for an argument that is present and comparable. An argument that is missing, null, or of another type ("40", a string, where a number is compared) does not satisfy it, and the rule is passed over for the next one. So write the stricter requirement without a condition and the relaxation as the condition, as in the example: a refund whose amount cannot be read is finance's. Written the other way round (a general rule for support, and amount gt 100 for finance), a refund with no amount would fall to support's rule. With a required argument that cannot happen, because staging refuses an action without it; with an optional one, it can.

Checked at registration

A condition is checked against the arguments the tool declares. A rule naming a tool must name arguments that tool takes; a * rule must name arguments that at least one write takes, and is checked against every write that takes them. Where the schema declares a type, a comparison the type can never satisfy is refused:

{ "registered": false, "error": "Approval policy is invalid — rule 1 has a when on 'total', which refund_order does not take (its arguments: orderId, amount)." }

Others: rule 1 compares orderId, which refund_order declares string, with gt 5, which it can never hold; rule 1 compares amount with 'between', which must be one of gt, gte, lt, lte, equals or in; rule 1 compares amount with gt, which takes a number, not "100". Re-register the policy after a catalog change that removes or retypes an argument a condition names; a condition on an argument that no longer exists never holds.

A condition never auto-approves above low

autoApprove stays allowed only on a rule scoped to risk: "low", with or without a when, so a condition can narrow auto-approval ({ "risk": "low", "autoApprove": true, "when": { "amount": { "lt": 10 } } }) but never extend it to an elevated or high write. The engine holds the same line when it evaluates a policy, not only when it registers one.

More than one approver

With approvers above 1, each confirm is recorded and answers 202 with status AwaitingApprovers, approversRequired and approversReceived. The same person confirming twice counts once. The write executes on the confirm that reaches the number, and answers Executed.

What a bad policy says

Registration answers 400 with valid: false (validate) or registered: false (register) and one message listing every problem, for example:

{ "registered": false, "error": "Approval policy is invalid — rule 1 names no roles, so nobody could ever approve under it." }

Other messages you can meet: a key a rule does not take; a risk that is not one of low, elevated, high or *; a tool "not in the registered catalog"; more than 10 approvers; auto-approval on anything but a low rule; and the condition messages above.

Every decision is recorded

Confirms, rejections, expiries and executions land in an append-only audit, read with GET /v1/audit and exportable as CSV. See Audit.