Tether
DocsGitHub

API reference

Embed

What the console's last step does, as four calls: the one snippet your site needs, with the workspace's widget key; a new widget key when you want one; the snippet and where to paste it, mailed to the person who runs your site; and a check of what your site actually serves. Tenant secret key only, like origins: a browser key is refused with 403 insufficient_scope.

Method and pathWhat it does
POST /v1/embedThe snippet, with the workspace's widget key (issued the first time, the same after)
POST /v1/embed/rotateA new widget key in a new snippet; the old key stops working
POST /v1/embed/instructionsThe snippet and where to paste it, mailed to one address ("Send to my developer")
POST /v1/embed/checkWhat a page of your site serves: the snippet, its key, the origins and their policy ("Check my site")
PUT /v1/origins with holdUntilConfirmedPublic origins that wait for your confirmed address instead of being refused
GET /v1/chat/brandThe colour and logo the widget wears; the widget key may read it
PUT /v1/chat/brandSet or clear them (secret key only)

The snippet

curl -X POST "$TETHER_URL/v1/embed" \
  -H "X-Api-Key: $TETHER_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "language": "ka", "greeting": "რით შემიძლია დაგეხმაროთ?" }'
{
  "key": "tpk_5c1d…",
  "prefix": "tpk_5c1d9a0e",
  "kind": "publishable",
  "issued": false,
  "revokedPrefix": null,
  "endpoint": "https://api.trytether.ge",
  "scriptUrl": "https://api.trytether.ge/widget/tether-assistant.js",
  "snippet": "<script src=\"https://api.trytether.ge/widget/tether-assistant.js\" defer></script>\n<tether-assistant\n  endpoint=\"https://api.trytether.ge\"\n  publishable-key=\"tpk_5c1d…\"\n  mode=\"floating\"\n  lang=\"ka\"\n  launcher-label=\"მოგვწერეთ\"\n  greeting=\"რით შემიძლია დაგეხმაროთ?\"\n  placeholder=\"დაწერეთ კითხვა…\">\n</tether-assistant>",
  "origins": ["https://lodge.ge"],
  "held": ["https://www.lodge.ge"]
}

The widget key is the workspace's one publishable key for its site. The first call issues it (issued: true, label "Website widget"); every later call answers the same key, so the snippet already on your site keeps working and you can come back for it at any time. It is the one key Tether can show again: a publishable key is public by design (it sits in your page's HTML, may only chat, stream and send feedback, and a page on an origin you did not register cannot use it), so its value is kept beside its hash. Every other key is still a hash alone and shown once. The database allows one live widget key per workspace.

snippet is everything a page needs: the script from your Tether API's own address (no file to host), and the element with your endpoint, the widget key, a floating launcher, and the first words a visitor reads in language (ka or English): greeting as you give it (one line, at most 200 characters, escaped), a launcher label and a placeholder. Paste it once, before </body>; the widget guide has the steps for the common site builders. origins and held are what /v1/origins holds now.

A secret key never appears in a snippet; building one with any other key is refused.

A new widget key

POST /v1/embed/rotate, same body and answer, with issued: true and the revokedPrefix of the old key. The old key stops working at once, so the snippet on your site must be replaced. Rotating the widget key with POST /v1/account/keys/{prefix}/rotate keeps the new key as the widget key too; revoking it leaves the workspace with none until the next POST /v1/embed.

Origins that wait for your confirmation

A self-serve workspace registers a public origin only once its signup address is confirmed (what "live" means). Asked to, it keeps such an origin waiting instead of refusing it:

curl -X PUT "$TETHER_URL/v1/origins" \
  -H "X-Api-Key: $TETHER_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "origins": ["https://lodge.ge", "https://www.lodge.ge", "http://localhost:8080"], "holdUntilConfirmed": true }'
{ "origins": ["http://localhost:8080"], "held": ["https://lodge.ge", "https://www.lodge.ge"], "reason": "public_origin" }

Local origins are allowed at once, so the snippet works on localhost for testing. The held ones are refused to a browser exactly as an unregistered origin is (403 origin_not_allowed) until the address is confirmed; opening the confirmation link allows them in the same step that releases held mail, with nothing to send again. Without holdUntilConfirmed the request is refused as before, 403 email_unconfirmed with reason public_origin. A PUT replaces both lists; GET /v1/origins answers { "origins": [...], "held": [...] }. Once the address is confirmed, held is always empty.

Send to my developer

