Back to Blog
Security

The Signature Header You Verify Just Stopped Arriving

On September 1, 2026, HighLevel stopped signing webhooks with its RSA X-WH-Signature header and moved to Ed25519 in X-GHL-Signature. Here is why a signature scheme cutover breaks receivers in two opposite ways, and how to build a verifier that survives the next one.

WebhookVault Team · Webhook Infrastructure Experts10 min read
A dark computer monitor showing lines of code in orange and white text, with a red light glowing along its right edge and a faint blue glow in the lower left corner

The header you verify stopped arriving on September 1

Your webhook verifier reads one header. It has read that header for years. Then one morning the header is gone, and your handler does one of two things: it rejects every delivery, or it accepts every delivery without checking anything. Both are outages. Only one of them shows up on a dashboard.

HighLevel just ran exactly this kind of cutover. Their webhook integration guide said it plainly: the legacy X-WH-Signature header would be deprecated on September 1, 2026, and after that date webhooks are signed only with X-GHL-Signature. The name is the smallest part of the change. The old header carried an RSA-SHA256 signature and the new one carries Ed25519, so the public key and the verification call changed too.

HighLevel won't be the last. Standard Webhooks, RFC 9421 and the general drift toward public-key signatures mean more providers will run this same migration over the next couple of years. Treat this one as a rehearsal.

What HighLevel actually changed

Here is the before and after, as the guide documents it.

Before, every delivery carried X-WH-Signature: a base64-encoded RSA-SHA256 signature over the payload, checked against an RSA public key HighLevel publishes in its docs. During the transition, deliveries also carried X-GHL-Signature, a base64-encoded Ed25519 signature over the same payload, checked against a separate Ed25519 public key.

The migration guidance was a precedence rule. If X-GHL-Signature is present, verify it with the Ed25519 key. If only X-WH-Signature is present, verify it with the legacy RSA key. If verification fails, reject the request.

September 1 has passed, so that second branch is dead code now. If your integration never got past the first draft, the one that only knew about X-WH-Signature, you are running a verifier that looks for a header nobody sends.

The retry policy makes it expensive. HighLevel retries on any response outside the 2xx range, timeouts and connection failures included. It retries up to 12 times after the original attempt, with exponential backoff and random jitter, and stops at the first 2xx. A receiver that rejects the new deliveries doesn't lose them quietly. It collects a dozen failing retries per event.

Fail-open verification is the bug nobody tests

Plenty of verifiers in the wild look like this.

const signature = req.headers['x-wh-signature']
if (signature && !verifyRsa(req.body, signature)) {
  return res.status(401).end()
}
// carry on processing

Read it slowly. Header present and wrong: reject. Header missing: carry on. Somebody wrote it that way so local testing worked without a signature, or so a health check could hit the endpoint, and then it shipped.

On September 1 that code stopped verifying anything. X-WH-Signature never shows up anymore, the condition is always false, and every request goes straight through. HighLevel's real deliveries still work. So does a forged request from anyone who has found your URL. Nothing errors. Nothing alerts. Your success rate looks great.

This is the one worth losing sleep over. A missing signature has to be a rejection, full stop. If you need an unsigned path for local development, put it behind an explicit environment flag that can't be set in production. Never behind a missing header.

Fail-closed has a cost too

The receivers that did the right thing, rejecting anything without a valid signature, started returning 401 on every HighLevel delivery on September 1.

Safe, yes. Free, no. Each event now burns through up to 12 retries, and the guide says the jitter can make the gap between attempts vary "from seconds to minutes". Every retry lands in your error logs. If your alerting keys on 4xx rates per route, it fires. If it keys only on 5xx, it may stay quiet, because from your server's point of view a 401 is a correct answer to a bad request.

Depending on the provider, sustained failures can also get your endpoint switched off. We covered that spiral in When the Sender Gives Up on Your Webhook Endpoint. A signature cutover is a textbook trigger: every delivery fails, for a reason that won't clear on its own, until someone ships a code change.

