Skip to content

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 ​

EventWhendata
conversation.startedA visitor's first message started a conversationbotId, conversationId, visitorId
conversation.endedHowever 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.createdA message the visitor can see was stored: theirs, the agent's, your team's, or a system linebotId, conversationId, message
lead.capturedA lead was saved (by the agent, the form or the API)botId, conversationId (or null), leadId, fields
handoff.requestedThe agent or the visitor asked for a personbotId, conversationId, handoffId, reason, summary
handoff.acceptedSomeone took the conversation overbotId, conversationId, handoffId, userId, userName
handoff.resolvedIt was handed back to the agentbotId, conversationId, handoffId, by (the user's id)
feedback.receivedThumbs on a reply, or a CSAT scorebotId, 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 "", 204
php
<?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 2xx status 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 in Wireface-Event-Id) to skip duplicates, and createdAt to 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 301 or 302 turns the POST into a GET: give the final URL.

Endpoint rules ​

  • The URL must use https (plain http only 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}.

Wireface Chat 0.1.0. These docs are served by your own server.