Reference

Webhooks

VerifyMe notifies your server of every important change with a signed HTTPS POST. This page lists the events and payloads, shows how to verify the signature in six languages, and explains retries, ordering, secret rotation, testing and troubleshooting.

How webhooks work

Create one or more endpoints in the portal (Developers, Webhooks) or with POST /v1/webhook-endpoints. Each endpoint has a URL, a list of subscribed events (or * for all) and a signing secret (whsec_...) shown once. When something happens, VerifyMe writes the event in the same database transaction as the state change (a transactional outbox) and delivers it to every subscribed active endpoint. Events are therefore never lost and never announced for a change that did not commit.

curl -X POST "https://kyc.example.com/v1/webhook-endpoints" \
  -H "Authorization: Bearer $VERIFYME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://shop.example.com/hooks/verifyme",
        "events": ["onboarding.submitted", "onboarding.approved", "onboarding.rejected", "onboarding.reverted"] }'
{
  "endpointId": "whe_01M3TRSXX1MWDZ00PMG2",
  "url": "https://shop.example.com/hooks/verifyme",
  "events": ["onboarding.submitted", "onboarding.approved", "onboarding.rejected", "onboarding.reverted"],
  "status": "ACTIVE",
  "consecutiveFailures": 0,
  "secretRotating": false,
  "createdOn": "2026-10-01T03:40:51.489Z",
  "updatedOn": "2026-10-01T03:40:51.489Z",
  "secret": "whsec_Yw80IXbqWnq6bPhYcvY27tJ4ooPKXKECVBqeRjeOHOg",
  "note": "Store the signing secret securely. It will not be shown again."
}

Up to 10 endpoints per workspace. Subscribing to a short list of events keeps your receiver simple.

Envelope and headers

Every delivery is a POST with Content-Type: application/json and this body:

FieldTypeDescription
eventIdstringUnique, stable id (evt_...). Dedupe on this. Redeliveries and replays reuse it
eventTypestringOne of the events below
schemaVersionstringPayload schema version, currently "1"
tenantIdstringYour workspace id
requestIdstring or nullThe onboarding request (vm_req_...). null for webhook.test
externalReferencestring or nullThe reference you supplied when creating the request
occurredAtstringISO 8601 UTC time the change happened
environmentstringtest or live
dataobjectEvent specific fields (see below)
HeaderValue
Content-Typeapplication/json
User-AgentVerifyMe-Webhooks/1.0
X-VerifyMe-Event-IdSame as eventId
X-VerifyMe-TimestampUnix time in seconds when this attempt was signed
X-VerifyMe-Signaturev1=<hex>; two comma-separated values during secret rotation
X-VerifyMe-DeliveryDelivery id (del_...), unique per delivery
X-VerifyMe-AttemptAttempt number, starting at 1

Events

EventSent whendata fields
onboarding.createdA request is createdstatus, applicantType, expiresAt
onboarding.startedThe applicant completes the first action (status IN_PROGRESS)status, stepKey
onboarding.step.completedA step completesstepKey, stepType, attempt, flags[]
onboarding.submittedThe applicant submits (or resubmits after a revert)status, resubmission
onboarding.revertedA reviewer sends steps backstatus, reasonCode, steps[], message
onboarding.approvedFinal approval (manual or automatic)status, reasonCode, automated
onboarding.rejectedFinal rejectionstatus, reasonCode
onboarding.expiredThe link expired before submissionstatus, reason (LINK_EXPIRED)
onboarding.cancelledCancelled by API, reviewer or operatorstatus, by
verification.pendingA provider check is waiting (for example DigiLocker consent)stepKey
verification.completedA provider check succeededstepKey, capability, outcome
verification.failedA provider check failedstepKey, capability, reason
document.uploadedA document or photo was storeddocumentId, documentType, stepKey
document.expiredA stored document was removed under the retention policydocumentId, documentType
webhook.testYou pressed Send test or called the test endpointmessage
verification.started, document.rejectedReserved names you can subscribe to; not emitted by the current releasen/a

Sample payloads

