Gareth Wilson Gareth Wilson

Guide to Neon Webhooks: Features and Best Practices

Published


Neon Auth webhooks notify your application about authentication and user lifecycle events: a user is created, an OTP or magic link needs sending, a phone number is verified. If you're building on Neon Auth, webhooks are how you react to these events without polling.

This guide covers how Neon's native webhooks work, the events you'll handle, how to verify the Ed25519 detached-JWS signature (public-key crypto with no shared secret), the replay window, and the best practices for production.

What are Neon webhooks?

Neon Auth's native webhooks are HTTP POSTs delivered to a URL you configure, each signed with an Ed25519 detached JWS. Verification is asymmetric: there's no shared secret. You fetch Neon's public key from a JWKS endpoint (matching the key ID in the request) and verify the signature.

Neon Auth has had more than one webhook implementation. This guide covers Neon's native webhook signing (Ed25519 / JWKS), which is what the current Neon source verifies. An older Neon Auth path was powered by Stack Auth and delivered via Svix (a whsec_ HMAC scheme); if you're on that legacy path, verify with the Svix libraries instead. Confirm which system your Neon project uses before wiring up verification.

Neon webhook features

FeatureDetails
Signature headerX-Neon-Signature (detached JWS, header..signature)
Key ID headerX-Neon-Signature-Kid
Timestamp headerX-Neon-Timestamp (Unix milliseconds)
Other headersX-Neon-Event-Type, X-Neon-Event-Id, X-Neon-Delivery-Attempt (1-3)
Signature schemeEdDSA (Ed25519) detached JWS; no shared secret
Key distributionJWKS at <NEON_AUTH_URL>/.well-known/jwks.json (match kid)
Signing inputheader.base64url(timestamp + "." + base64url(body)).signature
Replay protectionReject X-Neon-Timestamp older than ~5 minutes
Delivery attemptsUp to 3 (X-Neon-Delivery-Attempt)

Common events

Neon Auth webhook events cover the auth and user lifecycle:

EventFires when
user.createdA new user is created
user.before_createBefore a user is created (pre-creation hook)
send.otpA one-time passcode needs delivering
send.magic_linkA magic link needs delivering
phone_number.verifiedA phone number is verified

The X-Neon-Event-Type header names the event; subscribe to what you need and consult Neon's webhook reference for the full list.

Setting up Neon webhooks

Configure webhooks in the Neon Auth dashboard, pointing at your HTTPS endpoint. There's no shared secret to copy; verification uses Neon's public keys from the JWKS endpoint, so all you need on your side is the JWKS URL for your Neon Auth instance.

Securing Neon webhooks

Each delivery carries an X-Neon-Signature (a detached JWS, header..signature), an X-Neon-Signature-Kid, and an X-Neon-Timestamp (milliseconds). To verify:

  1. Reject deliveries whose timestamp is more than ~5 minutes old.
  2. Reconstruct the signing input: base64url-encode the raw body, join it to the timestamp as timestamp + "." + payloadB64, base64url-encode that, and prefix the JWS header. So the full JWS is header.base64url(timestamp + "." + base64url(body)).signature.
  3. Fetch the public key from JWKS by matching X-Neon-Signature-Kid, and verify the Ed25519 signature.
const { compactVerify, importJWK } = require("jose");

const JWKS_URL = `${process.env.NEON_AUTH_URL}/.well-known/jwks.json`;

async function keyFor(kid) {
  const jwks = await fetch(JWKS_URL).then((r) => r.json()); // cache in production
  const jwk = jwks.keys.find((k) => k.kid === kid);
  return importJWK(jwk, "EdDSA");
}

async function verify(rawBody, headers) {
  const timestamp = headers["x-neon-timestamp"];
  // Reject stale deliveries (timestamp is in milliseconds)
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

  const [header, , signature] = (headers["x-neon-signature"] || "").split(".");
  const payloadB64 = Buffer.from(rawBody).toString("base64url");
  const signingPayloadB64 = Buffer.from(`${timestamp}.${payloadB64}`).toString("base64url");
  const compact = `${header}.${signingPayloadB64}.${signature}`;

  try {
    await compactVerify(compact, await keyFor(headers["x-neon-signature-kid"]));
    return true;
  } catch {
    return false;
  }
}

