API reference
Events webhook
Tether tells you, with a signed POST, when a write is waiting, decided, expired or executed, and when a conversation is handed to a person. This page sets where those events go and shows what happened to each one. If you only imported your API, this is how you hear about anything. Tenant secret key only.
Where events go
| You have set | Events go to |
|---|---|
| An events webhook | The events webhook |
| An events webhook and a tool endpoint | The events webhook. The one you set for events wins |
| A tool endpoint only | The tool endpoint, as they always have |
| Neither | Nowhere. Nothing is lost, because every action is still on GET /v1/actions and in the audit, but nothing tells you |
A tenant that imported its API from an OpenAPI document has no tool endpoint. Your API's base URL is where Tether calls your operations, and it is never used as a place to send events. Set an events webhook instead.
Set it
PUT /v1/events/webhook
curl -X PUT $TETHER_URL/v1/events/webhook \
-H "X-Api-Key: $TETHER_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://platform.example.com/tether/events" }'
{
"configured": true,
"url": "https://platform.example.com/tether/events",
"effectiveUrl": "https://platform.example.com/tether/events",
"effectiveSource": "events_webhook",
"signingSecretIssued": true,
"lastDelivery": null,
"lastFailure": null
}
The address passes the same address checks as the tool
endpoint: absolute, HTTPS, and not a private or loopback address. One that fails answers 400
invalid_endpoint and changes nothing. Send "" or null to clear it, after which events go to
the tool endpoint, if you have one. A body with no url answers 400 invalid_request.
effectiveUrl and effectiveSource (events_webhook, tool_endpoint or null) say where the
next event will go. GET /v1/events/webhook answers the same shape.
What arrives
Every event is signed with your tenant's signing secret, the one that signs tool calls, with
the same X-Tether-Signature, X-Tether-Timestamp and X-Tether-Idempotency-Key headers, plus
X-Tether-Delivery-Attempt. Verify it
as described in Verifying signatures, or hand the request
to the Node, Python or .NET SDK handler, which verifies it and passes it to
onEvent. The envelope is described in Request envelope.
event | Sent when |
|---|---|
action.pending | A write was staged and is waiting for a person. Carries summary and expires_at |
action.approved | A person approved it under your approval policy |
action.rejected | A person declined it. Carries reason |
action.expired | Its confirmation window passed before anyone confirmed it |
action.executed | Your API ran it and answered success |
action.verified | Its verify read showed the effect |
action.unverified | It did not, or could not be read. Carries reason; a conversation.handoff follows |
action.precondition_failed | At confirmation its precondition no longer held, so it was settled without the write being called. Carries reason |
action.provider_refused | The confirmed write was sent to your imported API, which refused it without applying it. Carries reason, with your status, code and words |
conversation.handoff | A conversation was handed to a person |
An event names a tool but is never a call: it has no arguments, no is_write and no mode.
Acknowledge it with any 2xx and run nothing.
Expiry happens on a schedule
An action nobody opens still expires. A sweep runs every minute
(AI:Approvals:ExpirySweep:IntervalSeconds), moves every pending action whose window has passed to
expired, records expired in the audit and sends action.expired. It uses the same conditional
transition a confirm does, so when a confirm and the sweep reach the same action together it is
expired once and announced once, and an action past its window is never executed, whichever
arrives first. GET /v1/actions stops listing it as pending and GET /v1/actions?status=expired
lists it.
Delivery, retries and ordering
An event is written to Tether's outbox in the same transaction as the change it announces: an action that is executed, rejected, expired or refused by its precondition has its event exactly when that change is committed, and never when it is rolled back. A background worker on every Tether instance sends it from there. Nothing is sent inside the request that staged, confirmed or rejected the action, so a slow, failing or hanging receiver never makes those slower, and the turn that hands a conversation to a person does not wait for it either.
| When | Usually within a second or two of the change |
| Timeout | Each attempt waits up to 10 seconds for your answer |
| Delivered | Your receiver answered 2xx |
| Retried | Any other answer, a timeout or an unreachable receiver. The waits double from 2 seconds, with jitter, up to 5 minutes |
| Given up | After 6 attempts the event is failed and not sent again. An address that fails the address checks fails at once |
Delivery is at least once. Deduplicate on request_id. Every attempt at one event carries the
same request_id (also sent as X-Tether-Idempotency-Key) and the same body. The timestamp and the
signature are made at each attempt, so a retry is never refused as stale. X-Tether-Delivery-Attempt
numbers the attempts from 1. An event can arrive twice: a worker that stops after your receiver
answered but before it recorded the answer leaves the event to be sent again.
Ordering. One action's events arrive in the order they happened: Tether sends none of them while
an earlier event for the same action is still waiting or being retried (an event that has failed
for good stops holding the next). Across actions and conversations there is no order. Every event
carries occurred_at, the moment it was raised, which is the field to order by.
Every attempt is recorded, so a receiver that has been failing does not go unnoticed:
GET /v1/events/deliveries?failed=true&limit=50
{
"deliveries": [
{
"event": "action.pending",
"actionId": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
"sessionId": null,
"url": "https://platform.example.com/tether/events",
"destination": "events_webhook",
"delivered": false,
"statusCode": 500,
"error": "the receiver answered 500",
"durationMs": 41,
"attemptedAt": "2026-10-14T10:13:42Z",
"eventId": "6ad75e9b73bd464ba811bf88e449495a",
"attempt": 1,
"retryAt": "2026-10-14T10:13:44Z"
}
],
"count": 1
}
| Parameter | Default | Notes |
|---|---|---|
failed | — | true for attempts that did not arrive, false for ones that did |
event | — | One event type, such as conversation.handoff |
actionId | — | The attempts about one action |
eventId | — | Every attempt at one event, by its request_id |
limit | 50 | 1 to 200, newest first |
eventId is the event's request_id, and attempt counts from 1. retryAt says when the next
attempt is due; an undelivered attempt with no retryAt was the last one. error is the receiver answered <status>, timeout: …, unreachable: …, invalid_endpoint: … (the stored address no
longer passes the address checks) or no_destination: … (the tenant cleared both its events webhook
and its tool endpoint before the event was sent). An event goes to the destination the tenant has
when it is sent, signed with its signing secret at that moment. The event's body is not kept in the
log; what happened to the action is in the audit. GET /v1/events/webhook also
carries the most recent attempt as lastDelivery and the most recent failure as lastFailure.
For a software provider
Set the events webhook on your own tenant and every business you
create starts with a copy of it (the configuration part events_webhook),
so your platform's one receiver hears about all of them. Each event is still signed with that
business's signing secret, the one you received when you created it, and names its tenant in
tenant.organization_id, so verify with the secret for the tenant the event names.