Skip to content

RestDB/webhook-verify

Repository files navigation

webhook-verify

npm version License: MIT Works with Codehooks.io Zero Dependencies

One API for all your webhooks. Verify signatures from Stripe, GitHub, Shopify, Slack, and 17 other providers with a single, consistent interface.

// Same pattern for every provider
verify('stripe', payload, headers, secret);
verify('github', payload, headers, secret);
verify('shopify', payload, headers, secret);

Why use this?

  • Multi-provider apps - Building a SaaS that receives webhooks from Stripe, GitHub, and Slack? Use one library instead of three.
  • Zero dependencies - Just Node.js crypto. No dependency tree to audit.
  • Unified API - Stop reading different docs for each provider's signature scheme. HMAC, Ed25519, RSA - it's all just verify().
  • Key rotation built-in - Rotate secrets without downtime using additionalSecrets.

Built and maintained by Codehooks.io - the serverless backend platform for webhook integrations.

Installation

npm install webhook-verify

Quick Start

import { verify } from 'webhook-verify';

// Pass headers directly - signature is extracted automatically
const isValid = verify('stripe', req.rawBody, req.headers, webhookSecret);

if (!isValid) {
  return res.status(401).send('Invalid signature');
}

Important: You must use the raw request body (exact bytes) for verification. Parsed JSON won't work because signatures are computed over the original bytes. See Raw Body Handling for setup instructions.

Supported Providers

Provider Header Method
Stripe Stripe-Signature HMAC-SHA256 + timestamp
GitHub X-Hub-Signature-256 HMAC-SHA256
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 (base64)
Slack X-Slack-Signature HMAC-SHA256 + timestamp
Twilio X-Twilio-Signature HMAC-SHA1
Discord X-Signature-Ed25519 Ed25519
Linear Linear-Signature HMAC-SHA256
Vercel x-vercel-signature HMAC-SHA1
Svix svix-signature HMAC-SHA256 + timestamp
Clerk svix-signature HMAC-SHA256 (Svix)
SendGrid X-Twilio-Email-Event-Webhook-Signature ECDSA
Paddle Paddle-Signature RSA-SHA256
Intercom X-Hub-Signature HMAC-SHA1
Mailchimp X-Mailchimp-Signature HMAC-SHA256 (base64)
GitLab X-Gitlab-Token Token comparison
Home Assistant X-HA-Secret Token comparison
Typeform Typeform-Signature HMAC-SHA256 (base64)
Crystallize X-Crystallize-Signature JWT + HMAC-SHA256
Zendesk X-Zendesk-Webhook-Signature HMAC-SHA256 + timestamp
Square x-square-hmacsha256-signature HMAC-SHA256
HubSpot X-HubSpot-Signature-V3 HMAC-SHA256 + timestamp
Segment X-Signature HMAC-SHA1

API

verify(provider, payload, signatureOrHeaders, secret, options?)

Verify a webhook signature.

function verify(
  provider: Provider,
  payload: string | Buffer,
  signatureOrHeaders: string | Headers,
  secret: string,
  options?: VerifyOptions
): boolean;

Parameters:

  • provider - The webhook provider name
  • payload - The raw request body (string or Buffer)
  • signatureOrHeaders - Either the request headers object OR a signature string
  • secret - The webhook secret, API key, or public key
  • options - Provider-specific options

Returns: true if the signature is valid, false otherwise

Throws: Error if headers object is passed but required signature headers are missing

import { verify } from 'webhook-verify';

// Recommended: Pass headers directly
const isValid = verify('stripe', req.rawBody, req.headers, secret);

// Also works: Pass signature string manually
const isValid = verify('stripe', req.rawBody, signatureString, secret);

getSupportedProviders()

Returns an array of all supported provider names.

isProviderSupported(provider)

Check if a provider is supported.

Raw Body Handling

Webhook signatures are computed over the exact bytes sent by the provider. You must use the raw, unparsed request body - not JSON.parse(body) or similar.

Codehooks.io

Codehooks.io provides req.rawBody automatically:

import { app } from 'codehooks-js';
import { verify } from 'webhook-verify';

app.post('/webhook', async (req, res) => {
  // ✅ Use req.rawBody (the raw bytes)
  const isValid = verify('github', req.rawBody, req.headers, secret);

  // ❌ Don't use req.body (parsed JSON)
  // const isValid = verify('github', JSON.stringify(req.body), req.headers, secret);
});