So fail closed, always. And when signature failures jump overnight, open an incident.

Ed25519 in Node is no drop-in swap

If you have only done HMAC or RSA verification in Node, Ed25519 has one trap. You don't pass a digest algorithm, because Ed25519 does its own hashing. You pass null.

import crypto from 'node:crypto'

// RSA-SHA256, the legacy X-WH-Signature scheme
function verifyRsa(body: Buffer, signature: string, publicKeyPem: string): boolean {
  return crypto
    .createVerify('SHA256')
    .update(body)
    .verify(publicKeyPem, signature, 'base64')
}

// Ed25519, the current X-GHL-Signature scheme
function verifyEd25519(body: Buffer, signature: string, publicKeyPem: string): boolean {
  return crypto.verify(null, body, publicKeyPem, Buffer.from(signature, 'base64'))
}

Swap the key but keep createVerify('SHA256'), and you get a verifier that throws or returns false on every request. Now you're back in the fail-closed incident, convinced the provider is broken.

Both signatures are base64, so the decoding carries over. The key doesn't. The Ed25519 key is a PEM block of a few dozen characters. The RSA key is a 4096-bit monster. If your config loader has a length check or a "looks like RSA" sanity check, it will reject the new key. Yes, this happens.

Verify the bytes you received, not the object you parsed

HighLevel's own sample code builds the verification payload with JSON.stringify(req.body). It parses the JSON and serializes it again. That works as long as re-serializing gives exactly the bytes that were signed: same key order, same whitespace, same number formatting, same escaping of non-ASCII characters.

Usually it does. When it doesn't, say a float like 1.0 comes back as 1, that one delivery fails verification and nobody can work out why.

Capture the raw body and verify that. In Express, mount express.raw({ type: 'application/json' }) on the webhook route and parse the JSON yourself after verification passes. One line, and a whole class of heisenbugs is gone. Same argument as in Transforming Webhook Payloads at the Gateway: once anything touches the bytes, the signature check is testing your serializer.

Write the verifier as an ordered list of schemes

The structural fix is to stop hardcoding "the signature header". A provider has signature schemes, plural, and at any moment you accept some of them. Model that directly.

import crypto from 'node:crypto'

type Scheme = {
  name: string
  header: string
  acceptUntil?: Date
  verify: (body: Buffer, signature: string) => boolean
}

const schemes: Scheme[] = [
  {
    name: 'ghl-ed25519',
    header: 'x-ghl-signature',
    verify: (body, sig) =>
      crypto.verify(null, body, process.env.GHL_ED25519_PUBLIC_KEY!, Buffer.from(sig, 'base64')),
  },
  {
    name: 'wh-rsa-sha256',
    header: 'x-wh-signature',
    acceptUntil: new Date('2026-09-01T00:00:00Z'),
    verify: (body, sig) =>
      crypto
        .createVerify('SHA256')
        .update(body)
        .verify(process.env.GHL_RSA_PUBLIC_KEY!, sig, 'base64'),
  },
]

export function verifyDelivery(body: Buffer, headers: Record<string, string | string[] | undefined>) {
  const now = new Date()
  for (const scheme of schemes) {
    const value = headers[scheme.header]
    if (typeof value !== 'string') continue
    if (scheme.acceptUntil && now >= scheme.acceptUntil) continue
    try {
      return { ok: scheme.verify(body, value), scheme: scheme.name }
    } catch {
      return { ok: false, scheme: scheme.name }
    }
  }
  return { ok: false, scheme: 'none' }
}

A few decisions in there are deliberate.

The strongest scheme present decides. If X-GHL-Signature is there and fails, the function does not fall back to X-WH-Signature. That matches HighLevel's precedence rule, and an attacker can't pick the weaker check by sending a different header.

The legacy scheme expires by itself. acceptUntil turns the RSA path off on the provider's announced date, no deploy needed. Without it the old branch lives forever, and nobody checks whether that old key was ever retired or leaked.

No matching header means ok: false. There is no path through this function that returns true without a successful cryptographic check.

