Tether
DocsGitHub

API reference

Actions

One endpoint stages a write, one lists the writes waiting, and confirm is the only thing in the system that can make one happen. Reject says no, and reconcile settles a write whose outcome is unknown.

Stage a write directly

POST /v1/actions

Stages a registered write without a chat turn, exactly as a model call would. Nothing reaches your endpoint until the action is confirmed. Tenant secret key only.

curl -X POST $TETHER_URL/v1/actions \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tool": "cancel_order", "arguments": { "id": "A1" } }'
{
  "status": "preview",
  "toolName": "cancel_order",
  "actionId": "8f1c…",
  "confirmationToken": "tac_…",
  "expiresAt": "2026-09-30T05:03:02Z",
  "summary": "cancel order (id: A1) — awaiting confirmation.",
  "resultJson": "{ … }"
}

actionId is what you pass to confirm. resultJson carries the same preview as an escaped string and is kept for compatibility; read the top-level fields instead. A tool that is not registered answers 404, a read tool or arguments missing a required property answer 400, and actionId is null in each case.

A write that declares a precondition is checked before it is staged. When your system says no, nothing is staged and the answer is 409 with status precondition_failed; resultJson carries the reason, the check, and your read's result. When the read cannot be made, the answer is 502 with status precondition_error, and nothing is staged either.

List pending actions

GET /v1/actions?limit=50&scope=3&tool=cancel_order

Writes staged for this tenant and still awaiting a decision, newest first. With status, the writes in that state instead, such as status=executed to see what verification found.

ParameterDefaultNotes
limit50Clamped to 1–200.
scope—Only actions staged under this scope id.
tool—Only actions for this tool.
statuspendingpending, executed, failed, rejected, expired, indeterminate or precondition_failed.
{
  "actions": [
    {
      "actionId": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
      "tool": "void_ticket",
      "commandType": "void_ticket",
      "title": "void_ticket",
      "rationale": null,
      "arguments": { "ticket_id": "T-4417" },
      "status": "pending",
      "scopeId": 3,
      "subject": "G1",
      "sessionId": "5b0f3c2e-8a1d-4f6b-9e27-3c4d5e6f7a81",
      "requestedByUserId": 431,
      "createdAt": "2026-08-20T18:41:07Z",
      "expiresAt": "2026-08-20T19:41:07Z",
      "verification": null
    }
  ],
  "count": 1
}

sessionId is the conversation whose turn staged the write, or null when it was staged here directly. verification is null until an executed write that declares verify has been read back, and then has the shape described under Verification.

The listing is scoped to the tenant, not to the user or session: one operator sees writes another operator's turn staged. Who may approve is decided by your approval policy, not by who staged the action, so an approval queue can show every waiting write to the people allowed to act on it. commandType and tool are the same value.

Confirm an action

POST /v1/actions/{actionId}/confirm
curl -X POST https://api.trytether.ge/v1/actions/9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33/confirm \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "X-Tether-User-Id: 431" \
  -H "Content-Type: application/json" \
  -d '{ "acknowledgeHighRisk": true, "idempotencyKey": "confirm-9c1e7b6a-attempt-1" }'

X-Tether-User-Id names the approver. With no users and no policy registered it is accepted as stated; once you register users and a policy, the person must be registered and their role must be allowed to approve that tool.

FieldTypeDefaultNotes
acknowledgeHighRiskbooleanfalseRequired for a write registered elevated or high risk, and for one whose declared value is at or above the threshold — or could not be read at all.
idempotencyKeystring—Your own key. A repeat with a key that matched a prior successful execution returns that stored result and runs nothing.

The request body may be omitted entirely, which is equivalent to sending both defaults.

What happens, in order

  1. Replay check. If idempotencyKey matches a prior successful execution for this tenant and user, its stored result is returned as Replayed.

  2. Lookup. The action must exist, belong to this tenant, be pending, and be unexpired. An expired action answers Expired and is never claimed; a rejected one answers Rejected. Actions also expire on a schedule, without anyone opening them, and the business is told once with action.expired (see Events webhook).

  3. Who is approving. With users and a policy registered, X-Tether-User-Id must name an active registered user whose role may approve this tool, and a policy that needs several approvers holds the action at AwaitingApprovers until enough different people have confirmed. With none registered, the id is accepted as stated.

  4. Handler resolution. The command type must still be a registered write in the current catalog.

  5. The guard. Tenant boundary, then the risk acknowledgement.

  6. The precondition. For a write that declares one, the read is called again, for the action's customer. If the check no longer holds, the action is settled as PreconditionFailed and the write is never called. If the read fails, the write is not called and the action stays pending (PreconditionError).

  7. The claim. A compare-and-set flips pending → executing. Losing it means another confirm won.

  8. Execution. Your endpoint is called with "mode": "execute". Once, never retried. If it does not answer, the action becomes Indeterminate and waits for reconcile.

  9. Verification. For a write that declares verify, once the execution is recorded, the read is called once, for the action's customer, and its answer is recorded as verified or unverified. It never calls the write again and never changes the status above.

Response

{
  "status": "Executed",
  "actionId": "9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33",
  "commandType": "void_ticket",
  "appliedChanges": { "voided": true, "ticket_id": "T-4417" },
  "errorCode": null,
  "errorMessage": null,
  "verification": {
    "status": "verified",
    "reason": "get_ticket reports state = \"void\".",
    "checkedAt": "2026-08-20T18:43:12Z",
    "handoff": null
  }
}

appliedChanges is whatever your endpoint returned, passed through unmodified.

Verification

verification is present on an Executed (and a Replayed) answer for a write that declares verify, and null otherwise.