app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  if (!(await verify(req.body, req.headers))) return res.sendStatus(401);

  res.sendStatus(200); // acknowledge fast
  processQueue.add({ type: req.headers["x-neon-event-type"], body: JSON.parse(req.body) });
});

Cache the JWKS response and refresh when you see an unknown kid, so you're not fetching keys on every delivery.

Neon webhook limitations and pain points

Two webhook systems, two verification schemes

The Problem: Neon Auth has shipped both a Svix-based path (legacy, whsec_ HMAC) and the native Ed25519/JWKS scheme. Verification code written for one doesn't validate the other, and it's easy to follow the wrong docs.

Why It Happens: Neon Auth's implementation evolved; the native signing scheme replaced the Svix delivery for current setups.

Workarounds:

  • Confirm which system your project uses. For the native scheme, verify the Ed25519 detached JWS against JWKS (above); for the legacy Svix path, use the Svix libraries with the whsec_ secret.

How Hookdeck Can Help: Hookdeck verifies at the edge, so your application receives pre-verified events without you having to track which Neon signing scheme is in force.

Detached JWS with a nested signing input

The Problem: The signed input isn't the raw body. It's header.base64url(timestamp + "." + base64url(body)).signature. Verifying the body directly, or getting the double base64url wrong, fails every delivery.

Why It Happens: Neon binds the timestamp into the signed payload and uses a detached JWS, so the payload has to be reconstructed.

Workarounds:

  • Reconstruct the exact signing input as above and verify with an Ed25519-capable JWS library.

How Hookdeck Can Help: Hookdeck reconstructs and verifies the signature at the edge, so your handler never has to implement the nested encoding.

No shared secret means JWKS management

The Problem: There's no secret to store; you fetch public keys from JWKS. An integration that fetches once and caches forever breaks when Neon rotates keys.

Why It Happens: Asymmetric signing distributes public keys via JWKS and rotates them.

Workarounds:

  • Cache JWKS with a sensible TTL and refresh on an unknown kid.

How Hookdeck Can Help: Hookdeck tracks Neon's rotating keys and verifies each delivery, so key rotation never surfaces as a verification outage in your app.

Retries and replay window

The Problem: Deliveries are attempted up to 3 times (X-Neon-Delivery-Attempt), so the same event can arrive more than once, and stale deliveries must be rejected within the ~5-minute window.

Why It Happens: Retries favor delivery; the timestamp window bounds replay.

Workarounds:

  • Dedupe on X-Neon-Event-Id, and acknowledge fast so retries don't age out.

How Hookdeck Can Help: Hookdeck deduplicates on the event ID and verifies freshness at the edge, so retries don't double-process. See our guide to webhook idempotency.

Best practices

Verify the Ed25519 JWS against JWKS

Reconstruct the nested signing input, match the kid to a JWKS key, verify the Ed25519 signature, and reject timestamps older than ~5 minutes.

Confirm which webhook system you're on

Use the native Ed25519/JWKS verification for current setups; only use the Svix path if your project is on the legacy Stack Auth delivery.

Acknowledge fast, process asynchronously

Return 200 as soon as the signature verifies and defer work to a queue. See why to process webhooks asynchronously.

Dedupe on the event ID

Use X-Neon-Event-Id as an idempotency key so the up-to-3 delivery attempts don't double-process.

Make Neon webhooks production-ready

Hookdeck verifies the Ed25519 JWS, deduplicates, and durably queues every auth event

Conclusion

Neon Auth's native webhooks are signed with an Ed25519 detached JWS verified against a JWKS public key, with no shared secret and a nested, double-base64url signing input that binds the timestamp. Get the reconstruction right, match the kid to a JWKS key, reject stale timestamps, and dedupe on the event ID, and be sure you're on the native scheme rather than the legacy Svix path.

Hookdeck verifies the Ed25519 signature, tracks key rotation, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique auth events.

Get started with Hookdeck for free and handle Neon webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.