export default app.init();

Express.js / Node.js

Use express.raw() middleware to capture the raw body:

import express from 'express';
import { verify } from 'webhook-verify';

const app = express();

// Apply raw body parser to webhook routes
app.post(
  '/webhook/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // req.body is a Buffer containing the raw bytes
    const isValid = verify('stripe', req.body, req.headers, secret);
  }
);

// For other routes, you can still use JSON parsing
app.use(express.json());

Why Raw Body Matters

// Original webhook payload from provider
'{"id":123,"name":"test"}';

// After JSON.parse() + JSON.stringify()
'{"id":123,"name":"test"}'; // Might look the same...

// But sometimes:
'{"name":"test","id":123}'; // Key order changed!
'{ "id": 123, "name": "test" }'; // Whitespace changed!

// The signature won't match because the bytes are different

Provider Examples

Stripe

import { verify } from 'webhook-verify';

app.post(
  '/webhook/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const isValid = verify(
      'stripe',
      req.body,
      req.headers,
      process.env.STRIPE_WEBHOOK_SECRET
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body);
    // Process webhook...
  }
);

GitHub

import { verify } from 'webhook-verify';

app.post(
  '/webhook/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const isValid = verify(
      'github',
      req.body,
      req.headers,
      process.env.GITHUB_WEBHOOK_SECRET
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    const event = req.headers['x-github-event']; // e.g., "push", "pull_request"
    // Process webhook...
  }
);

Shopify

import { verify } from 'webhook-verify';

app.post(
  '/webhook/shopify',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const isValid = verify(
      'shopify',
      req.body,
      req.headers,
      process.env.SHOPIFY_WEBHOOK_SECRET
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    const topic = req.headers['x-shopify-topic']; // e.g., "orders/create"
    // Process webhook...
  }
);

Slack

import { verify } from 'webhook-verify';

