Ed25519 Webhook Signatures and Zero-Downtime Key Rotation
Outbound endpoints can now sign with Ed25519 (Standard Webhooks v1a) instead of a shared HMAC secret. Here is why public-key signing helps the people receiving your webhooks, exactly what the headers look like, how to verify them in Node, and how rotation keeps both signatures valid during a grace period.
The Problem With a Shared Secret
Every outbound webhook Hookbase sends has been signed with HMAC-SHA256, following the Standard Webhooks spec: a whsec_ secret, a signature over {webhook-id}.{webhook-timestamp}.{body}, and a webhook-signature: v1,<base64> header. HMAC is fast, well understood and supported by every language, and it stays the default.
It has one structural property worth thinking about, though: it is symmetric. The secret that verifies a signature is the same secret that creates one. Every receiver that can check your webhooks can also forge them. That secret ends up in environment variables, CI settings, a teammate's .env and sometimes a support ticket, and anyone holding it can produce a request your receiver will accept.
For many integrations that's fine. But if your receivers run in many places, if several teams share one endpoint configuration, or if you would rather the receiving side held nothing an attacker could sign with, there is a better tool.
What Shipped: Ed25519 (v1a)
Outbound endpoints now have a signature scheme setting with two values:
| Scheme | Header entry | Receiver verifies with |
|--------|--------------|------------------------|
| hmac-sha256 (default) | v1,<base64> | The endpoint's shared whsec_ secret |
| ed25519 | v1a,<base64> | The endpoint's whpk_ public key |
With ed25519, Hookbase generates an Ed25519 key pair for the endpoint. The private key (whsk_...) is encrypted at rest like any signing secret and is never returned by the API, not on create, not on read and not on rotation. You get the public key, whpk_<base64 of the 32-byte key>, which can verify signatures but cannot produce them. You can paste it into a config file, commit it to a repo or send it to a partner without changing your threat model.
The signed content is identical to HMAC: {webhook-id}.{webhook-timestamp}.{raw body}. Only the algorithm and the version tag change. Each v1a entry is a base64-encoded 64-byte Ed25519 signature.
Turning It On
In the dashboard, pick Ed25519 (v1a) under Signature scheme when you create an outbound endpoint. The endpoint page then shows the public key with a copy button.
Through the API, pass signatureScheme on create:
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": "app_...", "url": "https://example.com/webhooks", "signatureScheme": "ed25519"}'
# → { "data": { "id": "...", "signatureScheme": "ed25519", "publicKey": "whpk_...", ... } }
Every endpoint response includes signatureScheme and publicKey (which is null for HMAC endpoints), so you can fetch the key again whenever you need it. Endpoints created through the embeddable customer portal accept the same signatureScheme field.
To move an existing endpoint between schemes, PATCH it with {"signatureScheme": "ed25519"} (or "hmac-sha256"). Switching generates a fresh key of the new kind straight away and ends any rotation grace period. A v1 receiver can't check a v1a signature and a v1a receiver can't check a v1 one, so there is nothing useful to dual-sign. Coordinate the switch with whoever runs the receiver.
The Headers
A delivery to an Ed25519 endpoint carries:
webhook-id: <message id>
webhook-timestamp: 1760950000
webhook-signature: v1a,<base64 signature>
The legacy x-hookbase-id, x-hookbase-timestamp and x-hookbase-signature aliases are still sent with the same values, so older receivers keep working while they migrate to the Standard Webhooks names. Each delivery attempt is signed when it is sent, with a fresh timestamp, so a retry an hour later still passes a five-minute freshness check.
Verifying in Node
No SDK or extra dependency is needed. Node's built-in crypto verifies Ed25519 (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'));
});
}
Three details matter:
- Verify the raw body. Re-serializing parsed JSON changes whitespace and key order, and the signature no longer matches.
- Check the timestamp. The signed content includes it, so an attacker can't change it, but you still have to reject old timestamps to stop replays. Five minutes is the usual window.
- Accept any matching entry.
webhook-signatureis a space-separated list. During a rotation it holds two entries, which is how zero-downtime rotation works (more below).
The dashboard generates this snippet with your endpoint's public key already filled in. The signature verification guide has the same check for Python (cryptography), Go (crypto/ed25519) and Java (the JDK's built-in Ed25519 Signature).
A note on the SDKs: the webhook verify helpers in the Hookbase SDKs check v1 (HMAC) entries only. For a v1a endpoint, use the standard-library code above. It is about twenty lines and has no dependencies.
Zero-Downtime Rotation
Rotating a signing key is where webhook integrations usually break. Change the key on the sender and every receiver still holding the old one starts rejecting traffic until someone updates it. Change it on the receiver first and every request fails until the sender catches up.
Hookbase avoids both by signing with both keys for a grace period. Rotate from the endpoint page (Rotate key for Ed25519, Rotate secret for HMAC) or through the API:
curl -X POST https://api.hookbase.app/api/organizations/{orgId}/webhook-endpoints/{endpointId}/rotate-secret \
-H "Authorization: Bearer $HOOKBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"gracePeriodSeconds": 3600}'
For an Ed25519 endpoint the response contains public keys only:
{
"signatureScheme": "ed25519",
"publicKey": "whpk_new...",
"previousPublicKey": "whpk_old...",
"previousSecretExpiresAt": "2026-10-20T16:00:00Z",
"secretVersion": 2
}
Until previousSecretExpiresAt, every delivery carries two signatures, old key first:
webhook-signature: v1a,<signed with old key> v1a,<signed with new key>
HMAC endpoints work the same way, with v1,<old> v1,<new>. A receiver that still has the old key matches the first entry. A receiver that already has the new key matches the second. Because the verifier above accepts any matching entry, nothing fails at any point during the rollout. The recommended order is:
- Rotate, and get the new public key.
- Deploy the new key to your receivers before
previousSecretExpiresAt. If you want to be sure, accept both keys for a while. - When the grace period ends, only the new key signs.
The grace period defaults to one hour through the API and can be set anywhere from 0 (switch immediately) to 86400 seconds (24 hours). The dashboard offers none, 1 hour, 6 hours or 24 hours. Rotations started from the customer portal default to 24 hours. The legacy x-hookbase-signature header only ever carries the current key's signature, because older consumers often read it as a single value.
Ed25519 also makes rotation easier to coordinate: the thing you hand to receivers is a public key. You can post it in a channel, put it in a config repo or email it to a partner without first finding a secure way to share a secret.
Which One Should You Use?
- Stay on HMAC if you want the widest out-of-the-box support. The webhook verify helpers in the Node, Python, Go, .NET and Java SDKs all handle
v1, and every language's standard library has HMAC-SHA256. - Choose Ed25519 when you don't want receivers to hold anything that can sign: many receiving services, shared configs, third parties verifying your traffic, or a compliance stance that prefers asymmetric keys.
Both schemes are available for every outbound endpoint, and the verification guide covers both. For the full rotate-secret request and response, see the Send API reference.