{
  "eventId": "evt_01M3TRSBVZRESD72XABQ",
  "eventType": "onboarding.created",
  "schemaVersion": "1",
  "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
  "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
  "externalReference": "ORD-2001",
  "occurredAt": "2026-10-01T03:40:33.019Z",
  "environment": "test",
  "data": { "status": "LINK_ISSUED", "applicantType": "applicant", "expiresAt": "2026-10-04T03:40:33.019Z" }
}
{
  "eventId": "evt_01M3TRSBWW74BWKTCS6Z",
  "eventType": "onboarding.started",
  "schemaVersion": "1",
  "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
  "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
  "externalReference": "ORD-2001",
  "occurredAt": "2026-10-01T03:40:33.052Z",
  "environment": "test",
  "data": { "status": "IN_PROGRESS", "stepKey": "mobileOtp" }
}
{
  "eventId": "evt_01M3TRSBYJBF8GBMJE1A",
  "eventType": "onboarding.step.completed",
  "schemaVersion": "1",
  "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
  "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
  "externalReference": "ORD-2001",
  "occurredAt": "2026-10-01T03:40:33.105Z",
  "environment": "test",
  "data": { "stepKey": "bank", "stepType": "BankVerification", "attempt": 1, "flags": [] }
}
{
  "eventId": "evt_01M3TRSBZ95445F69Z4C",
  "eventType": "onboarding.submitted",
  "schemaVersion": "1",
  "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
  "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
  "externalReference": "ORD-2001",
  "occurredAt": "2026-10-01T03:40:33.129Z",
  "environment": "test",
  "data": { "status": "SUBMITTED", "resubmission": false }
}
{
  "eventId": "evt_01M3TRT4Q8M2R0C7WZ1D",
  "eventType": "onboarding.approved",
  "schemaVersion": "1",
  "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
  "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
  "externalReference": "ORD-2001",
  "occurredAt": "2026-10-01T05:12:07.442Z",
  "environment": "test",
  "data": { "status": "APPROVED", "reasonCode": "VERIFIED_OK", "automated": false }
}
{ "status": "REJECTED", "reasonCode": "FRAUD_SUSPECTED" }
{
  "status": "REVERTED",
  "reasonCode": "DOCUMENT_UNCLEAR",
  "steps": ["documents"],
  "message": "The registration certificate is blurred. Please upload a clearer photo."
}
{ "status": "EXPIRED", "reason": "LINK_EXPIRED" }
{ "status": "CANCELLED", "by": "API_CLIENT" }
{ "stepKey": "aadhaar" }
{ "stepKey": "pan", "capability": "pan", "outcome": "VERIFIED" }
{ "stepKey": "selfie", "capability": "face", "reason": "BELOW_THRESHOLD" }
{ "documentId": "doc_01M3TRSCCSKFRSR85W59", "documentType": "SELFIE", "stepKey": "selfie" }

The three lines in the first block are verification.pending, verification.completed and verification.failed; the last is document.uploaded.

The reasonCode on decisions is one of DOCUMENT_UNCLEAR, DATA_MISMATCH, FACE_MISMATCH, MISSING_INFORMATION, FRAUD_SUSPECTED, POLICY_VIOLATION, VERIFIED_OK, OTHER. Events are small by design: fetch the full result with GET /v1/onboarding-requests/{id}/result instead of expecting personal data in the webhook.

Verifying signatures

Every request is signed so you can prove it came from VerifyMe and was not altered or replayed.

Signed string
timestamp + "." + rawBody where timestamp is the value of X-VerifyMe-Timestamp and rawBody is the exact bytes received
Algorithm
HMAC-SHA256 with your endpoint's whsec_... secret, hex encoded (lowercase)
Header
X-VerifyMe-Signature: v1=<hex>. During secret rotation: v1=<hex>,v1=<hex>. Accept the request if any value matches
Replay window
Reject if abs(now - timestamp) > 300 seconds
Comparison
Constant-time (timing-safe) comparison, never ==

Steps your receiver must follow, in order:

  1. Read the raw body before any JSON parsing (frameworks that parse first change the bytes and break the signature).
  2. Check the timestamp is within 300 seconds of now.
  3. Compute the HMAC and compare in constant time against every v1= value.
  4. Respond 401 if invalid. Otherwise parse the JSON, dedupe on eventId, enqueue work and respond 2xx quickly.
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;

