Validate Generic webhook signatures
Verify signed Generic webhook requests with the one-time secret, reject replays safely, and handle retries without processing a Delivery twice.
A Generic Notifier sends JSON to a public HTTPS receiver you control. PointWarden signs every Test Delivery and production Delivery Attempt so your receiver can reject requests that were changed or did not come from someone holding the Notifier's signing secret.
Understand the signed request
Generic requests use Content-Type: application/json. Every JSON envelope has version: 1 and Workspace data. Production envelopes contain stable Delivery and Notification Event IDs, plus Monitor and Incident data. A Test Delivery instead carries its own Test Delivery ID in delivery.id, uses a diagnostic event object with type test, and omits Monitor and Incident data. These test values do not create a Delivery or Notification Event.
This fictional incident-opening example shows the complete production envelope. PointWarden sends it as compact JSON; the indentation below is only for reading. Verify the signature against the exact raw body bytes received over HTTP, not a pretty-printed or reconstructed copy.
{
"delivery": {
"id": "delivery_example"
},
"event": {
"confirmedAt": "2026-08-20T03:02:00.000Z",
"id": "event_example",
"observedAt": "2026-08-20T03:00:00.000Z",
"occurredAt": "2026-08-20T03:00:00.000Z",
"type": "incident-opened"
},
"incident": {
"confirmedAt": "2026-08-20T03:02:00.000Z",
"detectedAt": "2026-08-20T03:00:00.000Z",
"id": "incident_example",
"recoveredAt": null,
"recoveryObservedAt": null
},
"monitor": {
"id": "monitor_example",
"name": "Example Monitor"
},
"version": 1,
"workspace": {
"id": "workspace_example",
"name": "Example Workspace"
}
}| Field | Production meaning |
|---|---|
version | 1. Reject or isolate unsupported versions before processing. |
delivery.id | Stable Delivery ID shared by every Delivery Attempt for this Delivery. |
event.id | Stable Notification Event ID. |
event.type | incident-opened or recovery-confirmed. |
event.occurredAt, event.observedAt, event.confirmedAt | ISO 8601 event timestamps. |
workspace.id, workspace.name | Workspace identity and display name. |
monitor.id, monitor.name | Monitor identity and display name. |
incident.id and Incident timestamps | Incident identity with detectedAt, confirmedAt, recoveryObservedAt, and recoveredAt values. Recovery values are null until known. |
| Header | Meaning |
|---|---|
X-PointWarden-Timestamp | Unix time in seconds. Use the exact header value in the signed message. |
X-PointWarden-Signature | v1= followed by a 64-character lowercase hexadecimal HMAC-SHA-256 digest. |
X-PointWarden-Event | Production requests contain the Notification Event ID. Test Deliveries contain test, a diagnostic marker rather than a Notification Event ID. |
X-PointWarden-Delivery | Production requests keep the same Delivery ID across retry attempts. Test Deliveries contain their Test Delivery ID, not a Delivery ID. |
Verify before parsing the JSON
Preserve the raw body
Read the HTTP body as bytes before a JSON parser changes spacing, escaping, or property order. Re-serializing parsed JSON produces different bytes and makes a valid signature fail.
Build the signed message
Join the timestamp header, one period, and the exact raw body with no added whitespace or newline:
timestamp + "." + rawBodyCalculate and compare
Calculate HMAC-SHA-256 with the full signing secret, encode the digest as lowercase hexadecimal, add
v1=, and compare it withX-PointWarden-Signatureusing a constant-time comparison.
Verification examples
Choose your receiver's language. Each example uses its standard cryptography API, validates a five-minute timestamp window, and compares the signature without leaking timing information. Pass the untouched body bytes, the two signature headers, and the secret from your secret manager. Parse the JSON only after the function returns true.
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_AGE_SECONDS = 5 * 60;
const SIGNATURE_PATTERN = /^v1=([0-9a-f]{64})$/;
export function verifyPointWarden(
rawBody,
timestamp,
signature,
secret,
) {
const match = SIGNATURE_PATTERN.exec(signature ?? "");
const timestampSeconds = /^\d+$/.test(timestamp ?? "")
? Number(timestamp)
: Number.NaN;
const now = Math.floor(Date.now() / 1000);
if (
!timestamp ||
!match ||
!Number.isSafeInteger(timestampSeconds) ||
timestampSeconds < now - MAX_AGE_SECONDS ||
timestampSeconds > now + MAX_AGE_SECONDS
) {
return false;
}
const expected = createHmac("sha256", secret)
.update(timestamp, "utf8")
.update(".", "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(match[1], "hex");
if (received.length !== expected.length) {
return false;
}
return timingSafeEqual(received, expected);
}import hashlib
import hmac
import re
import time
MAX_AGE_SECONDS = 5 * 60
SIGNATURE_PATTERN = re.compile(r"^v1=([0-9a-f]{64})$")
TIMESTAMP_PATTERN = re.compile(r"^[0-9]+$")
def verify_pointwarden(
raw_body: bytes,
timestamp: str | None,
signature: str | None,
secret: str,
) -> bool:
match = SIGNATURE_PATTERN.fullmatch(signature or "")
if not timestamp or not TIMESTAMP_PATTERN.fullmatch(timestamp) or not match:
return False
timestamp_seconds = int(timestamp)
now = int(time.time())
if not now - MAX_AGE_SECONDS <= timestamp_seconds <= now + MAX_AGE_SECONDS:
return False
message = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(
secret.encode("utf-8"),
message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(match.group(1), expected)package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
const maxAgeSeconds int64 = 5 * 60
var signaturePattern = regexp.MustCompile("^v1=([0-9a-f]{64})$")
var timestampPattern = regexp.MustCompile("^[0-9]+$")
func VerifyPointWarden(
rawBody []byte,
timestamp string,
signature string,
secret string,
) bool {
match := signaturePattern.FindStringSubmatch(signature)
timestampSeconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || !timestampPattern.MatchString(timestamp) || len(match) != 2 {
return false
}
now := time.Now().Unix()
if timestampSeconds < now-maxAgeSeconds ||
timestampSeconds > now+maxAgeSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte{'.'})
mac.Write(rawBody)
received, err := hex.DecodeString(match[1])
if err != nil {
return false
}
return hmac.Equal(received, mac.Sum(nil))
}using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class PointWardenWebhook
{
private const long MaxAgeSeconds = 5 * 60;
public static bool Verify(
byte[] rawBody,
string? timestamp,
string? signature,
string secret)
{
if (
timestamp is null ||
signature is null ||
!Regex.IsMatch(timestamp, @"^[0-9]+$") ||
!Regex.IsMatch(signature, @"^v1=[0-9a-f]{64}$") ||
!long.TryParse(
timestamp,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var timestampSeconds))
{
return false;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (
timestampSeconds < now - MaxAgeSeconds ||
timestampSeconds > now + MaxAgeSeconds)
{
return false;
}
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
var message = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, message, prefix.Length, rawBody.Length);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = hmac.ComputeHash(message);
var received = Convert.FromHexString(signature[3..]);
return CryptographicOperations.FixedTimeEquals(received, expected);
}
}import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
final class PointWardenWebhook {
private static final long MAX_AGE_SECONDS = 5 * 60;
private static final Pattern SIGNATURE_PATTERN =
Pattern.compile("^v1=([0-9a-f]{64})$");
static boolean verify(
byte[] rawBody,
String timestamp,
String signature,
String secret
) throws GeneralSecurityException {
if (
timestamp == null ||
signature == null ||
!timestamp.matches("[0-9]+")
) {
return false;
}
Matcher match = SIGNATURE_PATTERN.matcher(signature);
if (!match.matches()) {
return false;
}
final long timestampSeconds;
try {
timestampSeconds = Long.parseLong(timestamp);
} catch (NumberFormatException error) {
return false;
}
long now = Instant.now().getEpochSecond();
if (
timestampSeconds < now - MAX_AGE_SECONDS ||
timestampSeconds > now + MAX_AGE_SECONDS
) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
));
mac.update(timestamp.getBytes(StandardCharsets.UTF_8));
mac.update((byte) '.');
byte[] expected = mac.doFinal(rawBody);
byte[] received = HexFormat.of().parseHex(match.group(1));
return MessageDigest.isEqual(received, expected);
}
}Reject replays without breaking retries
A Delivery can have more than one Delivery Attempt. Each Delivery Attempt is one HTTP request. A retry creates a new Delivery Attempt for the same Delivery.
PointWarden uses at-least-once Delivery. A production retry keeps the same Delivery ID but carries a fresh timestamp and signature. Return a 2xx response after successful processing. PointWarden may retry 408, 425, 429, 5xx, timeout, and network failures; other non-2xx responses and redirects end that Delivery permanently.
Rotate a secret safely
Rotate the signing secret if it is exposed or unavailable. Rotation immediately invalidates the previous secret, pauses the Notifier, returns it to Unverified, and cancels unfinished Deliveries for the old configuration. Store the replacement shown once, update the receiver, send a successful Test Delivery, and then enable the Notifier again.
Return to Configure a Notifier for target and subscription setup. Then verify and enable the Notifier. To understand stable Delivery IDs and attempts, Inspect Delivery evidence.