Tether
DocsGitHub

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 setEvents go to
An events webhookThe events webhook
An events webhook and a tool endpointThe events webhook. The one you set for events wins
A tool endpoint onlyThe tool endpoint, as they always have
NeitherNowhere. 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.

eventSent when
action.pendingA write was staged and is waiting for a person. Carries summary and expires_at
action.approvedA person approved it under your approval policy
action.rejectedA person declined it. Carries reason
action.expiredIts confirmation window passed before anyone confirmed it
action.executedYour API ran it and answered success
action.verifiedIts verify read showed the effect
action.unverifiedIt did not, or could not be read. Carries reason; a conversation.handoff follows
action.precondition_failedAt confirmation its precondition no longer held, so it was settled without the write being called. Carries reason
action.provider_refusedThe confirmed write was sent to your imported API, which refused it without applying it. Carries reason, with your status, code and words
conversation.handoffA 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.

WhenUsually within a second or two of the change
TimeoutEach attempt waits up to 10 seconds for your answer
DeliveredYour receiver answered 2xx
RetriedAny other answer, a timeout or an unreachable receiver. The waits double from 2 seconds, with jitter, up to 5 minutes
Given upAfter 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
}
ParameterDefaultNotes
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
limit501 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.