Skip to content

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, localhost and 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 ​

EventWhendata
conversation.createdA visitor starts a conversation{ conversation }
conversation.assignedA conversation is assigned or transferred{ conversation }
conversation.closedA conversation ends (by an agent, the API, or after 30 idle minutes){ conversation }
message.createdA visitor, agent or integration sends a message (internal notes are not sent){ message, conversation }
offline_message.createdA visitor leaves a message while nobody is available{ offline_message }
visitor.identifiedA visitor's name, email or phone becomes known{ visitor }
visitor.deletedAn 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:

json
{
  "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-Signaturet=<unix seconds>,v1=<signature> (below)
X-SealReply-EventThe event type
X-SealReply-Event-IdThe event's id, the same on retries and resends
X-SealReply-DeliveryThis 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:

js
// 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);
}
py
"""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
<?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
//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 use created_at and the conversation's status rather 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.