Most questions your docs can't answer are about the visitor themselves: "where's my order?", "what plan am I on?", "is my server up?". With Look it up, the bot asks your own system and answers from what it says. It's read-only: the bot can look things up, never change them. Look it up comes with the Team plan.
It takes two parts, both on your own server:
- Signed visitors. Your site tells tufu who the visitor is, signed so nobody can pretend to be someone else.
- Lookups. Addresses on your server that answer with JSON. tufu calls them for the bot, signed, and the bot sees only the fields you allow.
Admins set both up on the bot's Look it up tab.
Signed visitors#
On the Look it up tab, press Make the secrets. tufu shows two secrets, once each:
- the identity secret, which your server uses to sign who the visitor is;
- the signing secret, which tufu uses to sign its requests to your lookups.
Keep both on your server, never in a web page. New identity secret and New signing secret replace one at once, if one leaks.
When a signed-in customer loads a page, your server makes a token for them and puts it on the chat:
<tufu-chat team="your-team" bot="your-bot" identity="TOKEN"></tufu-chat>
The token is the visitor's details as JSON (user_id, which is required, then email and name if you want the
inbox to show them, and exp, when it stops working, at most 24 hours ahead), base64url
encoded, a dot, and an HMAC-SHA256 of that text with the identity secret. Make a new one on each page load, with a short
life (an hour is plenty):
import base64, hashlib, hmac, json, time
IDENTITY_SECRET = "idsec_..." # from the Look it up tab
SIGNING_SECRET = "lksec_..."
def identity_token(user) -> str:
claims = {"user_id": str(user.id), "email": user.email, "name": user.name, "exp": int(time.time()) + 3600}
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode()
return payload + "." + hmac.new(IDENTITY_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
def from_tufu(header: str, signed: bytes) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(SIGNING_SECRET.encode(), parts["t"].encode() + b"." + signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
tufu checks the token when the conversation starts. A token that's wrong, expired or for another bot is ignored: the visitor chats as an ordinary visitor and never sees an error. A signed conversation keeps its visitor: someone else signing in on the same browser starts a new one.
Answers to a signed visitor are never kept in the Ask console's quick-answer store, so one customer's answer never reaches another.
Add a lookup#
Under Add a lookup:
- Name: how the bot calls it, like
order_status. - When to use it: in plain words, for the bot: "When the visitor asks where their order is."
- Address and Method: an https address on your server, GET or POST.
- What the visitor gives (optional): the parameter for something the visitor types, like
order_number, and What it looks like ("The order number, like 1042"). The bot only passes something the visitor wrote in the conversation. - Fields the bot may see: one per line, a name in your answer (
status) or a path into it (order.eta,items.0.name). Nothing else in your answer reaches the bot. - Only for signed-in visitors: on by default. Leave it on for anything personal. Turned off, the lookup also runs for visitors your site didn't sign, and in email, Discord and Telegram conversations.
Then press Test with a sample user id and input: tufu calls your address as the bot would and shows what the bot would see, and the names of every field in your answer, so you can pick the ones to allow.
What tufu sends#
For a signed visitor, tufu sends their user_id from the token, plus the visitor's input if the lookup has one. A
GET adds them to the address (?user_id=u_123&order_number=1042); a POST sends them as JSON
({"user_id": "u_123", "order_number": "1042"}). The Tufu-Lookup header names the lookup.
Every request carries a Tufu-Signature header, the same as webhooks': t=<unix time>,v1=<signature>,
an HMAC-SHA256 of <unix time>.<signed text> with the signing secret. The signed text is the raw body for a POST,
and for a GET the query string exactly as sent (everything after the ?). Check it with from_tufu above, then look
up only what belongs to that user_id:
from flask import Flask, abort, request
app = Flask(__name__)
@app.get("/tufu/order")
def order_status():
if not from_tufu(request.headers.get("Tufu-Signature", ""), request.query_string):
abort(401)
order = find_order(request.args["user_id"], request.args["order_number"]) # only this user's orders
if order is None:
abort(404)
return {"status": order.status, "eta": order.eta.isoformat()}
The same in Node:
const crypto = require("crypto");
const express = require("express");
const app = express();
function identityToken(user) {
const claims = { user_id: String(user.id), email: user.email, name: user.name, exp: Math.floor(Date.now() / 1000) + 3600 };
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
return payload + "." + crypto.createHmac("sha256", IDENTITY_SECRET).update(payload).digest("hex");
}
function fromTufu(header, signed) {
const parts = Object.fromEntries(header.split(",").map((item) => item.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac("sha256", SIGNING_SECRET).update(`${parts.t}.${signed}`).digest("hex");
const given = Buffer.from(parts.v1 || "");
return given.length === 64 && crypto.timingSafeEqual(Buffer.from(expected), given);
}
app.get("/tufu/order", async (req, res) => {
if (!fromTufu(req.get("Tufu-Signature") || "", req.originalUrl.split("?")[1] || "")) return res.sendStatus(401);
const order = await findOrder(req.query.user_id, req.query.order_number); // only this user's orders
if (!order) return res.sendStatus(404);
res.json({ status: order.status, eta: order.eta });
});
What tufu reads back#
Answer within 5 seconds with JSON of at most 8 KB. A 404 means "nothing found", and the bot says so. tufu doesn't follow redirects and only calls addresses on the public internet. Anything else (an error, a timeout, an answer that isn't JSON) is a failed lookup: the bot tells the visitor it couldn't check and offers a person.
What your team sees#
Each lookup shows in the conversation in your inbox ("Looked up order_status"). A failed one shows there too, and the visitor's question lands in knowledge gaps, so you see what visitors couldn't get.
Limits#
Up to 5 lookups per bot. The bot looks up at most 10 times in one conversation, and a few times a minute for one visitor, so a visitor or a script can't run up your system.