export function verifyVerifyMeSignature({ secret, timestamp, signatureHeader, rawBody }) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;

  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")
  );
  // The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
  return String(signatureHeader || "").split(",").some((part) => {
    const [version, sig] = part.trim().split("=");
    if (version !== "v1" || !sig) return false;
    const given = Buffer.from(sig);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

const app = express();

// IMPORTANT: use the raw body. If a JSON parser runs first, the bytes change and verification fails.
app.post("/hooks/verifyme", express.raw({ type: "application/json", limit: "256kb" }), async (req, res) => {
  const ok = verifyVerifyMeSignature({
    secret: process.env.VERIFYME_WEBHOOK_SECRET,
    timestamp: req.get("X-VerifyMe-Timestamp"),
    signatureHeader: req.get("X-VerifyMe-Signature"),
    rawBody: req.body,
  });
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  if (await alreadyProcessed(event.eventId)) return res.sendStatus(200);   // dedupe: delivery is at-least-once

  await queue.add("verifyme-event", event);        // do the real work asynchronously
  await markProcessed(event.eventId);
  res.sendStatus(200);                             // any 2xx acknowledges within 8 seconds
});
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

TOLERANCE_SECONDS = 300


def verify_signature(secret: str, timestamp: str, signature_header: str, raw_body: bytes) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    # The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
    for part in (signature_header or "").split(","):
        version, _, sig = part.strip().partition("=")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return True
    return False


app = Flask(__name__)


@app.post("/hooks/verifyme")
def verifyme_webhook():
    raw = request.get_data()                      # raw bytes, before any JSON parsing
    if not verify_signature(
        os.environ["VERIFYME_WEBHOOK_SECRET"],
        request.headers.get("X-VerifyMe-Timestamp", ""),
        request.headers.get("X-VerifyMe-Signature", ""),
        raw,
    ):
        abort(401)

    event = request.get_json(force=True)
    if already_processed(event["eventId"]):       # dedupe: delivery is at-least-once
        return "", 200
    enqueue("verifyme-event", event)              # do the real work asynchronously
    mark_processed(event["eventId"])
    return "", 200                                # any 2xx acknowledges within 8 seconds
using System.Security.Cryptography;
using System.Text;

public static class VerifyMeSignature
{
    public static bool Verify(string secret, string? timestamp, string? signatureHeader,
                              byte[] rawBody, int toleranceSeconds = 300)
    {
        if (!long.TryParse(timestamp, out var ts)) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;

        var payload = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var expected = Encoding.UTF8.GetBytes(Convert.ToHexString(hmac.ComputeHash(payload)).ToLowerInvariant());

        // The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
        foreach (var part in (signatureHeader ?? "").Split(','))
        {
            var kv = part.Trim().Split('=', 2);
            if (kv.Length == 2 && kv[0] == "v1" &&
                CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(kv[1]), expected))
                return true;
        }
        return false;
    }
}

// ASP.NET Core minimal API
app.MapPost("/hooks/verifyme", async (HttpRequest req, IConfiguration cfg, IEventStore store) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);                          // raw bytes, before any JSON binding
    var raw = ms.ToArray();

    if (!VerifyMeSignature.Verify(cfg["VerifyMe:WebhookSecret"]!,
            req.Headers["X-VerifyMe-Timestamp"], req.Headers["X-VerifyMe-Signature"], raw))
        return Results.Unauthorized();

    using var doc = System.Text.Json.JsonDocument.Parse(raw);
    var eventId = doc.RootElement.GetProperty("eventId").GetString()!;
    if (await store.SeenAsync(eventId)) return Results.Ok();   // dedupe: delivery is at-least-once

    await store.EnqueueAsync(eventId, raw);                    // do the real work asynchronously
    return Results.Ok();                                       // any 2xx acknowledges within 8 seconds
});
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class VerifyMeSignature {
  private VerifyMeSignature() {}

  public static boolean verify(String secret, String timestamp, String signatureHeader,
                               byte[] rawBody, long toleranceSeconds) {
    long ts;
    try { ts = Long.parseLong(timestamp); } catch (NumberFormatException e) { return false; }
    if (Math.abs(Instant.now().getEpochSecond() - ts) > toleranceSeconds) return false;
    if (signatureHeader == null) return false;

    try {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
      byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);

      // The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
      for (String part : signatureHeader.split(",")) {
        String[] kv = part.trim().split("=", 2);
        if (kv.length == 2 && kv[0].equals("v1")
            && MessageDigest.isEqual(kv[1].getBytes(StandardCharsets.UTF_8), expected)) return true;
      }
    } catch (GeneralSecurityException e) {
      return false;
    }
    return false;
  }
}

