Human Handoff

When your agent cannot help, the conversation goes to a person — because the visitor asked, or because the agent worked out for itself that it was stuck. Answer from the Tigerbase inbox, or wire it into the helpdesk your team already lives in.

This page is the second option: a server-to-server API and a webhook, so a handoff can arrive in Zendesk, Freshdesk, Intercom, Slack or something you wrote yourself, and your agent's reply reaches the visitor's widget live.

How a conversation moves

The webhook is the trigger; the API is how you answer.

The webhook is the trigger and the API is the response. You do not need to poll; the list endpoint exists for reconciliation and for a first sync, not as the normal path.

1. Get your credentials

In the dashboard, open your agent, then Settings → Integration and generate a key pair. You will see something like:

text
integrationKey:    tb_srv_6e8c7…
integrationSecret: tbsrv_22…

The secret is shown once

Store it before closing the dialog. It is hashed on our side, so we cannot show it to you again — generating a new pair is the only way back, and that invalidates the old one.

These are not your widget keys

The widget's X-API-Key pair is embedded in the embed snippet on your public site — anyone who views the page source has it. These integration credentials are separate and must never reach a browser. Call this API from your server only.

2. Receive the webhook

Set your endpoint and a signing secret in the same settings panel. We POST JSON to it, with headers:

http
X-Tigerbase-Event: handoff.requested
X-Tigerbase-Signature: t=1758741059,v1=6a1f…
Content-Type: application/json

Three events are sent: handoff.requested, handoff.visitor_message and handoff.resolved. The body:

json
{
  "event": "handoff.requested",
  "sentAt": "2026-09-24T19:30:59.576Z",
  "chatbot": { "id": 2, "name": "Demo Agent" },
  "handoff": {
    "id": 7,
    "status": "pending",
    "browserId": "docs-demo-1",
    "reason": "Refund not processed",
    "visitorName": "Layla",
    "visitorEmail": "layla@example.com",
    "createdAt": "2026-09-24T19:30:59.576Z"
  }
}

3. Verify the signature

The header is t=<unix seconds>,v1=<hex>, and the signed value is `${timestamp}.${rawBody}` — the timestamp is inside the signature so a captured request cannot be replayed later.

Sign the raw bytes

Verify before parsing, against the exact body you received. JSON parsed and re-serialised is not the same bytes — key order, spacing and number formatting all move — and the signature will never match.
ts
// Express — note express.raw, not express.json
import crypto from "node:crypto";

app.post("/tigerbase/handoff",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("X-Tigerbase-Signature") ?? "";
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=")),
    );

    const expected = crypto
      .createHmac("sha256", process.env.TIGERBASE_WEBHOOK_SECRET)
      .update(`${parts.t}.${req.body}`)
      .digest("hex");

    const ok =
      parts.v1?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));

    if (!ok) return res.status(401).end();

    // Reject anything older than five minutes.
    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
      return res.status(401).end();
    }

    const payload = JSON.parse(req.body.toString());
    // Answer fast, then do the work. See the delivery note below.
    res.status(200).end();
    void handleHandoff(payload);
  });
python
# Python / Flask
import hmac, hashlib, time
from flask import request, abort

@app.post("/tigerbase/handoff")
def handoff():
    header = request.headers.get("X-Tigerbase-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    raw = request.get_data()  # bytes, before parsing

    expected = hmac.new(
        SECRET.encode(),
        f"{parts.get('t')}.".encode() + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(parts.get("v1", ""), expected):
        abort(401)
    if abs(time.time() - int(parts["t"])) > 300:
        abort(401)

    return "", 200

Delivery: three attempts, then it stops

Each attempt times out after 8 seconds and we try 3 times. Answer 2xx quickly and do your work afterwards — a slow handler looks like a failure and gets retried, so you may see the same event twice. Key your processing on handoff.id so a repeat is harmless.

Redirects are not followed, and the URL is resolved once and checked before we connect, so a webhook pointing at a private or internal address is refused. If your endpoint is unreachable the handoff still appears in the Tigerbase inbox — nothing is lost, and a visitor is never blocked by your server being down.

4. The API

Base URL /integrations/handoff. Every request carries both headers:

http
X-Integration-Key: tb_srv_6e8c7…
X-Integration-Secret: tbsrv_22…

The credentials belong to one agent, which is why no route takes an agent id — scope is implied by the key you used.

GET/integrations/handoff/conversations
Open conversations, oldest first. Optional ?status=pending|active|resolved|expired.
json
[
  {
    "id": 7,
    "status": "pending",
    "browserId": "docs-demo-1",
    "reason": "Refund not processed",
    "visitor": { "name": "Layla", "email": "layla@example.com" },
    "createdAt": "2026-09-24T19:30:59.576Z",
    "lastMessageAt": "2026-09-24T19:30:59.576Z"
  }
]
GET/integrations/handoff/conversations/:id
One conversation with the whole thread, including what the bot said before the handoff.
json
{
  "id": 7,
  "status": "pending",
  "browserId": "docs-demo-1",
  "reason": "Refund not processed",
  "visitor": { "name": "Layla", "email": "layla@example.com" },
  "createdAt": "2026-09-24T19:30:59.576Z",
  "transcript": [
    { "role": "user",  "content": "where is my refund",  "at": "…" },
    { "role": "model", "content": "Let me check that…",  "at": "…" },
    { "role": "agent", "content": "Hi Layla — …",
      "authorName": "Sara from Support", "at": "…" }
  ]
}

role is user (the visitor), model (the agent) or agent (a person).

POST/integrations/handoff/conversations/:id/reply
Send a reply. It reaches the visitor's widget over the same live channel a dashboard reply uses, so they see it immediately.
json
{
  "message": "Hi Layla — I can see the refund. It is on its way.",
  "authorName": "Sara from Support"
}

message is required, 1–4000 characters. authorNameis optional (max 80) and is what the visitor sees; it defaults to “Support”.

json
{
  "delivered": true,
  "message": {
    "role": "agent",
    "content": "Hi Layla — I can see the refund. It is on its way.",
    "timestamp": "07:31 PM",
    "at": "2026-09-24T19:31:12.415Z",
    "authorName": "Sara from Support"
  }
}

Use at for anything you store or sort by — timestampis a display string in the agent's local format and is not machine-readable.

POST/integrations/handoff/conversations/:id/resolve
Close it. The bot resumes answering that visitor. Idempotent — resolving an already-resolved conversation returns the same body and is not an error, so a retry after a timeout is safe.
json
{ "id": 7, "status": "resolved" }

Errors

CodeMeaning
401Missing or wrong credentials. Both headers are required.
404No such conversation for this agent — the same answer whether it does not exist or belongs to someone else, so ids cannot be probed.
400Either the body failed validation (the message names the field), or you replied to a conversation that is already resolved. Resolved is terminal for replies.

A complete flow

bash
# 1. the visitor's request arrives on your webhook (verified above)
#    payload.handoff.id === 7

# 2. read the thread so your agent has context
curl https://api.tigerbase.cloud/integrations/handoff/conversations/7 \
  -H "X-Integration-Key:    $TIGERBASE_KEY" \
  -H "X-Integration-Secret: $TIGERBASE_SECRET"

# 3. answer
curl -X POST \
  https://api.tigerbase.cloud/integrations/handoff/conversations/7/reply \
  -H "X-Integration-Key:    $TIGERBASE_KEY" \
  -H "X-Integration-Secret: $TIGERBASE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"message":"On its way.","authorName":"Sara from Support"}'

# 4. close it — the bot takes over again
curl -X POST \
  https://api.tigerbase.cloud/integrations/handoff/conversations/7/resolve \
  -H "X-Integration-Key:    $TIGERBASE_KEY" \
  -H "X-Integration-Secret: $TIGERBASE_SECRET"

Worth knowing

  • An unanswered handoff expires after an hour. The bot resumes so the visitor is not left talking to nobody. You can still claim and reply to an expired conversation afterwards.
  • Both places stay in sync. A reply sent through this API shows in the Tigerbase inbox marked as answered elsewhere, so your team does not answer it twice.
  • Generating new credentials invalidates the old pair immediately. Deploy the new secret before you rotate.
  • browserIdidentifies the visitor's browser, not a person. It is stable for that browser and is what the transcript is keyed on.

Prefer not to build this? The Tigerbase inbox does the same job with no integration at all — this API exists for teams who already have a helpdesk they do not want to leave.