Log which scheme accepted every delivery

That scheme field is the most useful thing you'll log this quarter. Put it on every webhook log line and every metric.

With it, a cutover stops being guesswork. Before the deadline you see what share of deliveries already carry the new header. On the day, you watch the legacy count fall to zero while the new one takes over. Legacy traffic still arriving after the provider said it would stop deserves a look: a second sender you forgot, a stale replay sitting in a queue, or someone who captured an old request.

Keep a separate counter for scheme: none. In steady state it sits at zero. When it doesn't, either something is probing your endpoint or something in front of your app is dropping headers. The second one is more common than you'd think. API gateways and some WAF setups forward only an allowlist of headers, and a brand-new name like X-GHL-Signature isn't on it. The provider signs correctly, your code verifies correctly, and the header dies at the edge.

If you already tag logs with structured fields, as in Your Webhook Logs Are Useless, this is one more key. If you don't, here's your reason to start.

Your test fixtures are frozen at the old scheme

This one catches teams that did everything else right. Your integration tests replay captured HighLevel deliveries from a fixtures directory. Those fixtures were recorded two years ago. They carry X-WH-Signature and nothing else.

Tests pass. Production fails. The fixtures describe a sender that no longer exists.

Recapture fixtures whenever a provider announces a signing change, and date them. Better, add a test that fails when a fixture past a set age is in use. Signature fixtures go stale faster than payload fixtures: a payload can keep its shape for years, while the signing scheme changes on a date someone else picked.

Give deprecation notices an incident ticket

A signing deprecation is a scheduled outage with a date on it. Whether it hits you depends on work you do before that date.

When a provider announces a change to its signature header, algorithm or key, open a ticket due on the cutover date. Add the new scheme to your verifier and deploy it. Check the per-scheme metric to confirm the new header is being verified in production. Only then set the legacy acceptUntil.

Be strict about it, because this kind of change will keep coming. Providers have good reasons to move to public keys. As we covered in Webhook Signatures Are Converging on Standard Webhooks, an asymmetric signature leaves the receiver holding nothing secret, so there's no shared secret to leak the way GitHub's header exposure leaked them. Each of those migrations is a HighLevel-style cutover for somebody.

Public keys change what rotation means

With HMAC, rotation means you and the provider swap a shared secret, and during the overlap you try two secrets against the same header. With public keys, the provider starts signing with a new private key, and you need the matching public key before its first signature arrives.

That changes where the key lives. HighLevel publishes its keys in documentation, so an update means a person reading a page and editing config. Other providers serve keys from an endpoint you fetch and cache. The scheme list above handles both. A new key version is one more entry, with its own name in your logs, its own metric, and its own expiry date for the key it replaces.

Header names, algorithms and keys will all change on you at some point. When they do, you want to be editing a config entry, and nothing more.

Frequently asked questions

How do I know whether my HighLevel integration fails open right now? Send your production endpoint a request with a valid-looking JSON body and no signature headers at all. If you get anything other than a 401 or 403, your verifier accepts unsigned requests and you should fix that before anything else.

Should I return 401 or 400 when the signature header is missing? Either works for the sender, since HighLevel retries on any non-2xx response. A 401 is more honest and easier to alert on separately from malformed payloads, which is why we prefer it.

Can I verify with JSON.stringify of the parsed body like the provider sample does? It works as long as re-serialization reproduces the signed bytes exactly, which it usually does. Verifying the raw request body removes that assumption and costs one middleware change, so it is the safer default.

What happens to the retries that failed while my verifier was broken? Once HighLevel exhausts its 12 retries for an event, that delivery stops. After you fix the verifier, reconcile the affected window through the API instead of hoping the events arrive again, and deduplicate on the webhookId field in case some do.

Do I still need timestamp checks if the signature is Ed25519? Yes. A stronger algorithm makes forgery harder but does nothing against someone replaying a genuine signed request. Check the timestamp in the payload against a tolerance window and keep your webhookId deduplication in place.

Related posts