Events
What your server receives, and how to check it came from HAI.
HAI posts each event to your events address as JSON, with the headers HAI-Event-Id and HAI-Signature. Answer any 2xx within 10 seconds. Anything else is tried again for up to 24 hours; a 4xx other than 401, 403, 408 and 429 ends it at once.
Check the signature
HAI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>. Refuse anything older than five minutes and anything that does not match; answer 401. The portal's signature check tests that your server does.
# Python
import hmac, hashlib, time
def valid(secret: str, header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, v1)
// Node.js
const crypto = require("crypto");
function valid(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const mac = crypto.createHmac("sha256", secret).update(parts.t + ".").update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(parts.v1 || ""));
}
// Go
func valid(secret, header string, body []byte) bool {
var t, v1 string
for _, p := range strings.Split(header, ",") {
k, v, _ := strings.Cut(p, "=")
if k == "t" { t = v } else if k == "v1" { v1 = v }
}
sec, err := strconv.ParseInt(t, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 { return false }
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(t + "."))
m.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(v1))
}
Check the raw body, before any JSON parsing. To drop an event you already handled, use the id inside the signed body (the same value as HAI-Event-Id): a header is not covered by the signature, so someone who recorded an event could resend it under a new header id within the five minutes.
Event types
| Type | When |
|---|---|
message.created | A message in one of its chats (message.history is true for one from before it joined). |
message.unreadable | A message that was not sealed for the agent, with a reason. |
message.edited | Someone edited a message; the event carries the new message. |
message.deleted | Someone deleted a message for everyone (message_id). |
message.reaction | A reaction set or removed (reaction.emoji empty). |
sync.finished | A members sync ended (run). |
member.left, member.removed_by_admin, member.joined_hai | Members it manages. |
agent.removed, agent.not_admin, agent.admin_again | A group admin changed the agent's place. |
group.full, group.gone | The group reached 1,024 members, or was deleted. |
test | From the page's “Send a test event”. |
A message
| Field | Meaning |
|---|---|
message.kind | text, image, video, gif, audio, file, sticker, location, contact, poll. |
message.sender | uid, name, and phone when allowed (see How HAI works). |
message.text, caption, mentions | The text; mention offsets in UTF-16 units. |
message.addressed_to_agent | The message mentions the agent or replies to it. |
message.reply_to | The quoted message's id and sender. |
message.media | url and thumbnail_url to fetch with the agent's key, mime_type, size, width, height, duration, file_name. |
message.location, contact, poll | The location, the contact card, the poll. |
message.expires_at | When the chat's timer ends the message. |
message.history | Sent before the agent joined (only when a group admin shared the earlier messages). |