FieldMeaning
statusverified: your read showed the effect. unverified: it did not, or it failed, or it did not answer within a few seconds.
reasonWhat the read reported, or why it could not be checked.
checkedAtWhen the read answered or gave up.
handoffOn unverified only: { "trigger": "unverified_outcome", "sessionId": … }. A person has been handed the outcome, and sessionId names the conversation that staged the write, or is null.

An unverified outcome is not a failed action. The write happened, the status is still Executed, and nothing will run it again. Tell the customer that the request was sent and a person will check it, not that it is done.

StatusHTTPMeaningWhat to show
Executed200Your endpoint ran it and returned successDone, unless verification.status is unverified: then "Sent; a person will check it"
Replayed200The key matched a prior success; nothing ran againDone
Rejected200The action was rejected, now or earlier; it can never be confirmed"This request was declined"
AwaitingApprovers202Your approval is recorded, but the policy needs more distinct approvers. approversRequired and approversReceived say how many"Waiting for another approver"
NotFound404Unknown, already executed, or staged for a different tenant"This request is no longer valid — ask again"
Expired410The confirmation window passed; the action is gone"This request expired — ask again"
ApproverUnknown403No X-Tether-User-Id, or not an active registered user, while a policy is in forceSign the approver in as a registered user
ApprovalForbidden403The user's role may not approve this tool under your policy"You cannot approve this"
Prohibited451Categorically refused; no approver or credential clears itShow errorMessage, do not offer a retry
Indeterminate409Your endpoint was called and did not answer, so the write may or may not have happenedDo not retry; reconcile
PreconditionFailed409The write's precondition no longer held when it came to execute. The write was never called and the action is settled; confirming again answers the sameShow errorMessage; the customer asks again if their situation changes
PreconditionError503The precondition's read failed or did not answer. The write was not called and the action is still pendingOffer to confirm again later
ProviderRefused409The write was sent to your imported API, which refused it without applying it. errorMessage carries your status, code and reason. The action is settled; confirming again answers the sameShow errorMessage; the customer asks again if their situation changes
GuardForbidden400Wrong tenant, or your authorizer refused"You cannot approve this"
GuardRejected400Risk acknowledgement missingRe-prompt with the deliberate gesture
Conflict400Another confirm is already executing this action"Already in progress" — do not retry
HandlerMissing400The command is not a registered write in the current catalogAn integration bug: the catalog changed underneath the action
FailedRetryable400Transient failure; the action was released back to pendingOffer a retry, bounded to 3 total attempts
FailedTerminal400Terminal failure; the action is consumedShow errorMessage, do not offer a retry

Error codes

errorCodeWith status
PENDING_ACTION_NOT_FOUNDNotFound
action_expiredExpired
action_rejectedRejected
approver_unknownApproverUnknown
approver_forbiddenApprovalForbidden
awaiting_approversAwaitingApprovers
action_prohibitedProhibited
EXECUTION_INDETERMINATEIndeterminate
precondition_failedPreconditionFailed
precondition_errorPreconditionError
provider_refusedProviderRefused
HANDLER_NOT_REGISTEREDHandlerMissing
AI_ACTION_REQUIRES_CONFIRMATIONGuardRejected
ACTION_ALREADY_IN_PROGRESSConflict
EXECUTION_FAILED_RETRYABLEFailedRetryable
EXECUTION_ATTEMPTS_EXHAUSTEDFailedTerminal, after 3 attempts
EXECUTION_FAILED, VALIDATION_ERROR, RESOURCE_NOT_FOUND, FORBIDDEN, CONFLICTFailedTerminal
user_token_requiredFailedTerminal: the imported API requires the signed-in person's own token and the confirm carried none. The API was not called
ACTION_NOT_INDETERMINATEConflict, from reconcile on an action that is not indeterminate

Reject an action

POST /v1/actions/{actionId}/reject
curl -X POST $TETHER_URL/v1/actions/9c1e7b6a-3f42-4a51-9d0e-6b2c8f4a1d33/reject \
  -H "X-Api-Key: $TETHER_SECRET_KEY" \
  -H "X-Tether-User-Id: 431" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer withdrew the request" }'

The same identity rule as confirm: with a policy in force the user must be an active registered user, but no role is needed, because a no is always allowed. The answer is 200 with status Rejected. A rejected action can never be confirmed afterwards; confirming it again answers Rejected, not an error.

Reconcile an action

POST /v1/actions/{actionId}/reconcile

For an action whose status is Indeterminate: your endpoint was called and did not answer. This sends the write again under the original idempotency key, which is safe because your endpoint claims that key. It either replays its first result or performs the write for the first time, and cannot perform it twice. Tenant secret key only; a confirmation token cannot reconcile. If the answer is still ambiguous the action stays Indeterminate, so reconciling twice is safe.

On an action that is not indeterminate it answers 400 with status Conflict and code ACTION_NOT_INDETERMINATE, and calls nothing.

The risk acknowledgement

{
  "status": "GuardRejected",
  "actionId": "9c1e7b6a…",
  "commandType": "void_ticket",
  "errorCode": "AI_ACTION_REQUIRES_CONFIRMATION",
  "errorMessage": "'void_ticket' is registered as a high-risk write. Re-submit the confirmation with acknowledgeHighRisk = true to proceed."
}

Expiry

A staged action expires after a time set by its declared risk: 24 hours for low, 4 hours for elevated, 1 hour for high. A proposal made against a picture of the world that has since moved on should not be confirmable. Confirming after the deadline answers 410 with status Expired and never executes.

expiresAt comes back when the write is staged and in the listing. Show the remaining time on the card, and stop offering the button when it passes.