Appearance
Webhooks
Webhooks send events to your own endpoint as they happen: a new conversation, each message, a lead, a hand-off, a rating. Add endpoints in the admin panel under Settings, Webhooks (admins only), choosing the events each one gets.
Events
| Event | When | data |
|---|---|---|
conversation.started | A visitor's first message started a conversation | botId, conversationId, visitorId |
conversation.ended | However it ended (reason: agent said goodbye, closed_by_team, idle, or visitor_new_conversation when the visitor started a new one) | botId, conversationId, reason |
message.created | A message the visitor can see was stored: theirs, the agent's, your team's, or a system line | botId, conversationId, message |
lead.captured | A lead was saved (by the agent, the form or the API) | botId, conversationId (or null), leadId, fields |
handoff.requested | The agent or the visitor asked for a person | botId, conversationId, handoffId, reason, summary |
handoff.accepted | Someone took the conversation over | botId, conversationId, handoffId, userId, userName |
handoff.resolved | It was handed back to the agent | botId, conversationId, handoffId, by (the user's id) |
feedback.received | Thumbs on a reply, or a CSAT score | botId, conversationId, messageId (null for CSAT), kind (thumb or csat), value, comment (when given) |
Every data also has workspaceId. A message.created message has id, seq, role (user, assistant, human_agent or system_event), text, modality (text or voice), createdAt, authorName, visibility and attachments. A visitor's spoken turn is sent once its words are known.
The Test button sends a ping event (data: { workspaceId, message: "Hello from Wireface Chat" }). It is stored like any other event, so a failed ping is retried and can be sent again from the delivery log.
The request
http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: WirefaceChat-Webhooks/1
Wireface-Event: lead.captured
Wireface-Event-Id: evt_01K...
Wireface-Signature: t=1791504000,v1=5f2b...
{
"id": "evt_01K...",
"type": "lead.captured",
"createdAt": "2026-10-07T12:00:00.000Z",
"data": {
"workspaceId": "ws_01K...",
"botId": "bot_01K...",
"conversationId": "cnv_01K...",
"leadId": "lead_01K...",
"fields": { "name": "Bo", "email": "bo@example.com" }
}
}Checking the signature
Each endpoint has a signing secret (whsec_...), shown once when you create it; you can make a new one at any time. Wireface-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<t>.<raw body>").
To verify: take t and v1 from the header, compute the HMAC over t, a dot, and the raw request body (before any JSON parsing), compare it with v1 in constant time, and reject old timestamps (five minutes is a sensible limit) so a captured request can't be replayed later.
js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
app.post('/wireface', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('wireface-signature') ?? '').split(',').map(p => p.split('=', 2)),
);
const expected = createHmac('sha256', process.env.WIREFACE_WEBHOOK_SECRET)
.update(`${parts.t}.`)
.update(req.body) // the raw Buffer
.digest('hex');
const given = Buffer.from(parts.v1 ?? '');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || given.length !== expected.length || !timingSafeEqual(given, Buffer.from(expected))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// queue the work, answer quickly
res.sendStatus(204);
});python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WIREFACE_WEBHOOK_SECRET"].encode()
@app.post("/wireface")
def wireface():
header = request.headers.get("Wireface-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = parts.get("t", "")
body = request.get_data() # the raw bytes
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = request.get_json()
# queue the work, answer quickly
return "", 204php
<?php
$secret = getenv('WIREFACE_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw body
$parts = [];
foreach (explode(',', $_SERVER['HTTP_WIREFACE_SIGNATURE'] ?? '') as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// queue the work, answer quickly
http_response_code(204);go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
Data json.RawMessage `json:"data"`
}
func Wireface(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the raw body
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
parts := map[string]string{}
for _, p := range strings.Split(r.Header.Get("Wireface-Signature"), ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WIREFACE_WEBHOOK_SECRET")))
mac.Write([]byte(parts["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
t, err := strconv.ParseInt(parts["t"], 10, 64)
age := time.Since(time.Unix(t, 0))
if err != nil || age > 5*time.Minute || age < -5*time.Minute || !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
var event Event
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// queue the work, answer quickly
w.WriteHeader(http.StatusNoContent)
}Delivery and retries
- Events are queued as they happen and sent within a couple of seconds. Answer with any
2xxstatus within 10 seconds; anything else, a timeout, or a connection error is a failure. Do slow work after answering. - A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (seven tries in all), then given up.
- After 20 failures in a row, the endpoint is switched off. Switching it back on resets the count.
- Deliveries can arrive more than once (a retry after a slow answer, or a manual redelivery) and out of order. Use
id(also inWireface-Event-Id) to skip duplicates, andcreatedAtto order them. - The admin panel shows each endpoint's last 100 deliveries, with the status code and the start of your response, and can send any of them again. Events are kept for a week for this.
- Up to 3 redirects are followed, but a
301or302turns thePOSTinto aGET: give the final URL.
Endpoint rules
- The URL must use
https(plainhttponly works when the server isn't in production mode). - It must be on the public internet, unless the server sets
ALLOW_PRIVATE_NETWORK=true. See Security.
REST API
Manage endpoints with an API token that has the webhooks:write scope: GET /api/v1/webhooks (which also lists the event names), POST /api/v1/webhooks ({ "url", "events" }, answers with the secret), PATCH /api/v1/webhooks/{id} (url, events, active), POST /api/v1/webhooks/{id}/secret, POST /api/v1/webhooks/{id}/test, GET /api/v1/webhooks/{id}/deliveries, POST /api/v1/webhooks/deliveries/{id}/redeliver and DELETE /api/v1/webhooks/{id}.