app.post(
  '/webhook/slack',
  express.raw({ type: 'application/x-www-form-urlencoded' }),
  (req, res) => {
    // Slack requires both signature and timestamp - handled automatically
    const isValid = verify(
      'slack',
      req.body,
      req.headers,
      process.env.SLACK_SIGNING_SECRET
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // Process webhook...
  }
);

Twilio

import { verify } from 'webhook-verify';

app.post(
  '/webhook/twilio',
  express.urlencoded({ extended: false }),
  (req, res) => {
    // Twilio requires the full URL for verification
    const url = `https://${req.headers.host}${req.originalUrl}`;
    const isValid = verify(
      'twilio',
      req.body,
      req.headers,
      process.env.TWILIO_AUTH_TOKEN,
      { url }
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // Process webhook...
  }
);

Discord

import { verify } from 'webhook-verify';

app.post(
  '/webhook/discord',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // Discord uses Ed25519 - signature and timestamp handled automatically
    const isValid = verify(
      'discord',
      req.body,
      req.headers,
      process.env.DISCORD_PUBLIC_KEY
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // Process interaction...
  }
);

Svix / Clerk

import { verify } from 'webhook-verify';

app.post(
  '/webhook/clerk',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // Svix requires signature, timestamp, and message ID - handled automatically
    const isValid = verify(
      'clerk',
      req.body,
      req.headers,
      process.env.CLERK_WEBHOOK_SECRET
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    const messageId = req.headers['svix-id'];
    // Process webhook...
  }
);

GitLab

import { verify } from 'webhook-verify';

app.post('/webhook/gitlab', express.json(), (req, res) => {
  // GitLab uses token comparison (not signature)
  const isValid = verify(
    'gitlab',
    '',
    req.headers,
    process.env.GITLAB_WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).send('Invalid token');
  }

  const event = req.headers['x-gitlab-event']; // e.g., "Push Hook"
  // Process webhook...
});

Home Assistant

import { verify } from 'webhook-verify';

app.post('/webhook/homeassistant', express.json(), (req, res) => {
  // Home Assistant uses token comparison via X-HA-Secret header
  const isValid = verify(
    'homeassistant',
    '',
    req.headers,
    process.env.HA_SHARED_SECRET
  );

  if (!isValid) {
    return res.status(401).send('Invalid token');
  }

  const { entity_id, state } = req.body;
  // Process Home Assistant event...
});

Home Assistant configuration example:

rest_command:
  send_event:
    url: "https://your-webhook-url.com/ha/event"
    method: POST
    headers:
      X-HA-Secret: !secret webhook_secret
    content_type: "application/json"
    payload: '{"entity_id": "{{ entity_id }}", "state": "{{ state }}"}'

Crystallize

import { verify } from 'webhook-verify';

app.post(
  '/webhook/crystallize',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // Crystallize requires the full URL for verification
    const url = `https://${req.headers.host}${req.originalUrl}`;
    const isValid = verify(
      'crystallize',
      req.body,
      req.headers,
      process.env.CRYSTALLIZE_SIGNATURE_SECRET,
      { url }
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // Process webhook...
  }
);

Codehooks.io Example

import { app } from 'codehooks-js';
import { verify } from 'webhook-verify';

app.post('/webhook/stripe', async (req, res) => {
  // Pass headers directly - uses req.rawBody for verification
  const isValid = verify(
    'stripe',
    req.rawBody,
    req.headers,
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.rawBody);
  // Process event...

  res.json({ received: true });
});

export default app.init();

Options

Key Rotation (additionalSecrets)

When rotating webhook secrets, you need to accept both old and new secrets during a transition period. Use additionalSecrets to verify against multiple secrets:

// During secret rotation, accept both new and old secrets
verify('github', payload, headers, newSecret, {
  additionalSecrets: [oldSecret],
});

// Support multiple old secrets if needed
verify('stripe', payload, headers, currentSecret, {
  additionalSecrets: [previousSecret, olderSecret],
});

The primary secret is tried first. If verification fails, each additional secret is tried in order until one succeeds or all fail. This works with all providers.

Timestamp Tolerance

For providers that include timestamps (Stripe, Slack, Svix), you can customize the tolerance window:

// Allow signatures up to 10 minutes old (default is 5 minutes)
verify('stripe', payload, signature, secret, { tolerance: 600 });

Twilio URL

Twilio requires the full webhook URL for verification:

verify('twilio', payload, signature, secret, {
  url: 'https://example.com/webhook',
});

Crystallize URL

Crystallize requires the full webhook URL for verification:

verify('crystallize', payload, signature, secret, {
  url: 'https://example.com/webhook',
});

Square URL

Square requires the full webhook URL for verification:

verify('square', payload, signature, secret, {
  url: 'https://example.com/webhook',
});

HubSpot URL

HubSpot (v3) requires the full webhook URL and optionally the HTTP method:

verify('hubspot', payload, signature, secret, {
  url: 'https://example.com/webhook',
  method: 'POST', // optional, defaults to 'POST'
});

Generic Algorithm Handlers

For providers not explicitly supported, or for custom verification logic, use the generic handlers:

HMAC Verification

import { hmac } from 'webhook-verify';

// Basic HMAC-SHA256 verification (hex encoded)
hmac.verify(payload, signature, secret);

// With options
hmac.verify(payload, signature, secret, {
  algorithm: 'sha256', // 'sha1' | 'sha256' | 'sha512'
  encoding: 'hex', // 'hex' | 'base64'
  prefix: 'sha256=', // Strip prefix from signature
});

// Generate a signature
const sig = hmac.sign(payload, secret, { encoding: 'base64' });

HMAC with Timestamp

import { hmac } from 'webhook-verify';

// Stripe-style: signature over "timestamp.payload"
hmac.verifyWithTimestamp(payload, signature, secret, timestamp, {
  format: '{timestamp}.{payload}',
});

// Slack-style: signature over "v0:timestamp:payload"
hmac.verifyWithTimestamp(payload, signature, secret, timestamp, {
  format: 'v0:{timestamp}:{payload}',
  tolerance: 300, // Max age in seconds
});

Ed25519 Verification

import { ed25519 } from 'webhook-verify';

// Verify Ed25519 signature (e.g., Discord)
const message = timestamp + payload;
ed25519.verify(message, signature, publicKey);

RSA Verification

import { rsa } from 'webhook-verify';

// Verify RSA-SHA256 signature
rsa.verify(payload, signature, publicKey);

// With SHA1
rsa.verify(payload, signature, publicKey, { algorithm: 'RSA-SHA1' });

Utility Functions

import { timingSafeEqual, validateTimestamp } from 'webhook-verify';

// Timing-safe string comparison
timingSafeEqual(a, b);

// Validate timestamp freshness
validateTimestamp(timestamp, toleranceSeconds);

Custom Provider Example (Express)

import { hmac } from 'webhook-verify';

// Use express.raw() to get the raw body as a Buffer
app.post(
  '/webhook/custom',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-signature'];
    const timestamp = req.headers['x-timestamp'];

    // req.body is a Buffer when using express.raw()
    const isValid = hmac.verifyWithTimestamp(
      req.body,
      signature,
      process.env.WEBHOOK_SECRET,
      timestamp,
      {
        format: '{timestamp}:{payload}',
        algorithm: 'sha256',
        encoding: 'hex',
        tolerance: 300,
      }
    );

    if (!isValid) {
      return res.status(401).send('Invalid signature');
    }

    // Parse verified payload
    const data = JSON.parse(req.body.toString());
    // Process webhook...
  }
);

