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": "whsec_…" }secret is null when the monitor has no webhook delivery configured, so there
is nothing signed to verify yet. The secret is also shown on the monitor's page
in the dashboard, under the webhook delivery.
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
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}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.
Retries
If your endpoint returns a non-2xx response or times out, Verid retries with exponential backoff:
| 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.