Tether
DocsGitHub

Guides

Verified customers

A guest who asks to cancel "my booking" has to be someone. Your site already knows who is signed in, so your server says so in a short signed token, and Tether passes the verified customer to your API beside the tenant. The customer is never read from the conversation.

The flow

  1. Your server issues itself a customer-token key, once, with the tenant's secret key.
  2. After your own sign-in, your server mints a customer token for the signed-in customer and gives it to the page.
  3. The page, or the widget, sends it with every turn in X-Tether-Customer-Token.
  4. Tether verifies it and puts the customer in what your API receives: customer.id in the request envelope, or the X-Tether-Customer-Id header on an imported API. Your API enforces ownership with it.

1. Issue the key

curl -X POST https://api.trytether.ge/v1/account/customer-token-key \
  -H "X-Api-Key: $TETHER_SECRET_KEY"
{
  "customerTokenKey": "tck_9f2c…",
  "tenantId": 42,
  "algorithm": "HS256",
  "header": "X-Tether-Customer-Token",
  "maxLifetimeSeconds": 3600
}

Only the secret key can call this; a publishable key is refused. The key is shown once. Calling again rotates it, and tokens signed with the old key stop verifying immediately.

2. Mint the token

A compact JWT, HS256 only, with four claims in this order:

ClaimValue
subYour own id for the customer: 1 to 128 characters from letters, digits and . _ ~ : @ + = / | -
tenantYour Tether tenant id, as a number
iatIssued at, seconds since the epoch
expExpiry, seconds since the epoch, at most 3600 after iat

Every SDK mints it for you, byte for byte the same:

const { mintCustomerToken } = require('tether')
const token = mintCustomerToken(process.env.TETHER_CUSTOMER_TOKEN_KEY, {
  tenant: 42, customerId: req.user.id, ttlSeconds: 300,
})
from tether import mint_customer_token
token = mint_customer_token(os.environ["TETHER_CUSTOMER_TOKEN_KEY"], tenant=42,
                            customer_id=current_user.id, ttl_seconds=300)
var token = TetherCustomerToken.Mint(customerTokenKey, tenantId: 42, customerId: user.Id,
    lifetime: TimeSpan.FromMinutes(5));

Any JWT library that signs HS256 works too. Keep tokens short: a token lifted from a page is worth only as long as it lives, and minting a fresh one is cheap.

3. Send it with each turn

With the widget, as an attribute or a property:

<tether-assistant endpoint="…" publishable-key="tpk_…" customer-token="eyJhbGciOi…"></tether-assistant>

From your own client or backend, as a header on /v1/chat or /v1/chat/stream:

curl -X POST https://api.trytether.ge/v1/chat \
  -H "X-Api-Key: $TETHER_PUBLISHABLE_KEY" \
  -H "X-Tether-Customer-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "გააუქმე ჩემი ჯავშანი B102" }'

The same header works on POST /v1/actions and POST /v1/tools/{name}/call when your backend stages or calls on a customer's behalf.

When a token is refused

A token that fails is refused, never quietly treated as an anonymous visitor:

{
  "error": {
    "code": "invalid_customer_token",
    "reason": "expired",
    "message": "The customer token has expired. Mint a fresh one for the signed-in customer."
  }
}

The status is 401 and reason is one of:

ReasonMeans
malformedNot a compact JWT, or a claim is missing or not a number
unsupported_algorithmThe header does not say HS256. none is never accepted
not_configuredThe tenant has not issued a customer-token key
wrong_tenantThe tenant claim is a different tenant from the key it arrived with
invalid_signatureNot signed with this tenant's current customer-token key
invalid_subjectsub is missing or outside the allowed characters
issued_in_futureiat is ahead of Tether's clock by more than a minute
expiredexp has passed, allowing a minute of clock skew
lifetime_too_longexp is more than 3600 seconds after iat

The widget shows the visitor a sign-in prompt and dispatches tether-customer-token-refused with detail.reason, so the page can mint a fresh token and set customerToken.

4. Enforce ownership in your API

The verified customer arrives beside the tenant:

{
  "tool": "cancel_booking",
  "arguments": { "bookingId": "B102" },
  "tenant": { "organization_id": 42, "scope_id": null, "user_id": 501 },
  "customer": { "id": "G1" },
  "is_write": true,
  "mode": "execute",
  "request_id": "…"
}

On an imported API it arrives as X-Tether-Customer-Id: G1. It is null, or the header is absent, when the turn had no verified customer. The SDKs expose it as ctx.customer.

cancel_booking: {
  write: true, risk: 'high', requiresCustomer: true,
  handler: async ({ bookingId }, ctx) => {
    const booking = await db.bookings.find(bookingId)
    if (booking.guestId !== ctx.customer.id) throw new Error('not_owner')
    return db.bookings.cancel(bookingId)
  },
}

A confirmed write carries the customer it was staged for, even though a member of staff approved it later from a request with no customer of its own. The audit names that customer as the subject of every event.

Memory follows the customer

What a verified customer tells the agent is remembered for that customer: a "reply in English" applies to their next conversation too, on any device, and to nobody else. Their conversations belong to them, so another guest naming one of their session_ids is refused 403 session_not_yours. A visitor without a token is remembered only within the conversation Tether issued to them. See Context and memory.

Capabilities that need a customer

Declare requires_customer: true (requiresCustomer in the SDKs) on a capability that acts for one customer. Without a verified customer Tether neither calls it nor stages it, and tells the model to ask the person to sign in, so the agent says that instead of guessing. It defaults to false, and a value that is not true or false is refused at registration. See the tool spec.