Tether
DocsGitHub

Guides

Calling your API as the signed-in user

An imported API is called with the one credential you stored at import. Some APIs accept only their own users' session tokens, which expire, and issue no long-lived service credential. For those, the page passes the signed-in person's token with each turn, and Tether calls your API with it. Nothing changes on your server.

The flow

  1. With the secret key, you choose how your imported API uses a person's token: ignore, prefer or require.
  2. Your page, or the widget, sends the signed-in person's session token with every turn in X-Tether-Upstream-Token.
  3. When Tether calls your imported API during that request, it sends that token as Authorization: Bearer … instead of the stored credential, as the mode says.
  4. Your API checks the token exactly as it does for your own app, and answers as that person.

1. Choose the mode

ModeA token came with the requestNo token came with the request
ignore (default)The stored credential is sent; the token is not usedThe stored credential is sent
preferThe person's token is sentThe stored credential is sent
requireThe person's token is sentYour API is not called. The call fails with user_token_required, and the model is told to ask the person to sign in

Under require Tether never falls back to the stored credential. Every tenant starts at ignore, so nothing changes until you choose. Set it without importing again:

curl -X PATCH https://api.trytether.ge/v1/tools/api \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "userToken": "require" }'
{ "baseUrl": "https://api.yourcompany.com", "userToken": "require", "credentialStored": false }

Or pass userToken on POST /v1/tools/import, or use tether import --user-token require and tether api set --user-token require from the CLI. See Changing the connection for the whole route.

The mode decides the bearer for every call Tether makes to your imported API: reads, the read behind a write's precondition, the read that verifies a write, and the confirmed write itself.

2. Send the token with each turn

With the widget, as an attribute, or as a property when the page refreshes the session:

<tether-assistant endpoint="…" publishable-key="tpk_…" upstream-token="the person's session token"></tether-assistant>
document.querySelector('tether-assistant').upstreamToken = refreshedToken

From your own client or backend, as a header on /v1/chat, /v1/chat/stream, POST /v1/actions or POST /v1/tools/{name}/call:

curl -X POST https://api.trytether.ge/v1/chat \
  -H "X-Api-Key: $TETHER_PUBLISHABLE_KEY" \
  -H "X-Tether-Upstream-Token: $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "what is on my order 4412?" }'

The header is accepted on any authenticated route and with any kind of key. A leading Bearer is stripped, so you can pass your own Authorization value as it is. The SDKs export the name: UPSTREAM_TOKEN_HEADER in Node and Python, TetherCustomerToken.UpstreamTokenHeader in .NET.

When a token is refused

Tether refuses a token only for its shape, before anything runs, with 400:

{
  "error": {
    "code": "invalid_upstream_token",
    "reason": "too_long",
    "message": "The upstream token is 4100 characters; the limit is 4000."
  }
}
ReasonMeans
emptyThe header is present with nothing in it, or nothing after Bearer
too_longLonger than 4000 characters
invalid_charactersAnything but printable ASCII: a space, a control character, a line break or a non-ASCII letter
repeatedThe header was sent more than once

The message never quotes the value. The widget tells the person their sign-in could not be used and dispatches tether-upstream-token-refused with detail.reason.

An expired or revoked token is your API's answer, not Tether's. When your API answers 401 or 403, the tool result says which credential was sent: the person's token, which has probably expired and should be passed fresh, the stored credential, or none. The token itself is never repeated.

Confirming a write

A write in your imported catalog is staged and confirmed as any other write. It runs inside the confirm request, so it uses the confirming request's token, not the one the turn that staged it carried. Who confirms decides what is sent:

Who confirmsWhat happens
A person in the approval inboxThe inbox carries no user token. Under prefer the write uses the stored credential. Under require the confirm answers 400 with status FailedTerminal and errorCode user_token_required; your API is not called, the action is settled as failed, and no other credential is tried
The widgetNever. The element does not confirm writes
Your backend, with the secret keyIt may send X-Tether-Upstream-Token on POST /v1/actions/{actionId}/confirm, and that token is used

So under require, writes go through your backend, confirming on behalf of the person who is signed in, with their token on the confirm. A write approved in the inbox under require fails cleanly rather than running as somebody else.

Calling a read directly

POST /v1/tools/{name}/call under require without a token answers 403 with user_token_required in the body's status, the same way customer_required is reported. Your API is not called.

What it does not change

  • Only the imported API. The header reaches nothing but the calls to the base URL of your imported API. Your signed tool endpoint and an MCP upstream ignore it.
  • It is not a customer token. Tether cannot read who the person is from your token. To let a capability act for one customer and be checked by Tether, send a verified customer token as well. The two travel side by side.
  • It is not a Tether credential. It never authenticates a request to Tether and never widens what a key may reach.