Webhooks
TAP posts approval events to your own endpoint, so your app can show a pause and
its outcome without polling GET /agent/approvals/:txn_id.
Add an endpoint under Team → Webhooks in the dashboard. TAP generates a
signing secret and shows it once — copy it then; it is stored encrypted and
no read endpoint returns it. Endpoints must be https, and TAP refuses a host
that resolves to a private or internal address.
Delivery never blocks or delays an approval. If your endpoint is down you lose
notifications, not approvals — the dashboard inbox and
GET /agent/approvals/:txn_id remain the source of truth.
Events
| Event | When |
|---|---|
approval.requested | A credentialed request paused and is waiting for a human. |
approval.decided | A human approved or denied it. |
approval.expired | Nobody decided in time — or the agent withdrew the request. |
Select the ones you want per endpoint; selecting none subscribes to all.
Payloads
Every body is JSON. txn_id is the same id /forward returned in its 202 and
the same one GET /agent/approvals/:txn_id answers to.
approval.requested
{
"event": "approval.requested",
"txn_id": "550e8400-e29b-41d4-a716-446655440000",
"credential_name": "stripe",
"method": "POST",
"target_host": "api.stripe.com",
"body_preview": "{\"amount\":2000,\"currency\":\"usd\"}",
"summary": "Create a $20.00 charge",
"approval_url": "https://app.tap.human.tech/approve/txn/550e8400-…",
"expires_at": "2026-06-05T14:37:15Z",
"notification_channel": "dashboard"
}target_host is the host only, never the full URL — a path or query can carry an
identifier a webhook receiver has no business seeing just to render “something is
waiting”. body_preview and summary may be null.
approval.decided
{
"event": "approval.decided",
"txn_id": "550e8400-e29b-41d4-a716-446655440000",
"outcome": "approved",
"approver": "sam@example.com",
"channel": "dashboard",
"upstream_status": 200
}outcome is approved or denied. channel is the approval channel the request
was routed to (dashboard, telegram, matrix, agent_reflected), not the
surface the person typed the decision into. upstream_status is the status TAP
got after forwarding, and is null for a denial (nothing was sent) or when the
forward produced no status.
approval.expired
{ "event": "approval.expired", "txn_id": "550e8400-e29b-41d4-a716-446655440000" }Headers and signature
POST /your/endpoint
Content-Type: application/json
TAP-Event: approval.requested
TAP-Delivery: 9f1c… # unique per delivery — use it as an idempotency key
TAP-Signature: t=1700000000,v1=668389f6…v1 is the hex HMAC-SHA256 of "<t>.<raw request body>", keyed by the
subscription’s signing secret. Verify against the raw bytes you received —
re-serializing the JSON changes them.
The timestamp is inside the signed string, so a captured delivery cannot be
replayed later under a fresh t. Reject a t outside your own tolerance
(five minutes is a reasonable window).
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)import crypto from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Retries
Answer with any 2xx as soon as you have stored the event; do the work
afterwards. TAP treats anything else — including a 3xx, which it does not
follow — as a failed attempt.
Five attempts over about ten minutes (immediately, then after 15s, 60s, 3m and
6m), with a 10-second timeout per attempt. Deliveries repeat on retry, so
de-duplicate on TAP-Delivery.
Team → Webhooks lists recent deliveries with their status, attempt count and the upstream error, and a manager can Resend one. A resend sends the stored bytes again with a freshly signed timestamp, so it passes your replay window.