// Spring Boot controller
@PostMapping("/hooks/verifyme")
public ResponseEntity<Void> verifyme(
    @RequestBody byte[] rawBody,                                   // raw bytes, not a bound DTO
    @RequestHeader(value = "X-VerifyMe-Timestamp", required = false) String timestamp,
    @RequestHeader(value = "X-VerifyMe-Signature", required = false) String signature) throws IOException {
  if (!VerifyMeSignature.verify(secret, timestamp, signature, rawBody, 300))
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();

  JsonNode event = objectMapper.readTree(rawBody);
  String eventId = event.get("eventId").asText();
  if (store.seen(eventId)) return ResponseEntity.ok().build();     // dedupe: delivery is at-least-once
  store.enqueue(eventId, rawBody);                                 // do the real work asynchronously
  return ResponseEntity.ok().build();                              // any 2xx acknowledges within 8 seconds
}
<?php
const VERIFYME_TOLERANCE_SECONDS = 300;

function verifyMeSignature(string $secret, string $timestamp, string $signatureHeader, string $rawBody): bool
{
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > VERIFYME_TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
    foreach (explode(',', $signatureHeader) as $part) {
        [$version, $sig] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $sig)) {
            return true;
        }
    }
    return false;
}

// hooks/verifyme.php
$raw = file_get_contents('php://input');                 // raw bytes, before any JSON decoding
$ok = verifyMeSignature(
    getenv('VERIFYME_WEBHOOK_SECRET'),
    $_SERVER['HTTP_X_VERIFYME_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_VERIFYME_SIGNATURE'] ?? '',
    $raw
);
if (!$ok) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
if (alreadyProcessed($event['eventId'])) {               // dedupe: delivery is at-least-once
    http_response_code(200);
    exit;
}
enqueue('verifyme-event', $event);                       // do the real work asynchronously
http_response_code(200);                                 // any 2xx acknowledges within 8 seconds
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

