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 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:
integrationKey: tb_srv_6e8c7…
integrationSecret: tbsrv_22…The secret is shown once
These are not your widget keys
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:
X-Tigerbase-Event: handoff.requested
X-Tigerbase-Signature: t=1758741059,v1=6a1f…
Content-Type: application/jsonThree events are sent: handoff.requested, handoff.visitor_message and handoff.resolved. The body:
{
"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
// 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 / 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 "", 200Delivery: three attempts, then it stops
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:
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.
/integrations/handoff/conversations?status=pending|active|resolved|expired.[
{
"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"
}
]/integrations/handoff/conversations/:id{
"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).
/integrations/handoff/conversations/:id/reply{
"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”.
{
"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.
/integrations/handoff/conversations/:id/resolve{ "id": 7, "status": "resolved" }Errors
| Code | Meaning |
|---|---|
| 401 | Missing or wrong credentials. Both headers are required. |
| 404 | No such conversation for this agent — the same answer whether it does not exist or belongs to someone else, so ids cannot be probed. |
| 400 | Either 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
# 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.