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:
| Field | Type | Description |
|---|---|---|
eventId | string | Unique, stable id (evt_...). Dedupe on this. Redeliveries and replays reuse it |
eventType | string | One of the events below |
schemaVersion | string | Payload schema version, currently "1" |
tenantId | string | Your workspace id |
requestId | string or null | The onboarding request (vm_req_...). null for webhook.test |
externalReference | string or null | The reference you supplied when creating the request |
occurredAt | string | ISO 8601 UTC time the change happened |
environment | string | test or live |
data | object | Event specific fields (see below) |
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | VerifyMe-Webhooks/1.0 |
X-VerifyMe-Event-Id | Same as eventId |
X-VerifyMe-Timestamp | Unix time in seconds when this attempt was signed |
X-VerifyMe-Signature | v1=<hex>; two comma-separated values during secret rotation |
X-VerifyMe-Delivery | Delivery id (del_...), unique per delivery |
X-VerifyMe-Attempt | Attempt number, starting at 1 |
Events
| Event | Sent when | data fields |
|---|---|---|
onboarding.created | A request is created | status, applicantType, expiresAt |
onboarding.started | The applicant completes the first action (status IN_PROGRESS) | status, stepKey |
onboarding.step.completed | A step completes | stepKey, stepType, attempt, flags[] |
onboarding.submitted | The applicant submits (or resubmits after a revert) | status, resubmission |
onboarding.reverted | A reviewer sends steps back | status, reasonCode, steps[], message |
onboarding.approved | Final approval (manual or automatic) | status, reasonCode, automated |
onboarding.rejected | Final rejection | status, reasonCode |
onboarding.expired | The link expired before submission | status, reason (LINK_EXPIRED) |
onboarding.cancelled | Cancelled by API, reviewer or operator | status, by |
verification.pending | A provider check is waiting (for example DigiLocker consent) | stepKey |
verification.completed | A provider check succeeded | stepKey, capability, outcome |
verification.failed | A provider check failed | stepKey, capability, reason |
document.uploaded | A document or photo was stored | documentId, documentType, stepKey |
document.expired | A stored document was removed under the retention policy | documentId, documentType |
webhook.test | You pressed Send test or called the test endpoint | message |
verification.started, document.rejected | Reserved names you can subscribe to; not emitted by the current release | n/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 + "." + rawBodywheretimestampis the value ofX-VerifyMe-TimestampandrawBodyis 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) > 300seconds - Comparison
- Constant-time (timing-safe) comparison, never
==
Steps your receiver must follow, in order:
- Read the raw body before any JSON parsing (frameworks that parse first change the bytes and break the signature).
- Check the timestamp is within 300 seconds of now.
- Compute the HMAC and compare in constant time against every
v1=value. - Respond
401if invalid. Otherwise parse the JSON, dedupe oneventId, enqueue work and respond2xxquickly.
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 secondsusing 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 secondspackage 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 sameeventId. Store processed ids (a unique constraint is enough) for at least 7 days and return200for 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:
| Attempt | Delay before it (nominal) |
|---|---|
| 1 | Immediately (or as soon as the worker picks it up) |
| 2 | 30 seconds after attempt 1 failed |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
| 8 | 12 hours (final) |
- A full cycle spans roughly 21 hours. After the last failure the delivery is marked
DEAD. - Permanent failures stop immediately. Responses
3xxand4xxother than408,409and429are treated as permanent and not retried (redirects are not followed). Do not return401or404for transient problems such as an unreachable database; return503. - Endpoint protection. After 20 consecutive failed attempts across deliveries the endpoint is set to
DISABLEDand an audit event is written. Fix the receiver, then re-enable it in the portal or withPATCH /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.approvedcan arrive before a retriedstep.completed. UseoccurredAtand, when it matters, fetch the current state withGET /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.
- Rotate. Copy the new secret (shown once).
secretRotatingistrueon the endpoint. - Deploy the new secret to your receiver.
- 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 returns202and adeliveryId; awebhook.testevent 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 answers200for a valid signature and401for an invalid one. - Simulated events. In the sandbox, the portal can emit any event type on demand (
POST /api/portal/sandbox/emit) withdata.simulated: trueandexternalReference: "SIMULATED". - Local development. In the sandbox,
http://localhosttargets 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(supportsstatusandendpointIdfilters): 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
| Symptom | Likely cause | Fix |
|---|---|---|
| No deliveries at all | Endpoint DISABLED, event type not subscribed, or no events yet | Check status and subscribed events; press Send test |
Deliveries DEAD with HTTP_401 or HTTP_404 | Your receiver rejected them (often a wrong secret or wrong path). 4xx is not retried | Fix the receiver, then replay from the portal |
signatureValid: false in the sandbox sink | Body modified before verification, wrong secret, or clock skew | Verify raw bytes; compare against the secret from creation or last rotation; sync the clock |
| Verification passes in tests, fails in production | A proxy, WAF or framework re-encoded the body | Capture the raw bytes at the edge; disable body transformation on this route |
| Duplicate processing | Retries or replays carry the same eventId | Add the eventId unique constraint |
TIMEOUT in lastError | Your handler takes longer than 8 seconds | Acknowledge first, work later |
PRIVATE_ADDRESS_NOT_ALLOWED | Hostname resolves to a private address | Use a public address |
Endpoint suddenly DISABLED | 20 consecutive failures | Fix and re-enable with PATCH |