Agent skill

Neon Webhooks Skill

Receive and verify Neon Auth webhooks. Use when setting up Neon webhook handlers, debugging Ed25519 / detached JWS signature verification, or handling Neon Auth events like user.created, user.before_create, send.otp, send.magic_link, or phone_number.verified.

Install this skill

npx skills add hookdeck/webhook-skills --skill neon-webhooks


When to Use This Skill

  • Setting up Neon Auth webhook handlers
  • How do I verify Neon webhook signatures?
  • Why is my Neon webhook signature verification failing?
  • Understanding Neon Auth event types and blocking vs non-blocking events
  • Handling user.created, user.before_create, send.otp, send.magic_link, or phone_number.verified

Verification (core)

Neon Auth signs each webhook with EdDSA (Ed25519) as a detached JWS — there is no shared secret. You verify with the public key published at <NEON_AUTH_URL>/.well-known/jwks.json, selected by the X-Neon-Signature-Kid header. Do not use svix or an HMAC template — neither applies here.

The critical gotcha is the double base64url encoding of the signing input. A naive `${timestamp}.${body}` reconstruction will always fail. Use the raw request body bytes and note X-Neon-Timestamp is in milliseconds.

import crypto from 'node:crypto';

// X-Neon-Signature is a detached JWS: "header..signature" (empty middle section).
async function verifyNeonWebhook(rawBody, headers, jwksUrl) {
  const [headerB64, emptyPayload, signatureB64] = headers['x-neon-signature'].split('.');
  if (emptyPayload !== '') throw new Error('Expected detached JWS (header..signature)');

  const jwks = await fetch(jwksUrl).then((r) => r.json());          // cache these keys by kid
  const jwk = jwks.keys.find((k) => k.kid === headers['x-neon-signature-kid']);
  if (!jwk) throw new Error('Signing key not found in JWKS');
  const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });

  // Double base64url: signingInput = header + "." + b64url(timestamp + "." + b64url(rawBody))
  const payloadB64 = Buffer.from(rawBody, 'utf8').toString('base64url');
  const inner = `${headers['x-neon-timestamp']}.${payloadB64}`;      // timestamp is in MILLISECONDS
  const signingInput = `${headerB64}.${Buffer.from(inner, 'utf8').toString('base64url')}`;

  const ok = crypto.verify(null, Buffer.from(signingInput), publicKey,
    Buffer.from(signatureB64, 'base64url'));                          // null alg = Ed25519
  if (!ok) throw new Error('Invalid signature');
  return JSON.parse(rawBody);                                         // parse only AFTER verifying
}

Enforce a timestamp tolerance (e.g. 5 minutes) against X-Neon-Timestamp to block replays, and use X-Neon-Event-Id for idempotency.

For complete handlers with route wiring, event dispatch, JWKS caching, and tests, see:

Request Headers

HeaderDescription
X-Neon-SignatureDetached JWS, format header..signature (empty middle section)
X-Neon-Signature-KidKey ID — select the matching key from the JWKS
X-Neon-TimestampUnix timestamp in milliseconds (replay protection)
X-Neon-Event-TypeEvent type, e.g. user.created
X-Neon-Event-IdEvent UUID — use for idempotency
X-Neon-Delivery-AttemptDelivery attempt number (1, 2, or 3)

Common Event Types

EventTypeFires When
send.otpBlockingA one-time passcode needs delivering (custom OTP delivery)
send.magic_linkBlockingA magic link needs delivering (custom link delivery)
user.before_createBlockingJust before a user is written — validate/reject signups
user.createdNon-blockingA user account has been created (sync to CRM/analytics)
phone_number.verifiedNon-blockingA user's phone number has been verified

Blocking events pause the auth flow until your endpoint returns a 2xx (or times out) — respond fast and do heavy work asynchronously.

For full event reference, see Neon Auth webhooks.

Environment Variables

NEON_AUTH_URL=https://your-neon-auth-domain.com   # JWKS fetched from ${NEON_AUTH_URL}/.well-known/jwks.json

There is no signing secret — verification uses the public JWKS, so nothing sensitive is stored.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 neon --path /webhooks/neon

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 3, 2026

View on GitHub →