Receiving webhooks
Webhooks tell your server when something happens, so you do not have to poll. They need a plan with webhooks (Starter and up).
Add an endpoint
In the console, open Developers → Webhooks → Add endpoint:
- URL: HTTPS on the public internet. Addresses in private networks,
localhostand the like are refused, and the address a name resolves to is checked again on every delivery. - Events: the ones you want (below).
- Copy the signing secret (
whsec_…). It is shown once; New secret replaces it (the old one stops working at once).
Up to 10 endpoints per workspace. Send test event delivers a ping event, whatever the endpoint subscribes to.
Events
| Event | When | data |
|---|---|---|
conversation.created | A visitor starts a conversation | { conversation } |
conversation.assigned | A conversation is assigned or transferred | { conversation } |
conversation.closed | A conversation ends (by an agent, the API, or after 30 idle minutes) | { conversation } |
message.created | A visitor, agent or integration sends a message (internal notes are not sent) | { message, conversation } |
offline_message.created | A visitor leaves a message while nobody is available | { offline_message } |
visitor.identified | A visitor's name, email or phone becomes known | { visitor } |
visitor.deleted | An owner or admin deleted all data about a visitor (data requests); delete your copy too | { visitor: { id, site_id } } |
Every delivery is a POST with a JSON body like:
{
"id": "0192f2c1-7d3a-7c1e-9b0e-4f5a6b7c8d9e",
"type": "message.created",
"created_at": "2026-10-25T12:00:00Z",
"data": {
"message": { "id": "0192f2c1-…", "sender": { "type": "visitor" }, "content_type": "text", "text": "Do you ship to Spain?", "created_at": "…" },
"conversation": { "id": "0192f2b8-…", "site_id": "…", "visitor_id": "…", "status": "waiting", "assigned_member_id": null, "started_at": "…" }
}
}and these headers:
| Header | |
|---|---|
X-Signature | t=<unix seconds>,v1=<signature> (below) |
X-SealReply-Event | The event type |
X-SealReply-Event-Id | The event's id, the same on retries and resends |
X-SealReply-Delivery | This delivery attempt's ID (for support) |
Verify the signature
v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your secret. Check it against the raw body, before parsing JSON, and refuse timestamps more than 5 minutes off:
// Verify a SealReply webhook in Node.js (18+), with no dependencies.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/**
* header: the X-Signature request header ("t=1761390000,v1=5f2b…").
* rawBody: the request body exactly as received (a Buffer or string), not re-encoded JSON.
*/
export function verifySignature(secret, header, rawBody, now = Date.now() / 1000) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' node verify-webhook.mjs < body.json
if (import.meta.url === `file://${process.argv[1]}`) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const ok = verifySignature(process.env.SEALREPLY_WEBHOOK_SECRET, process.env.SIGNATURE, Buffer.concat(chunks));
console.log(ok ? "ok" : "invalid");
process.exit(ok ? 0 : 1);
}"""Verify a SealReply webhook in Python 3.10+, with the standard library only."""
import hashlib
import hmac
import os
import sys
import time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
"""header: the X-Signature request header; raw_body: the request body as received."""
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' python3 verify_webhook.py < body.json
if __name__ == "__main__":
ok = verify_signature(os.environ["SEALREPLY_WEBHOOK_SECRET"], os.environ["SIGNATURE"], sys.stdin.buffer.read())
print("ok" if ok else "invalid")
sys.exit(0 if ok else 1)<?php
// Verify a SealReply webhook in PHP 7.4+, with no extensions beyond the defaults.
const SEALREPLY_TOLERANCE_SECONDS = 300;
/**
* $header: the X-Signature request header ($_SERVER['HTTP_X_SIGNATURE']).
* $rawBody: the request body as received (file_get_contents('php://input')).
*/
function sealreply_verify_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', $part, 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(($now ?? time()) - (int) $parts['t']) > SEALREPLY_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' php verify-webhook.php < body.json
if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === __FILE__) {
$ok = sealreply_verify_signature(getenv('SEALREPLY_WEBHOOK_SECRET'), getenv('SIGNATURE'), stream_get_contents(STDIN));
echo $ok ? "ok\n" : "invalid\n";
exit($ok ? 0 : 1);
}//go:build ignore
// Verify a SealReply webhook in Go, with the standard library only.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// VerifySignature checks the X-Signature header against the raw request body.
func VerifySignature(secret, header string, rawBody []byte, now time.Time) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || parts["v1"] == "" {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
// Try it: SEALREPLY_WEBHOOK_SECRET=whsec_… SIGNATURE='t=…,v1=…' go run verify_webhook.go < body.json
func main() {
body, _ := io.ReadAll(os.Stdin)
if VerifySignature(os.Getenv("SEALREPLY_WEBHOOK_SECRET"), os.Getenv("SIGNATURE"), body, time.Now()) {
fmt.Println("ok")
return
}
fmt.Println("invalid")
os.Exit(1)
}Our test suite signs bodies exactly as deliveries are signed and runs each of these, so they work as printed.
Answer quickly, handle repeats
- Return any 2xx within 10 seconds. Anything else counts as a failure. Do slow work after answering (put the event on a queue).
- Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. An endpoint that has failed for 3 days in a row is switched off; turn it on again in the console once fixed.
- Rarely, the same event arrives twice, and events can arrive out of order. De-duplicate by the event
id, and usecreated_atand the conversation'sstatusrather than arrival order.
The delivery log
Developers → Webhooks → Delivery log shows each delivery for 30 days: status, response code or error, time taken, next retry, and the body sent. Resend sends an event again now, with the same id.