Integrations

The webhook contract

AskVoro's signed customer events use one format. Customer lookups, verification notifications, and POST tool calls made with a workspace signing secret all use the same envelope with a different type, so you write the verification once and handle events in a switch.

POST tools without a signing secret and all GET, PUT, PATCH, and DELETE tools use the separate plain-fetch path. They are not covered by this contract and must not be used for trusted personal-data access.

The request#

http
POST /your/webhook/endpoint
Content-Type: application/json
X-Chat-Signature: t=1735689600,v1=<hex hmac-sha256>
json
{
  "id": "evt_...",
  "type": "tool.track_order",
  "createdAt": 1735689600,
  "workspaceId": "your-workspace-id",
  "data": {}
}

Both createdAt and the t= signature timestamp are Unix seconds.

Event types#

TypeSent when
user.lookupChecking whether an email belongs to a known customer
identity.verifiedA visitor completed email verification
tool.<tool_name>A POST tool fired, using that tool's snake_case name

A tool named track_order arrives as tool.track_order.

Verifying the signature#

The header carries a timestamp and an HMAC:

text
X-Chat-Signature: t=<unix seconds>,v1=<hex hmac-sha256>

The signed value is the timestamp, a literal ., and the raw request body:

text
<t>.<raw body>

signed with your workspace's signing secret using HMAC-SHA256.

Two rules that catch most people:

Verify against the raw body, before any JSON parsing. Parse and re-serialise and the bytes change, so the signature won't match — even though the data is identical.

Compare in constant time. Use hash_equals, crypto.timingSafeEqual or your language's equivalent, not ==.

Node:

javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=")));
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");

  const received = Buffer.from(parts.v1, "hex");
  const computed = Buffer.from(expected, "hex");
  return received.length === computed.length && timingSafeEqual(received, computed);
}

PHP:

php
function askvoro_verify(string $rawBody, string $header, string $secret): bool {
  parse_str(str_replace(',', '&', $header), $parts);
  $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
  return hash_equals($expected, $parts['v1'] ?? '');
}

Also reject events whose timestamp is far from now — a few minutes of tolerance is plenty — so a captured request can't be replayed later.

Your signing secret#

Mint it in the dashboard. It's stored server-side and never exposed to the browser or the widget.

Treat it like a password: environment variable, not source control. If it leaks, rotate it — anyone holding it can forge events that look exactly like ours.

Responding#

Return 200 with a JSON body. Whatever you return for a tool.* event is what the AI sees, so return the smallest useful answer: the fields needed to reply, not your whole order object.

Return an error status and the AI treats the call as failed and tells the visitor it couldn't look it up.

Scope every response to the verified email#

Worth repeating here, because this is where it's enforced.

For a signed POST tool, AskVoro injects the visitor's verified email into the envelope server-side when a verified session exists. The model cannot supply, omit, or change it.

For any tool that returns customer data, leave verification required and use that injected email as the scope of the query. Never trust an order number, customer id, or account reference the model passed to widen what a request can reach — those came from the conversation and a visitor can say anything.

Verification controls what the AI can see. Your handler controls what it can get. Only the second one is a security boundary.

Testing#

The WordPress plugin in our examples is a complete working implementation of this contract, including signature verification, and is a reasonable thing to read even if you're not using WordPress.