func verifySignature(secret, timestamp, header string, body []byte) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(body)
	expected := []byte(hex.EncodeToString(mac.Sum(nil)))

	// The header can carry two signatures while a secret is being rotated: "v1=<hex>,v1=<hex>"
	for _, part := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
		if len(kv) == 2 && kv[0] == "v1" && hmac.Equal([]byte(kv[1]), expected) {
			return true
		}
	}
	return false
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10)) // raw bytes, before JSON decoding
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	if !verifySignature(os.Getenv("VERIFYME_WEBHOOK_SECRET"),
		r.Header.Get("X-VerifyMe-Timestamp"), r.Header.Get("X-VerifyMe-Signature"), body) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	var event struct {
		EventID   string `json:"eventId"`
		EventType string `json:"eventType"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if alreadyProcessed(event.EventID) { // dedupe: delivery is at-least-once
		w.WriteHeader(http.StatusOK)
		return
	}
	enqueue(event.EventID, body) // do the real work asynchronously
	w.WriteHeader(http.StatusOK) // any 2xx acknowledges within 8 seconds
}

Test your verifier with a fixed vector

Secret whsec_test, timestamp 1790826033, body {"hello":"world"}: the signed string is 1790826033.{"hello":"world"}. Generate the expected value with printf '%s' '1790826033.{"hello":"world"}' | openssl dgst -sha256 -hmac whsec_test and compare it to your function (disable the time check in tests). The sandbox sink below verifies real deliveries end to end.

Replay protection and deduplication

  • Replay window. The signed timestamp proves *when this attempt was signed*. A captured request replayed later than five minutes is rejected by the check above. Keep your server clock synchronised (NTP).
  • Dedupe on eventId. Delivery is at least once. A retry after a timeout, a manual replay from the portal, or an overlap during an outage can deliver the same event again with the same eventId. Store processed ids (a unique constraint is enough) for at least 7 days and return 200 for repeats.
  • Idempotent side effects. Make handlers safe to run twice even without the dedupe table, for example UPDATE ... SET status = 'APPROVED' rather than increment a counter.

Delivery, retries and ordering

Respond with any 2xx within 8 seconds to acknowledge. Anything else is a failure and is retried with exponential backoff and ±20% jitter, up to 8 attempts in total:

AttemptDelay before it (nominal)
1Immediately (or as soon as the worker picks it up)
230 seconds after attempt 1 failed
32 minutes
410 minutes
530 minutes
62 hours
76 hours
812 hours (final)
  • A full cycle spans roughly 21 hours. After the last failure the delivery is marked DEAD.
  • Permanent failures stop immediately. Responses 3xx and 4xx other than 408, 409 and 429 are treated as permanent and not retried (redirects are not followed). Do not return 401 or 404 for transient problems such as an unreachable database; return 503.
  • Endpoint protection. After 20 consecutive failed attempts across deliveries the endpoint is set to DISABLED and an audit event is written. Fix the receiver, then re-enable it in the portal or with PATCH /v1/webhook-endpoints/{id} and {"status":"ACTIVE"} (this resets the failure counter).
  • Ordering is not guaranteed. Events are produced in order but delivered concurrently and retried independently, so onboarding.approved can arrive before a retried step.completed. Use occurredAt and, when it matters, fetch the current state with GET /v1/onboarding-requests/{id} rather than trusting arrival order.
  • Manual replay. In the portal you can replay a delivery. It creates a new delivery with the same eventId.

Secret rotation

Rotate a signing secret in the portal (step-up required). VerifyMe issues a new secret and keeps the previous one valid for 24 hours. During that time every delivery carries two signatures, one per secret: X-VerifyMe-Signature: v1=<new>,v1=<old>. Your verifier (as above) accepts the request if either matches, so you can deploy the new secret without downtime.

  1. Rotate. Copy the new secret (shown once). secretRotating is true on the endpoint.
  2. Deploy the new secret to your receiver.
  3. After rollout (and before 24 hours), nothing else is required. The old signature simply stops being sent.

Testing

  • Send test. In the portal choose Send test on an endpoint, or call POST /v1/webhook-endpoints/{id}/test. It returns 202 and a deliveryId; a webhook.test event is delivered to that endpoint only.
  • Sandbox sink. In the sandbox, create a sink in the portal (POST /api/portal/webhooks/sandbox-sink). It registers an endpoint at /demo/sink/{endpointId} on the sandbox host that verifies the signature the way a real consumer must and keeps the last 30 deliveries (signature valid or not, event id, type, attempt) for you to inspect. It answers 200 for a valid signature and 401 for an invalid one.
  • Simulated events. In the sandbox, the portal can emit any event type on demand (POST /api/portal/sandbox/emit) with data.simulated: true and externalReference: "SIMULATED".
  • Local development. In the sandbox, http://localhost targets are allowed so you can receive webhooks on your laptop. In production only public HTTPS targets are accepted; use a tunnelling tool while developing against a shared environment.
  • Inspect deliveries with GET /v1/webhook-events (supports status and endpointId filters): attempt, status, HTTP status, duration, next retry and last error.

Endpoint hardening

  • HTTPS only in production. Plain HTTP URLs are rejected with HTTPS_REQUIRED. Use a certificate from a public CA.
  • SSRF protection. VerifyMe resolves your hostname and refuses private, loopback, link-local and carrier-grade NAT ranges (PRIVATE_ADDRESS_NOT_ALLOWED). The check runs when you save the endpoint and again at every delivery to defend against DNS rebinding. URLs with embedded credentials are rejected.
  • Authenticate by signature, not by IP. Delivery IPs can change. If you must allow-list, ask for the current egress ranges, but still verify the signature.
  • No redirects. Redirects are not followed; return the final URL in the configuration.
  • Small, fast handler. Verify, dedupe, enqueue, return 200. Do slow work (fetching results, calling your CRM) in a background job.
  • Limit the body size you accept (events are well under 256 KB) and do not log the signature header or secrets.
  • Keep the secret out of source control and rotate it on staff changes.

Troubleshooting

SymptomLikely causeFix
No deliveries at allEndpoint DISABLED, event type not subscribed, or no events yetCheck status and subscribed events; press Send test
Deliveries DEAD with HTTP_401 or HTTP_404Your receiver rejected them (often a wrong secret or wrong path). 4xx is not retriedFix the receiver, then replay from the portal
signatureValid: false in the sandbox sinkBody modified before verification, wrong secret, or clock skewVerify raw bytes; compare against the secret from creation or last rotation; sync the clock
Verification passes in tests, fails in productionA proxy, WAF or framework re-encoded the bodyCapture the raw bytes at the edge; disable body transformation on this route
Duplicate processingRetries or replays carry the same eventIdAdd the eventId unique constraint
TIMEOUT in lastErrorYour handler takes longer than 8 secondsAcknowledge first, work later
PRIVATE_ADDRESS_NOT_ALLOWEDHostname resolves to a private addressUse a public address
Endpoint suddenly DISABLED20 consecutive failuresFix and re-enable with PATCH
Docs version 1.0.0API version v1Last updated 1 Oct 2026