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.
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.