Webhooks
Verid signs all webhook deliveries using HMAC-SHA256. The signature is included in the Verid-Signature header.
Signature format
Verid-Signature: t={timestamp},v1={signature}Where:
timestampis a Unix timestamp (seconds)signature= HMAC-SHA256({timestamp}.{raw_body},webhook_secret)
You must verify the signature before processing any webhook payload.
Getting your signing secret
Each monitor gets its own secret, generated the first time you give it a webhook delivery. Read it from:
GET /v1/monitors/{id}/signing-secret{ "secret": "3f9c…e21a" }secret is null when the monitor has no webhook delivery configured, so there is nothing signed to verify yet. The secret is a 64-character hex string with no prefix. It is also shown on the monitor's page in the dashboard, in the Webhook verification card on the Config tab.
The value is not included in GET /v1/monitors or GET /v1/monitors/{id} - ask for it explicitly, store it in your own secret manager, and pass it as the secret argument in the examples below.
Verification examples
Node.js
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhook(
header: string,
rawBody: string,
secret: string,
toleranceSecs = 300,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
);
const timestamp = parseInt(parts['t'] ?? '0', 10);
const signature = parts['v1'];
if (!timestamp || !signature) return false;
// Check timestamp drift
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSecs) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison (timingSafeEqual throws on unequal lengths)
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Python
import hmac
import hashlib
import time
def verify_webhook(header: str, raw_body: str, secret: str, tolerance_secs: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts.get("t", 0))
signature = parts.get("v1", "")
if not timestamp or not signature:
return False
if abs(time.time() - timestamp) > tolerance_secs:
return False
signed_payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Ruby
require 'openssl'
require 'time'
def verify_webhook(header, raw_body, secret, tolerance_secs: 300)
parts = Hash[header.split(',').map { |p| p.split('=', 2) }]
timestamp = parts['t']&.to_i
signature = parts['v1']
return false unless timestamp && signature
return false if (Time.now.to_i - timestamp).abs > tolerance_secs
signed_payload = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
endGo
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func verifyWebhook(header, rawBody, secret string, toleranceSecs float64) bool {
parts := make(map[string]string)
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
tsStr, ok := parts["t"]
if !ok { return false }
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil { return false }
sig, ok := parts["v1"]
if !ok { return false }
if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSecs { return false }
signedPayload := fmt.Sprintf("%d.%s", ts, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}PHP
<?php
function verifyWebhook(string $header, string $rawBody, string $secret, int $toleranceSecs = 300): bool {
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = explode('=', $part, 2);
$parts[$k] = $v;
}
$timestamp = (int)($parts['t'] ?? 0);
$signature = $parts['v1'] ?? '';
if (!$timestamp || !$signature) return false;
if (abs(time() - $timestamp) > $toleranceSecs) return false;
$signedPayload = "{$timestamp}.{$rawBody}";
$expected = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expected, $signature);
}
?>Webhook payload structure
{
"id": "del_01H...",
"version": "2026-05-01",
"monitor_id": "uuid",
"run_id": "uuid",
"fired_at": "2026-05-08T12:00:00Z",
"diff": {
"fields_changed": ["price", "stock"],
"before": { "price": "49.99", "stock": "In stock" },
"after": { "price": "44.99", "stock": "Low stock" }
},
"monitor": {
"url": "https://example.com/product",
"name": "My Product Monitor"
}
}The request also includes the header User-Agent: Verid/1.0.
To send your own headers with every delivery (for example an Authorization token your endpoint expects), add a headers object to the webhook delivery: { "type": "webhook", "url": "…", "headers": { "Authorization": "Bearer …" } }. Up to 20 headers. Verid-Signature, Host, Content-Length, Connection, Transfer-Encoding and Cookie are rejected; Content-Type and User-Agent are always Verid's own values.
Retries
If your endpoint returns a 5xx response, does not answer within 15 seconds, or cannot be reached, Verid retries on this schedule. A 4xx response is treated as a final answer and is not retried:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
After 6 failed attempts, the delivery is marked as dead. You can replay dead deliveries from the dashboard or via POST /v1/deliveries/:id/replay.