Webhook Signature Verification
Hookbase signs all outbound webhook payloads to ensure the integrity and authenticity of webhook deliveries, following the Standard Webhooks specification. Each endpoint uses one of two signature schemes:
| Scheme | Signature | You verify with | Choose it when |
|---|---|---|---|
hmac-sha256 (default) | v1,<base64> | The endpoint's shared whsec_ secret | You want the widest library support |
ed25519 | v1a,<base64> | The endpoint's whpk_ public key | You don't want receivers to hold anything that can sign: the private key never leaves Hookbase |
This guide covers HMAC first; see Ed25519 (v1a) Signatures for the asymmetric scheme.
Signature Format
Every webhook request from Hookbase includes the three Standard Webhooks headers:
| Header | Description |
|---|---|
webhook-id | The message ID, a UUID. It stays the same across retries, so use it to deduplicate |
webhook-timestamp | Unix timestamp (seconds) when the webhook was signed |
webhook-signature | One or more space-separated signatures: v1,<base64> (HMAC) or v1a,<base64> (Ed25519). Two entries while a rotated secret is in its grace period |
Hookbase also sends x-hookbase-id, x-hookbase-timestamp and x-hookbase-signature with the same id and timestamp, for receivers built before the switch to Standard Webhooks. Prefer the webhook-* headers: x-hookbase-signature carries only the current secret's signature, so a receiver that reads it fails during a secret rotation until it has the new secret. The Hookbase SDKs read only the webhook-* headers.
How Signatures Are Computed
Hookbase computes signatures using the following algorithm:
-
Create the signed content: Concatenate the message ID, timestamp, and raw request body with periods:
{webhook-id}.{webhook-timestamp}.{body} -
Decode the secret: Remove the
whsec_prefix from your signing secret and base64-decode the rest to bytes. Endpoints created before base64 secrets were introduced have a 64-character hex secret instead (whsec_+ 64 hex characters): decode that one as hex. The examples below handle both. -
Compute HMAC-SHA256: Sign the content (as UTF-8 bytes) with the decoded secret
-
Base64 encode: Encode the resulting signature as base64 (standard alphabet, with padding)
-
Format the header: Prefix with the version identifier:
v1,{base64_signature}. During a rotation grace period both secrets sign, old secret first:v1,<old> v1,<new>
Verification Steps
To verify a webhook signature:
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders - Verify the timestamp is within your tolerance window (recommended: 5 minutes)
- Construct the signed content from the message ID, timestamp, and raw request body
- Compute your own signature using your signing secret
- Split the header on spaces and accept the request if any
v1entry matches, using a timing-safe comparison
With a Hookbase SDK
Each SDK's verifier does all of the above in one call: it reads the webhook-* headers (names matched case-insensitively), checks the timestamp with a 5-minute default tolerance, accepts any matching entry, and throws (or returns an error) on failure. Pass the raw body, exactly as received.
| SDK | Verify an HMAC (v1) webhook |
|---|---|
Node.js (@hookbase/sdk) | new Webhook('whsec_...').verify(rawBody, headers) |
Python (hookbase) | Webhook("whsec_...").verify(raw_body, headers) |
Go (github.com/HookbaseApp/hookbase-go) | hookbase.NewWebhook("whsec_...").Verify(rawBody, headers) |
.NET (Hookbase) | new WebhookVerifier("whsec_...").Verify<T>(rawBody, headers) |
Java (app.hookbase:hookbase-java) | new Webhook("whsec_...").verify(rawBody, headers) |
For an Ed25519 endpoint, pass its whpk_ public key instead of the secret; see Ed25519 with a Hookbase SDK.
Code Examples
Without an SDK, use one of these snippets.
Node.js
const crypto = require('crypto');
// Decode a signing secret to its key bytes: drop the 'whsec_' prefix, then base64-decode.
// Legacy secrets are exactly 64 hex characters after the prefix and decode as hex.
function decodeSecret(secret) {
const key = secret.startsWith('whsec_') ? secret.slice(6) : secret;
return /^[0-9a-fA-F]{64}$/.test(key) ? Buffer.from(key, 'hex') : Buffer.from(key, 'base64');
}
// payload: the raw request body (string or Buffer); headers: Node's lower-cased header object
function verifyWebhookSignature(payload, headers, secret, toleranceSeconds = 300) {
const messageId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!messageId || !timestamp || !signatureHeader) {
throw new Error('Missing webhook signature headers');
}
// 1. Verify timestamp freshness
const now = Math.floor(Date.now() / 1000);
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error('Webhook timestamp is too old or too far in the future');
}
// 2. Compute the expected signature over `{id}.{timestamp}.{body}`
const expected = crypto
.createHmac('sha256', decodeSecret(secret))
.update(`${messageId}.${timestamp}.`)
.update(payload)
.digest();
// 3. The header holds one or more space-separated `v1,<base64>` entries (two while a
// rotated secret is in its grace period). Accept if any entry matches, timing-safely.
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const provided = Buffer.from(signature, 'base64');
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
});
}
// Express.js example: keep the raw body for verification
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body; // Buffer, exactly as received
const secret = process.env.WEBHOOK_SECRET;
try {
if (!verifyWebhookSignature(payload, req.headers, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
} catch (error) {
return res.status(400).json({ error: error.message });
}
const event = JSON.parse(payload);
// Process the webhook event...
res.status(200).json({ received: true });
});Python
import base64
import hashlib
import hmac
import re
import time
from typing import Mapping, Union
def decode_secret(secret: str) -> bytes:
"""Drop the 'whsec_' prefix, then base64-decode (legacy 64-hex secrets decode as hex)."""
key = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
if re.fullmatch(r"[0-9a-fA-F]{64}", key):
return bytes.fromhex(key)
return base64.b64decode(key)
def verify_webhook_signature(
payload: Union[bytes, str],
headers: Mapping[str, str],
secret: str,
tolerance_seconds: int = 300,
) -> bool:
"""
Verify a Hookbase webhook signature.
Args:
payload: The raw request body, exactly as received
headers: The request headers (any mapping; names are matched case-insensitively)
secret: The endpoint's signing secret (whsec_...)
tolerance_seconds: Maximum age of the signature (default 5 minutes)
Returns:
True if any v1 signature in the header matches
Raises:
ValueError: If a header is missing or the timestamp is outside the tolerance
"""
h = {k.lower(): v for k, v in headers.items()}
message_id = h.get("webhook-id")
timestamp = h.get("webhook-timestamp")
signature_header = h.get("webhook-signature")
if not message_id or not timestamp or not signature_header:
raise ValueError("Missing webhook signature headers")
# 1. Verify timestamp freshness
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
raise ValueError("Webhook timestamp is too old or too far in the future")
# 2. Compute the expected signature over `{id}.{timestamp}.{body}`
body = payload.encode("utf-8") if isinstance(payload, str) else payload
expected = base64.b64encode(
hmac.new(decode_secret(secret), f"{message_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
)
# 3. One or more space-separated `v1,<base64>` entries (two while a rotated secret is in its
# grace period): accept if any entry matches, with a timing-safe comparison
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(expected, signature.encode()):
return True
return False
# Flask example
import os
from flask import Flask, jsonify, request
app = Flask(__name__)
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
payload = request.get_data() # raw bytes, before any JSON parsing
secret = os.environ["WEBHOOK_SECRET"]
try:
if not verify_webhook_signature(payload, request.headers, secret):
return jsonify({"error": "Invalid signature"}), 401
except ValueError as e:
return jsonify({"error": str(e)}), 400
event = request.get_json()
# Process the webhook event...
return jsonify({"received": True}), 200Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// Legacy signing secrets are exactly 64 hex characters; current ones are base64.
var legacyHexSecret = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// decodeSecret drops the "whsec_" prefix and decodes the rest to the HMAC key bytes.
func decodeSecret(secret string) ([]byte, error) {
key := strings.TrimPrefix(secret, "whsec_")
if legacyHexSecret.MatchString(key) {
return hex.DecodeString(key)
}
return base64.StdEncoding.DecodeString(key)
}
// VerifyWebhookSignature verifies a Hookbase webhook signature. It returns nil if any v1
// signature in the webhook-signature header matches.
func VerifyWebhookSignature(payload []byte, headers http.Header, secret string, toleranceSeconds int64) error {
messageID := headers.Get("webhook-id")
timestampStr := headers.Get("webhook-timestamp")
signatureHeader := headers.Get("webhook-signature")
if messageID == "" || timestampStr == "" || signatureHeader == "" {
return errors.New("missing webhook signature headers")
}
// 1. Verify timestamp freshness
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
return errors.New("invalid timestamp format")
}
if math.Abs(float64(time.Now().Unix()-timestamp)) > float64(toleranceSeconds) {
return errors.New("webhook timestamp is too old or too far in the future")
}
// 2. Compute the expected signature over `{id}.{timestamp}.{body}`
secretBytes, err := decodeSecret(secret)
if err != nil {
return fmt.Errorf("invalid secret format: %w", err)
}
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(messageID + "." + timestampStr + "."))
mac.Write(payload)
expected := []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
// 3. One or more space-separated "v1,<base64>" entries (two while a rotated secret is in
// its grace period): accept if any entry matches, with a timing-safe comparison
for _, entry := range strings.Split(signatureHeader, " ") {
version, signature, ok := strings.Cut(entry, ",")
if ok && version == "v1" && subtle.ConstantTimeCompare(expected, []byte(signature)) == 1 {
return nil
}
}
return errors.New("signature mismatch")
}
// HTTP handler example
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
secret := os.Getenv("WEBHOOK_SECRET")
if err := VerifyWebhookSignature(payload, r.Header, secret, 300); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Process the webhook event...
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}Ruby
require 'openssl'
require 'base64'
require 'json'
module Hookbase
class SignatureError < StandardError; end
class WebhookVerifier
TOLERANCE_SECONDS = 300
def initialize(secret)
# Drop the 'whsec_' prefix, then base64-decode (legacy 64-hex secrets decode as hex)
key = secret.delete_prefix('whsec_')
@secret_bytes = key.match?(/\A\h{64}\z/) ? [key].pack('H*') : Base64.strict_decode64(key)
end
# headers: Rails' request.headers, or any hash with the header names
def verify!(payload, headers, tolerance_seconds: TOLERANCE_SECONDS)
message_id = header(headers, 'webhook-id')
timestamp = header(headers, 'webhook-timestamp')
signature_header = header(headers, 'webhook-signature')
raise SignatureError, 'Missing webhook signature headers' unless message_id && timestamp && signature_header
# 1. Verify timestamp freshness
unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= tolerance_seconds
raise SignatureError, 'Webhook timestamp is too old or too far in the future'
end
# 2. Compute the expected signature over `{id}.{timestamp}.{body}`
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', @secret_bytes, "#{message_id}.#{timestamp}.#{payload}")
)
# 3. One or more space-separated `v1,<base64>` entries (two while a rotated secret is in
# its grace period): accept if any entry matches, with a timing-safe comparison
matched = signature_header.split(' ').any? do |entry|
version, signature = entry.split(',', 2)
version == 'v1' && signature && OpenSSL.fixed_length_secure_compare(expected, signature)
rescue ArgumentError # different length
false
end
raise SignatureError, 'Signature mismatch' unless matched
true
end
private
def header(headers, name)
headers[name] || headers["HTTP_#{name.upcase.tr('-', '_')}"]
end
end
end
# Rails controller example
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
payload = request.raw_post
verifier = Hookbase::WebhookVerifier.new(ENV.fetch('WEBHOOK_SECRET'))
begin
verifier.verify!(payload, request.headers)
event = JSON.parse(payload)
# Process the webhook event...
render json: { received: true }, status: :ok
rescue Hookbase::SignatureError => e
render json: { error: e.message }, status: :unauthorized
end
end
endPHP
<?php
class HookbaseWebhookVerifier
{
private string $secretBytes;
private int $toleranceSeconds;
public function __construct(string $secret, int $toleranceSeconds = 300)
{
// Drop the 'whsec_' prefix, then base64-decode (legacy 64-hex secrets decode as hex)
$key = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;
$bytes = preg_match('/^[0-9a-fA-F]{64}$/', $key) ? hex2bin($key) : base64_decode($key, true);
if ($bytes === false) {
throw new InvalidArgumentException('Invalid signing secret');
}
$this->secretBytes = $bytes;
$this->toleranceSeconds = $toleranceSeconds;
}
/**
* Verify a Hookbase webhook signature
*
* @param string $payload The raw request body
* @param array $headers Request headers, e.g. getallheaders() (names are matched case-insensitively)
* @return bool True if valid
* @throws InvalidArgumentException If verification fails
*/
public function verify(string $payload, array $headers): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
$messageId = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatureHeader = $headers['webhook-signature'] ?? '';
if ($messageId === '' || $timestamp === '' || $signatureHeader === '') {
throw new InvalidArgumentException('Missing webhook signature headers');
}
// 1. Verify timestamp freshness
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $this->toleranceSeconds) {
throw new InvalidArgumentException('Webhook timestamp is too old or too far in the future');
}
// 2. Compute the expected signature over `{id}.{timestamp}.{body}`
$expected = base64_encode(
hash_hmac('sha256', "{$messageId}.{$timestamp}.{$payload}", $this->secretBytes, true)
);
// 3. One or more space-separated `v1,<base64>` entries (two while a rotated secret is in
// its grace period): accept if any entry matches, with a timing-safe comparison
foreach (explode(' ', $signatureHeader) as $entry) {
$parts = explode(',', $entry, 2);
if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
return true;
}
}
throw new InvalidArgumentException('Signature mismatch');
}
}
// Usage example
$payload = file_get_contents('php://input');
$verifier = new HookbaseWebhookVerifier(getenv('WEBHOOK_SECRET'));
try {
$verifier->verify($payload, getallheaders());
$event = json_decode($payload, true);
// Process the webhook event...
http_response_code(200);
echo json_encode(['received' => true]);
} catch (InvalidArgumentException $e) {
http_response_code(401);
echo json_encode(['error' => $e->getMessage()]);
}Java
The Java SDK (app.hookbase:hookbase-java, Java 25+) ships a verifier, app.hookbase.webhooks.Webhook. It reads the Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature), which Hookbase sends alongside the x-hookbase-* ones. This example uses the JDK's built-in HTTP server; in any framework, read the body as bytes, collect the headers into a map and call verify:
import app.hookbase.errors.WebhookVerificationException;
import app.hookbase.webhooks.Webhook;
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpServer;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class WebhookServer {
public static void main(String[] args) throws Exception {
// Accepts whsec_ secrets (base64 or legacy hex)
Webhook webhook = new Webhook(System.getenv("WEBHOOK_SECRET"));
HttpServer server = HttpServer.create(new InetSocketAddress(3000), 0);
server.createContext("/webhooks", exchange -> {
// 1. Read the raw body as bytes, exactly as received
byte[] body;
try (InputStream in = exchange.getRequestBody()) {
body = in.readAllBytes();
}
// 2. Collect the headers (names are matched case-insensitively)
Map<String, String> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> h : exchange.getRequestHeaders().entrySet()) {
headers.put(h.getKey(), h.getValue().getFirst());
}
int status;
try {
// 3. Checks the timestamp (5 minute tolerance) and every v1 signature,
// with a constant-time comparison, then parses the JSON body
JsonNode event = webhook.verify(body, headers);
// Process the webhook event...
status = 200;
} catch (WebhookVerificationException e) {
status = 401;
}
exchange.sendResponseHeaders(status, -1);
exchange.close();
});
server.start();
}
}In Spring Boot, take the body as @RequestBody byte[] body and pass headers.toSingleValueMap(). Read the body as bytes, not a String: Hookbase sends application/json without a charset, and servlet containers then decode text as ISO-8859-1, which breaks any non-ASCII payload. For Ed25519 endpoints, construct Webhook with the endpoint's whpk_ public key (Java SDK 1.1.0+); see Ed25519 (v1a) Signatures.
Ed25519 (v1a) Signatures
An endpoint created with signatureScheme: "ed25519" is signed with an Ed25519 private key that Hookbase generates and keeps (encrypted at rest). You get the matching public key, whpk_<base64 of the 32-byte key>, from the create response and from GET on the endpoint at any time (publicKey). It is not a secret: a public key can verify signatures but cannot produce them.
curl -X POST https://api.hookbase.app/api/organizations/{orgId}/webhook-endpoints \
-H "Authorization: Bearer $HOOKBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"applicationId": "<application id>", "url": "https://example.com/webhooks", "signatureScheme": "ed25519"}'
# → { "data": { "id": "...", "signatureScheme": "ed25519", "publicKey": "whpk_...", ... } }In the dashboard, pick Ed25519 (v1a) under Signature scheme when creating the endpoint; the endpoint page shows the public key with a copy button.
The signed content is the same as for HMAC, {webhook-id}.{webhook-timestamp}.{raw body}, and the signature goes in webhook-signature as v1a,<base64 of the 64-byte signature>. While a rotated key is in its grace period the header carries two entries, old key first: v1a,<old> v1a,<new>. Accept the request if any v1a entry verifies.
Ed25519 with a Hookbase SDK
The SDK verifiers pick the scheme from the key: pass the endpoint's whpk_ public key where you would pass the whsec_ secret, and the same verify call checks v1a entries, with the same timestamp tolerance and rotation handling.
| SDK | Minimum version | Verifier |
|---|---|---|
| Node.js | @hookbase/sdk 4.1.0 | new Webhook('whpk_...') |
| Python | hookbase[ed25519] 4.1.0 | Webhook("whpk_...") |
| Go | hookbase-go v1.13.0 | hookbase.NewWebhookPublicKey("whpk_...") |
| .NET | Hookbase 5.1.0 | new WebhookVerifier("whpk_...") |
| Java | hookbase-java 1.1.0 | new Webhook("whpk_...") |
Without an SDK, use one of the snippets below.
Node.js (v1a)
Uses only Node's built-in crypto (Node 16+):
import { createPublicKey, verify } from 'node:crypto';
const PUBLIC_KEY = process.env.HOOKBASE_PUBLIC_KEY; // whpk_...
const key = createPublicKey({
key: { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(PUBLIC_KEY.slice(5), 'base64').toString('base64url') },
format: 'jwk',
});
// rawBody: the request body exactly as received (a string, before JSON.parse)
export function verifyWebhook(rawBody, headers, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const content = Buffer.from(`${id}.${timestamp}.${rawBody}`);
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
return version === 'v1a' && !!signature && verify(null, content, key, Buffer.from(signature, 'base64'));
});
}Python (v1a)
Uses the cryptography package:
import base64
import os
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
PUBLIC_KEY = os.environ["HOOKBASE_PUBLIC_KEY"] # whpk_...
key = Ed25519PublicKey.from_public_bytes(base64.b64decode(PUBLIC_KEY.removeprefix("whpk_")))
def verify_webhook(raw_body: bytes, headers, tolerance_seconds: int = 300) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
content = f"{msg_id}.{timestamp}.".encode() + raw_body
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version != "v1a":
continue
try:
key.verify(base64.b64decode(signature), content)
return True
except (InvalidSignature, ValueError):
continue
return FalseGo (v1a)
Standard library only:
package webhooks
import (
"crypto/ed25519"
"encoding/base64"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// publicKey is the endpoint's whpk_ key with the prefix removed, base64-decoded (32 bytes).
func VerifyV1a(publicKey ed25519.PublicKey, rawBody []byte, h http.Header) bool {
id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
t, err := strconv.ParseInt(ts, 10, 64)
if id == "" || sigs == "" || err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
content := []byte(id + "." + ts + "." + string(rawBody))
for _, entry := range strings.Split(sigs, " ") {
version, sig, ok := strings.Cut(entry, ",")
if !ok || version != "v1a" {
continue
}
if raw, err := base64.StdEncoding.DecodeString(sig); err == nil && ed25519.Verify(publicKey, content, raw) {
return true
}
}
return false
}Java (v1a)
JDK only (Java 15+). The whpk_ key is the raw 32-byte public key, so it is wrapped in the fixed X.509 header the JDK's KeyFactory expects. Read the body as bytes, not a String, for the reason given in the Java section:
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Map;
public final class WebhookV1a {
// DER prefix that wraps a raw 32-byte Ed25519 key as an X.509 SubjectPublicKeyInfo
private static final byte[] ED25519_SPKI_PREFIX = HexFormat.of().parseHex("302a300506032b6570032100");
private final PublicKey key;
/** publicKey: the endpoint's whpk_... key */
public WebhookV1a(String publicKey) throws GeneralSecurityException {
byte[] raw = Base64.getDecoder().decode(publicKey.substring("whpk_".length()));
byte[] spki = new byte[ED25519_SPKI_PREFIX.length + raw.length];
System.arraycopy(ED25519_SPKI_PREFIX, 0, spki, 0, ED25519_SPKI_PREFIX.length);
System.arraycopy(raw, 0, spki, ED25519_SPKI_PREFIX.length, raw.length);
key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki));
}
/** rawBody: the request body exactly as received; headers: lower-case names */
public boolean verify(byte[] rawBody, Map<String, String> headers) {
String id = headers.get("webhook-id");
String timestamp = headers.get("webhook-timestamp");
String signatures = headers.get("webhook-signature");
if (id == null || timestamp == null || signatures == null) return false;
try {
if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) return false;
} catch (NumberFormatException e) {
return false;
}
byte[] prefix = (id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8);
for (String entry : signatures.split(" ")) {
String[] parts = entry.split(",", 2);
if (parts.length != 2 || !parts[0].equals("v1a")) continue;
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(key);
verifier.update(prefix);
verifier.update(rawBody);
if (verifier.verify(Base64.getDecoder().decode(parts[1]))) return true;
} catch (GeneralSecurityException | IllegalArgumentException e) {
// malformed entry: try the next one
}
}
return false;
}
}Rotating and switching schemes
- Rotate (
POST .../rotate-secret, or Rotate key in the dashboard) generates a new key pair and returnspublicKey(the new key) andpreviousPublicKey. UntilpreviousSecretExpiresAt, every delivery is signed with both keys (v1a,<old> v1a,<new>), so update your receivers within the grace period. No response ever contains the private key. - Switch scheme with
PATCH .../webhook-endpoints/{id}and{"signatureScheme": "ed25519"}(or"hmac-sha256"). Switching generates a fresh key of the new kind immediately and ends any grace period: a v1 receiver can't check a v1a signature and vice versa, so there is nothing to dual-sign. Switching to HMAC returns the newwhsec_secret once (data.secret). Coordinate the change with your receiver. - The embeddable portal API (
/portal/endpoints) accepts the samesignatureSchemefield on create and update.
Some Standard Webhooks libraries only implement v1 (HMAC). If yours doesn't accept a whpk_ key, use the snippets above.
Security Best Practices
Use Timing-Safe Comparison
Always use constant-time comparison functions to prevent timing attacks:
| Language | Function |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Go | crypto/subtle.ConstantTimeCompare() |
| Ruby | OpenSSL.fixed_length_secure_compare() or Rack::Utils.secure_compare() |
| PHP | hash_equals() |
| Java | java.security.MessageDigest.isEqual() |
Verify Timestamp Freshness
Check that the webhook timestamp is within an acceptable window (default: 5 minutes) to prevent replay attacks. Reject webhooks with timestamps that are too old or too far in the future.
Store Secrets Securely
- Never hardcode your signing secret in source code
- Use environment variables or a secrets manager
- Rotate secrets periodically using the secret rotation API
Read Raw Body
Ensure you read the raw request body before any JSON parsing or middleware transformation, as the signature is computed over the exact bytes sent.
Troubleshooting
"Signature mismatch" Error
- Check the raw body: Ensure you're using the exact request body without any modifications
- Verify the secret: Make sure you're using the correct signing secret for the endpoint
- Check header extraction: Ensure headers are being read correctly (case-insensitive)
"Timestamp too old" Error
- Check server clock: Ensure your server's clock is synchronized using NTP
- Increase tolerance: If experiencing network delays, consider increasing the tolerance window
- Check for replay: Old timestamps may indicate a replay attack
"Invalid signature format" Error
- Check header presence: Verify all three required headers are present
- Check version prefix: The signature should start with
v1,(HMAC) orv1a,(Ed25519). An Ed25519 endpoint never sendsv1,entries, so an HMAC verifier rejects it: check the endpoint'ssignatureScheme
Secret Rotation
Hookbase supports zero-downtime secret rotation with a grace period. During rotation:
- Both the old and new secrets are valid: every delivery carries two signatures,
v1,<old> v1,<new>(orv1a,<old> v1a,<new>for Ed25519 endpoints) - Verify by accepting the request if any entry matches the secret or key you hold
- After the grace period expires, only the new secret (or key) signs
See the Secret Rotation API for implementation details.