Webhooks

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

EventWhen
approval.requestedA credentialed request paused and is waiting for a human.
approval.decidedA human approved or denied it.
approval.expiredNobody 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.