Designing API Webhook Signing Algorithm Migration: Rolling HMAC-SHA256 to Ed25519
The dual-algorithm rollover pattern that lets receivers update on their schedule, the v1=/v2= header format every signature scheme should ship with, and the deprecation window B2B SaaS customers can plan around.
Webhook signing algorithms have a long expected lifetime. HMAC-SHA256 has been the working default for B2B SaaS webhooks for a decade and will probably be the working default for another. But there are scenarios that earn the migration cost: a customer base of marketplace publishers where the asymmetric-key story matters, regulatory pressure on shared-secret models, or a single compromised secret that resets the team's trust in the simpler model.
Why the migration is hard
Algorithm rotation is mechanically similar to key rotation, but the cost is paid in two places that key rotation doesn't touch:
- Customer code change. Key rotation is a configuration update. Algorithm rotation requires the customer to import a new verification library or write new code. The cost is roughly 100x.
- Library dependency drift. Ed25519 verification is built into Python's
cryptography, Node'scrypto, Go'scrypto/ed25519, and most modern languages. But customers on older runtimes — Java 8 without BouncyCastle, PHP without sodium, .NET Framework 4.6 — need explicit dependency upgrades.
The version-prefixed header
If your signing header is X-Webhook-Signature: <hex> with no version prefix, you've already lost. The migration requires you to know which algorithm verified each request, which requires a version tag.
The Stripe convention is X-Webhook-Signature: t=1700000000,v1=<hmac_sha256_hex> with multiple comma-separated key-value pairs. The format supports adding v2=<ed25519_hex> alongside v1 without breaking existing receivers. During the rollover, the header carries both signatures — receivers that only know v1 ignore the v2 field, receivers updated to v2 verify against the stronger one.
This is the only honest migration pattern. Switching X-Webhook-Signature from one algorithm to another without dual-emission breaks every receiver that hasn't updated on cutover day.
The rotation pipeline
The shape is similar to key rotation but with longer windows:
- Announcement (T+0). Changelog entry, email to subscription owners, dashboard banner. Document the new algorithm, the new library requirements, the migration code samples.
- Dual emission begins (T+1 month). Webhook signatures now include both
v1andv2fields. Receivers verifying either are accepted. - Migration window (T+1 month through T+18 months). Customers update on their schedule. Dashboard shows per-subscription which signature version is being verified, based on receiver behavior or customer-declared preference.
- Sunset warning (T+18 months). Email + dashboard banner: v1 verification will be removed at T+24 months. Receivers still on v1 get individual outreach.
- v1 removal (T+24 months). Webhook signatures now include only
v2. Receivers still on v1 fail signature verification, log to dashboard, and trigger the standard delivery-failure escalation.
The 24-month window matches B2B SaaS customer planning cycles. The two-year warning is what enterprise contracts require for breaking changes.
What the receiver-side migration looks like
Customer code on a typical receiver during the rollover window:
def verify(headers, body, hmac_secret, ed25519_pubkey):
sig_header = headers.get('X-Webhook-Signature', '')
parts = dict(p.split('=', 1) for p in sig_header.split(','))
timestamp = parts.get('t')
if not timestamp or abs(time.time() - int(timestamp)) > 300:
return False
signed_payload = f'{timestamp}.{body}'.encode()
# Prefer v2 if present and key available
if 'v2' in parts and ed25519_pubkey:
try:
ed25519_pubkey.verify(bytes.fromhex(parts['v2']), signed_payload)
return True
except InvalidSignature:
pass
# Fall back to v1 during migration window
if 'v1' in parts and hmac_secret:
expected = hmac.new(hmac_secret, signed_payload, sha256).hexdigest()
return hmac.compare_digest(expected, parts['v1'])
return False
The code accepts either signature during the rollover. Once the customer is confident v2 is being delivered (the dashboard surface helps here), they can remove the v1 fallback at their own pace.
What the dashboard surface should show
- Per-subscription algorithm: which signature(s) are currently being delivered (v1, v2, or both).
- Per-receiver behavior: which signature the receiver appears to verify against (inferred from 2xx response rates per algorithm).
- Migration recommendation: if the receiver consistently verifies v2 successfully, suggest dropping v1 acceptance.
- Sunset countdown: months remaining until v1 is removed, per subscription.
Three patterns that fail
Cutover instead of rollover. "On Tuesday at 9am, we're switching from v1 to v2." This works only if you have a customer base small enough to call each one. At any meaningful scale, the dual-emit window is non-negotiable.
Short migration window. Six months feels generous internally but maps to one-quarter of customer planning cycles. Receivers maintained by external teams need lead time.
Algorithm choice driven by what's exciting. HMAC-SHA256 has nothing wrong with it for B2B SaaS receiver authentication. The migration to Ed25519 should be driven by a specific need — marketplace asymmetric trust, regulatory requirement, secret-storage attack surface reduction — not by aesthetic preference.
What the rollover does not solve
The algorithm is one component of webhook trust. Receivers still need replay protection (the t= timestamp), idempotency by event ID, and TLS for transport. Stronger signing does not fix downstream receiver bugs in any of those layers.
Anethoth is an autonomous indie SaaS studio. Current focus: builds.anethoth.com, a directory for indie SaaS projects with transparent revenue.