Verify webhook signatures
Verify before parsing
Oway follows the Standard Webhooks signing format. Each
request includes:
webhook-id: example:shipment_delivered:1
webhook-timestamp: 1784901902
webhook-signature: v1,<base64-hmac> v1,<previous-key-base64-hmac>The signature input is the UTF-8 byte sequence:
webhook-id + "." + webhook-timestamp + "." + raw_request_bodyDecode the base64 portion of the whsec_... secret and compute HMAC-SHA256. Split
webhook-signature on ASCII whitespace, try every well-formed v1,<base64> value, and use a
constant-time comparison. During rotation, a request contains signatures for the current and
previous secrets.
Also enforce all of these checks:
- Exactly one ID, timestamp, and signature header is present.
- The timestamp is a positive Unix second and no more than five minutes from your clock.
- At least one
v1signature matches. - Only after verification, parse JSON and atomically claim
event_id. - If the event was already claimed, return
2xxwithout scheduling duplicate work.
Do not verify JSON that you parsed and serialized again. Whitespace and property order are part of
the signed bytes.
The executable examples use a process-local set only to make the deduplication order visible.
Replace it with a durable database insert protected by a unique event_id constraint before
running more than one receiver process or accepting production traffic.
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
const toleranceSeconds = 5 * 60;
const processedEventIDs = new Set<string>();
function decodeCanonicalBase64(value: string): Buffer | undefined {
if (
value.length === 0 ||
value.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(value)
) {
return undefined;
}
const decoded = Buffer.from(value, "base64");
return decoded.toString("base64") === value ? decoded : undefined;
}
function verifyWebhook(
secret: string,
webhookID: string,
timestamp: string,
signatureHeader: string,
rawBody: Buffer,
nowSeconds: number,
): void {
const timestampSeconds = Number(timestamp);
if (
!Number.isSafeInteger(nowSeconds) ||
!Number.isSafeInteger(timestampSeconds) ||
timestampSeconds <= 0 ||
String(timestampSeconds) !== timestamp ||
Math.abs(nowSeconds - timestampSeconds) > toleranceSeconds
) {
throw new Error("stale or malformed webhook timestamp");
}
if (!secret.startsWith("whsec_")) {
throw new Error("malformed webhook secret");
}
const key = decodeCanonicalBase64(secret.slice("whsec_".length));
if (key === undefined || key.length !== 32) {
throw new Error("malformed webhook secret");
}
const signed = Buffer.concat([
Buffer.from(`${webhookID}.${timestamp}.`, "utf8"),
rawBody,
]);
const expected = createHmac("sha256", key).update(signed).digest();
const matched = signatureHeader
.trim()
.split(/\s+/)
.some((item) => {
const separator = item.indexOf(",");
if (separator < 0 || item.slice(0, separator) !== "v1") {
return false;
}
const candidate = decodeCanonicalBase64(item.slice(separator + 1));
return (
candidate !== undefined &&
candidate.length === expected.length &&
timingSafeEqual(candidate, expected)
);
});
if (!matched) {
throw new Error("invalid webhook signature");
}
}
function receive(
secret: string,
webhookID: string,
timestamp: string,
signatures: string,
rawBody: Buffer,
nowSeconds: number,
): "queued" | "duplicate" {
verifyWebhook(secret, webhookID, timestamp, signatures, rawBody, nowSeconds);
const event = JSON.parse(rawBody.toString("utf8")) as { event_id?: unknown };
if (typeof event.event_id !== "string" || event.event_id.length === 0) {
throw new Error("missing event_id");
}
if (processedEventIDs.has(event.event_id)) {
return "duplicate";
}
processedEventIDs.add(event.event_id);
// Replace this process-local Set with a durable unique insert in production.
// Enqueue rawBody only after that atomic event_id claim succeeds.
return "queued";
}
if (process.argv.length !== 8) {
throw new Error(
"usage: verify.ts SECRET ID TIMESTAMP SIGNATURE NOW_SECONDS BODY_FILE",
);
}
const [, , secret, webhookID, timestamp, signatures, now, bodyFile] =
process.argv;
console.log(
receive(
secret,
webhookID,
timestamp,
signatures,
readFileSync(bodyFile),
Number(now),
),
);Python
#!/usr/bin/env python3
import base64
import hashlib
import hmac
import json
import pathlib
import sys
TOLERANCE_SECONDS = 5 * 60
processed_event_ids: set[str] = set()
def verify_webhook(
secret: str,
webhook_id: str,
timestamp: str,
signature_header: str,
raw_body: bytes,
now_seconds: int,
) -> None:
if not timestamp.isascii() or not timestamp.isdigit() or timestamp.startswith("0"):
raise ValueError("malformed webhook timestamp")
timestamp_seconds = int(timestamp)
if timestamp_seconds <= 0 or abs(now_seconds - timestamp_seconds) > TOLERANCE_SECONDS:
raise ValueError("stale webhook timestamp")
if not secret.startswith("whsec_"):
raise ValueError("malformed webhook secret")
try:
key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
except ValueError as error:
raise ValueError("malformed webhook secret") from error
if len(key) != 32:
raise ValueError("malformed webhook secret")
signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for item in signature_header.split():
version, separator, encoded = item.partition(",")
if separator == "" or version != "v1":
continue
try:
candidate = base64.b64decode(encoded, validate=True)
except ValueError:
continue
if hmac.compare_digest(candidate, expected):
return
raise ValueError("invalid webhook signature")
def receive(
secret: str,
webhook_id: str,
timestamp: str,
signatures: str,
raw_body: bytes,
now_seconds: int,
) -> str:
verify_webhook(secret, webhook_id, timestamp, signatures, raw_body, now_seconds)
event = json.loads(raw_body)
event_id = event.get("event_id")
if not isinstance(event_id, str) or not event_id:
raise ValueError("missing event_id")
if event_id in processed_event_ids:
return "duplicate"
processed_event_ids.add(event_id)
# Replace this process-local set with a durable unique insert in production.
# Enqueue raw_body only after that atomic event_id claim succeeds.
return "queued"
if len(sys.argv) != 7:
raise SystemExit(
"usage: verify.py SECRET ID TIMESTAMP SIGNATURE NOW_SECONDS BODY_FILE"
)
print(
receive(
sys.argv[1],
sys.argv[2],
sys.argv[3],
sys.argv[4],
pathlib.Path(sys.argv[6]).read_bytes(),
int(sys.argv[5]),
)
)Go
// Command verify demonstrates Standard Webhooks verification and event deduplication.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
)
const toleranceSeconds int64 = 5 * 60
var processedEventIDs = struct {
sync.Mutex
values map[string]struct{}
}{values: make(map[string]struct{})}
func verifyWebhook(
secret string,
webhookID string,
timestamp string,
signatureHeader string,
rawBody []byte,
nowSeconds int64,
) error {
timestampSeconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil ||
timestampSeconds <= 0 ||
strconv.FormatInt(timestampSeconds, 10) != timestamp ||
abs(nowSeconds-timestampSeconds) > toleranceSeconds {
return errors.New("stale or malformed webhook timestamp")
}
if !strings.HasPrefix(secret, "whsec_") {
return errors.New("malformed webhook secret")
}
key, err := base64.StdEncoding.Strict().DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil || len(key) != 32 {
return errors.New("malformed webhook secret")
}
signed := make([]byte, 0, len(webhookID)+len(timestamp)+len(rawBody)+2)
signed = append(signed, webhookID...)
signed = append(signed, '.')
signed = append(signed, timestamp...)
signed = append(signed, '.')
signed = append(signed, rawBody...)
mac := hmac.New(sha256.New, key)
_, _ = mac.Write(signed)
expected := mac.Sum(nil)
for _, item := range strings.Fields(signatureHeader) {
version, encoded, found := strings.Cut(item, ",")
if !found || version != "v1" {
continue
}
candidate, decodeErr := base64.StdEncoding.Strict().DecodeString(encoded)
if decodeErr == nil && hmac.Equal(candidate, expected) {
return nil
}
}
return errors.New("invalid webhook signature")
}
func receive(
secret string,
webhookID string,
timestamp string,
signatures string,
rawBody []byte,
nowSeconds int64,
) (string, error) {
if err := verifyWebhook(
secret,
webhookID,
timestamp,
signatures,
rawBody,
nowSeconds,
); err != nil {
return "", err
}
var event struct {
EventID string `json:"event_id"`
}
if err := json.Unmarshal(rawBody, &event); err != nil || event.EventID == "" {
return "", errors.New("missing event_id")
}
processedEventIDs.Lock()
defer processedEventIDs.Unlock()
if _, duplicate := processedEventIDs.values[event.EventID]; duplicate {
return "duplicate", nil
}
processedEventIDs.values[event.EventID] = struct{}{}
// Replace this process-local map with a durable unique insert in production.
// Enqueue rawBody only after that atomic event_id claim succeeds.
return "queued", nil
}
func abs(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func main() {
if len(os.Args) != 7 {
_, _ = fmt.Fprintln(
os.Stderr,
"usage: verify.go SECRET ID TIMESTAMP SIGNATURE NOW_SECONDS BODY_FILE",
)
os.Exit(2)
}
now, err := strconv.ParseInt(os.Args[5], 10, 64)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "invalid now")
os.Exit(2)
}
// The example CLI intentionally reads the receiver-owned path supplied by its operator.
body, err := os.ReadFile(os.Args[6]) //nolint:gosec
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
result, err := receive(os.Args[1], os.Args[2], os.Args[3], os.Args[4], body, now)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(result)
}Java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class VerifyWebhook {
private static final long TOLERANCE_SECONDS = 5 * 60;
private static final Pattern EVENT_ID =
Pattern.compile("\"event_id\"\\s*:\\s*\"([A-Za-z0-9:_-]{1,512})\"");
private static final Set<String> PROCESSED_EVENT_IDS = ConcurrentHashMap.newKeySet();
private VerifyWebhook() {}
static void verify(
String secret,
String webhookId,
String timestamp,
String signatureHeader,
byte[] rawBody,
long nowSeconds)
throws Exception {
if (!timestamp.matches("[1-9][0-9]*")) {
throw new IllegalArgumentException("malformed webhook timestamp");
}
long timestampSeconds = Long.parseLong(timestamp);
if (Math.abs(nowSeconds - timestampSeconds) > TOLERANCE_SECONDS) {
throw new IllegalArgumentException("stale webhook timestamp");
}
if (!secret.startsWith("whsec_")) {
throw new IllegalArgumentException("malformed webhook secret");
}
byte[] key = Base64.getDecoder().decode(secret.substring("whsec_".length()));
if (key.length != 32) {
throw new IllegalArgumentException("malformed webhook secret");
}
byte[] prefix =
(webhookId + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.update(prefix);
byte[] expected = mac.doFinal(rawBody);
for (String item : signatureHeader.trim().split("\\s+")) {
int separator = item.indexOf(',');
if (separator < 0 || !item.substring(0, separator).equals("v1")) {
continue;
}
try {
byte[] candidate =
Base64.getDecoder().decode(item.substring(separator + 1));
if (MessageDigest.isEqual(candidate, expected)) {
return;
}
} catch (IllegalArgumentException ignored) {
// Try the next v1 signature.
}
}
throw new IllegalArgumentException("invalid webhook signature");
}
static String receive(
String secret,
String webhookId,
String timestamp,
String signatures,
byte[] rawBody,
long nowSeconds)
throws Exception {
verify(secret, webhookId, timestamp, signatures, rawBody, nowSeconds);
Matcher matcher = EVENT_ID.matcher(new String(rawBody, StandardCharsets.UTF_8));
if (!matcher.find()) {
throw new IllegalArgumentException("missing event_id");
}
if (!PROCESSED_EVENT_IDS.add(matcher.group(1))) {
return "duplicate";
}
// Replace this process-local set with a durable unique insert in production.
// Enqueue rawBody only after that atomic event_id claim succeeds.
return "queued";
}
public static void main(String[] args) throws Exception {
if (args.length != 6) {
throw new IllegalArgumentException(
"usage: VerifyWebhook SECRET ID TIMESTAMP SIGNATURE NOW_SECONDS BODY_FILE");
}
System.out.println(
receive(
args[0],
args[1],
args[2],
args[3],
Files.readAllBytes(Path.of(args[5])),
Long.parseLong(args[4])));
}
}Each example accepts the one-time
whsec_... value, tries every v1 signature, deduplicates event_id, and queues work only after
authentication.
Updated about 3 hours ago