Custom Provider Example (Codehooks.io)

import { app } from 'codehooks-js';
import { hmac } from 'webhook-verify';

app.post('/webhook/custom', async (req, res) => {
  const signature = req.headers['x-signature'];
  const timestamp = req.headers['x-timestamp'];

  // Use req.rawBody for verification
  const isValid = hmac.verifyWithTimestamp(
    req.rawBody,
    signature,
    process.env.WEBHOOK_SECRET,
    timestamp,
    {
      format: '{timestamp}:{payload}',
      algorithm: 'sha256',
      encoding: 'hex',
      tolerance: 300,
    }
  );

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const data = JSON.parse(req.rawBody);
  // Process webhook...
});

export default app.init();

Unsupported Providers

Some providers use verification patterns that don't fit this library's synchronous model:

Provider Reason Alternative
PayPal Requires async certificate fetch and chain validation Use @paypal/paypal-server-sdk
AWS SNS Requires async certificate fetch Use aws-sdk

For these providers, we recommend using their official SDKs.

Security Notes

  • Always use the raw request body for verification (not parsed JSON)
  • Use timing-safe comparison (this library handles this automatically)
  • Keep webhook secrets secure and rotate them periodically
  • Validate timestamp freshness to prevent replay attacks

Requirements

  • Node.js >= 18.0.0 (required for native Ed25519 support)

About Codehooks.io

Codehooks.io is a serverless backend platform purpose-built for webhook integrations and event-driven automations. Deploy complete webhook handlers in minutes with built-in infrastructure—no need to assemble separate services.

Complete Webhook Infrastructure:

  • Built-in database - NoSQL document store for webhook events
  • Key-value store - Redis-like caching and state management
  • Job queues - Durable queues for async processing with automatic retries
  • Background workers - Cron jobs and scheduled tasks
  • Signature verification - Native req.rawBody support for HMAC validation

Developer Experience:

  • Instant deployment via CLI (coho deploy)
  • JavaScript/TypeScript with the codehooks-js library
  • Web-based Studio for code and data management
  • Flat-rate pricing with unlimited compute—no surprise bills

Get started for free | Documentation | Examples

Contributing

We welcome contributions, especially new provider implementations. Here's how to add support for a new webhook provider:

Adding a New Provider

  1. Create the provider file at src/providers/{provider}.ts:
import { createHmac } from 'crypto';
import { secureCompare } from '../utils/crypto.js';
import type { ProviderVerifier } from '../types.js';

export const myprovider: ProviderVerifier = {
  verify(payload, signature, secret, options) {
    // Implement verification logic
    // Return true if valid, false otherwise
  },
};
  1. Register the provider in src/providers/index.ts

  2. Add the provider type to the Provider union in src/types.ts

  3. Add header extraction in src/headers.ts (both providerHeaders and getHeaderNames)

  4. Add tests in test/verify.test.ts

  5. Document it in README.md (Supported Providers table + example if needed)

Provider Checklist

  • Uses timing-safe comparison for signatures
  • Handles both string and Buffer payloads
  • Returns false for invalid inputs (don't throw)
  • Includes timestamp validation if the provider uses it
  • Has test coverage with valid and invalid signatures

Running Tests

npm install
npm test
npm run build

Providers We'd Like to Add

  • Braintree
  • Customer.io
  • Postmark
  • Adyen
  • Coinbase
  • DocuSign
  • ...and many more!

Check the issues for provider requests or open one for a provider you'd like to see supported.

License

MIT


Made with care by Codehooks.io

About

Utilities to verify incoming webhook payloads

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors