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
- Your server issues itself a customer-token key, once, with the tenant's secret key.
- After your own sign-in, your server mints a customer token for the signed-in customer and gives it to the page.
- The page, or the widget, sends it with every turn in
X-Tether-Customer-Token. - Tether verifies it and puts the customer in what your API receives:
customer.idin the request envelope, or theX-Tether-Customer-Idheader 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:
| Claim | Value |
|---|---|
sub | Your own id for the customer: 1 to 128 characters from letters, digits and . _ ~ : @ + = / | - |
tenant | Your Tether tenant id, as a number |
iat | Issued at, seconds since the epoch |
exp | Expiry, 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:
| Reason | Means |
|---|---|
malformed | Not a compact JWT, or a claim is missing or not a number |
unsupported_algorithm | The header does not say HS256. none is never accepted |
not_configured | The tenant has not issued a customer-token key |
wrong_tenant | The tenant claim is a different tenant from the key it arrived with |
invalid_signature | Not signed with this tenant's current customer-token key |
invalid_subject | sub is missing or outside the allowed characters |
issued_in_future | iat is ahead of Tether's clock by more than a minute |
expired | exp has passed, allowing a minute of clock skew |
lifetime_too_long | exp 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.