curl -X POST "$TETHER_URL/v1/embed/instructions" \
  -H "X-Api-Key: $TETHER_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "email": "dev@agency.ge", "language": "en", "greeting": "Ask about our rooms." }'
{ "email": "dev@agency.ge", "status": "held", "reason": "third_party_mail", "message": "The snippet and instructions go to dev@agency.ge once this workspace's address is confirmed." }

202. Tether writes the message, not the caller: the snippet exactly as POST /v1/embed builds it for that language and greeting, where to paste it on a plain HTML site, WordPress (a "Custom HTML" block, no plugin), Wix, Tilda, Webflow and Shopify, the addresses it answers on, the Content-Security-Policy to allow if the site sends one, and that the key in it is publishable. No secret key or signing secret is ever in it; the widget key is read when the message is sent, so a rotation in between sends the new one. status is queued, held (sent once the workspace's address is confirmed, as every message to someone else waits; reason third_party_mail) or not_sent (this deployment sends no mail). It is the one message Tether sends to an address that is not a registered user, so it is limited to Mail:MaxEmbedInstructionsPerHour (5) a workspace an hour: over it, 429 embed_mail_limit. A malformed address is 400 invalid_email. Deliveries are listed with the rest of the mail as embed_instructions.

Check my site

curl -X POST "$TETHER_URL/v1/embed/check" \
  -H "X-Api-Key: $TETHER_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://lodge.ge/" }'
{
  "url": "https://lodge.ge/",
  "reachable": true,
  "status": 200,
  "finalUrl": "https://lodge.ge/",
  "error": null,
  "snippet": { "element": true, "script": true, "key": "this_workspace" },
  "secretKeyOnPage": false,
  "csp": { "scriptAllowed": true, "connectAllowed": false },
  "origins": [
    { "origin": "https://lodge.ge", "serves": true, "registered": true, "held": false },
    { "origin": "https://www.lodge.ge", "serves": false, "registered": false, "held": false }
  ]
}

Tether reads the page the way a browser gets it, through the same outbound guard as every call it makes to an address it did not choose (an address on a private or loopback network is refused unless the deployment allows it), following at most five redirects, within Embed:CheckTimeoutSeconds (8) for the whole check and reading at most Embed:CheckMaxBytes (512 KiB) of the page. It reports whether the element and its script are on the page, whether the key in it is this workspace's live widget key (this_workspace), another (other, such as a key since rotated) or absent (missing), whether the page shows a secret key, what its Content-Security-Policy header allows of this API (null when it sends none), and, for the address given, where it landed and the other of www. and the bare domain, whether each serves the site and whether the assistant may answer there. A page that does not answer in time is reachable: false with the reason in error. 400 invalid_site_url for an address that is not http or https or carries a user name. The check only reads; it is never needed to go live.

The widget's brand

The widget on your site wears your brand: the colour and logo Tether read from your site during the website preview, kept when you keep the preview as a workspace. When it mounts, the element reads them with its own widget key:

curl "$TETHER_URL/v1/chat/brand" -H "X-Api-Key: $TETHER_WIDGET_KEY"
{ "color": "#1f7a5a", "logo": "https://lodge.ge/logo.svg" }

Either may be null; a workspace that did not start from a preview has neither, and the widget keeps Tether's palette. This is the one read a widget key has, and it is a GET from a registered origin only, like the key's other calls (403 origin_not_allowed from anywhere else). The answer is Cache-Control: private, max-age=300, so a change reaches a visitor's browser within five minutes.

The colour becomes the widget's accent, the visitor's bubble and its accent-coloured text, with the ink on it chosen by contrast: white on a dark brand, near-black on a light one, and the avatar's icon kept dark when the brand is too light to read on white. A page that sets --tether-accent, --tether-on-accent or --tether-bubble itself still wins (widget guide). The logo replaces the avatar in the header and the empty state, and the avatar comes back if the image fails to load.

PUT /v1/chat/brand with the secret key replaces both, which is what the dashboard's Assistant settings do:

curl -X PUT "$TETHER_URL/v1/chat/brand" \
  -H "X-Api-Key: $TETHER_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{ "color": "#0a3d62", "logo": "https://lodge.ge/logo.svg" }'

The answer is the stored pair. color is a six-digit hex colour, stored in lower case; logo is an http or https address of at most 2048 characters with no user name. null or an empty string clears either. 400 invalid_brand_color or 400 invalid_brand_logo otherwise. A widget key is refused with 403 insufficient_scope.