npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@teamshift/webhook-inspect

v0.1.0

Published

Verify, explain and debug webhook signatures (Stripe, GitHub, Shopify, Slack, Twilio, generic HMAC) with a browser-safe WebCrypto core, plus a local capture server and replay CLI.

Readme

@teamshift/webhook-inspect

webhook-inspect is an open-source tool that verifies Stripe, GitHub, Shopify, Slack, Twilio and generic HMAC webhook signatures, explains what each event is, and tells you exactly why a signature check fails.

It has three parts:

  • A browser-safe core built on WebCrypto (globalThis.crypto.subtle), so the same code runs in Node.js 20+, browsers, Deno, Bun and edge runtimes.
  • A local capture server that prints and saves every request.
  • A replay command for re-sending captured events to your app. It can re-sign them with a fresh timestamp.

Try it in your browser: teamshift.io/tools/webhook-inspect. Paste headers, body and secret; verification happens client-side.

Quickstart

# Verify one request (prints the verdict, a diagnosis, and an explanation of the event)
npx @teamshift/webhook-inspect verify --provider stripe --secret whsec_... \
  --header 't=1760000000,v1=5257a8...' --body-file body.json

# Capture webhooks locally: prints headers, verification and explanation, saves to .webhook-inspect/
npx @teamshift/webhook-inspect listen --port 8787 --secret whsec_...

# Re-send a captured event to your app, re-signed with a fresh timestamp
npx @teamshift/webhook-inspect replay .webhook-inspect/2026-10-04T01-20-39-562Z-001-stripe.json \
  --to http://localhost:3000/api/webhooks/stripe --resign --secret whsec_...

--header accepts a bare signature value (for --provider's signature header) or Name: value, and can be repeated. The secret can also come from WEBHOOK_SECRET. verify exits 0 when the signature verifies and 1 when it doesn't.

Example output

A valid Stripe event:

  signature  ✓ verified (stripe, stripe-signature)
  event      Stripe payment_intent.succeeded (evt_3PqTest0001) for payment_intent pi_3PqTest0001 — 125.00 USD status succeeded [test mode]
    event            payment_intent.succeeded
    event id         evt_3PqTest0001
    mode             test
    object           payment_intent pi_3PqTest0001
    amount           125.00 USD
    customer         cus_QTest0001
    hint: Respond 2xx quickly and process asynchronously; Stripe retries non-2xx responses for up to 3 days in live mode.
    hint: Deduplicate on the event id (evt_3PqTest0001); Stripe can deliver the same event more than once.

The same request after a framework parsed the JSON and re-serialized it (the most common cause of "No signatures found matching the expected signature"):

  signature  ✗ signature_mismatch  No v1 signature matches HMAC-SHA256(secret, `${t}.${rawBody}`).
    confirmed: The signature matches when the JSON is restored to 2-space indentation and a trailing newline: the body was parsed and re-serialized before verification.
      → Verify against the raw request body before any JSON parsing or re-serialization (express.raw({type: 'application/json'}), await request.text(), Next.js route `await req.text()`).

listen prints one block per request and saves it as JSON:

── #1 POST /stripe/webhook  2026-10-04T01:20:39.562Z  stripe ────────
  content-type                 application/json
  stripe-signature             t=1791076839,v1=0157ef76d4f7f36870db90e758e2264c9c11e5bed3d9514590269f5344e2851a
  user-agent                   Stripe/1.0 (+https://stripe.com/docs/webhooks)
  body       380 bytes
  signature  ✓ verified (stripe, stripe-signature)
  event      Stripe payment_intent.succeeded (evt_3PqTest0001) for payment_intent pi_3PqTest0001 — 125.00 USD status succeeded [test mode]
  saved      .webhook-inspect/2026-10-04T01-20-39-562Z-001-stripe.json

Providers

| Provider | Header(s) | Algorithm | Signed content | Replay window | |---|---|---|---|---| | Stripe | Stripe-Signature: t=…,v1=… | HMAC-SHA256, hex | ${t}.${rawBody}; the key is the full whsec_… secret | 300 s (configurable) | | GitHub | X-Hub-Signature-256: sha256=… | HMAC-SHA256, hex | raw body | — | | Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256, base64 | raw body, keyed with the app's client secret | — | | Slack | X-Slack-Signature: v0=…, X-Slack-Request-Timestamp | HMAC-SHA256, hex | v0:${timestamp}:${rawBody} | 300 s (configurable) | | Twilio | X-Twilio-Signature | HMAC-SHA1, base64 | full URL + form params sorted by name (key+value); for JSON bodies, the URL with bodySHA256 plus a SHA-256 body check | — | | Generic | configurable (default X-Signature) | HMAC-SHA1/256/512, hex or base64 | raw body; sha256=-style prefixes stripped | — |

detectProvider(headers) picks the provider from these headers automatically.

What diagnose() checks

When verification fails, webhook-inspect tries concrete fixes. If a fix makes the signature verify, the cause is reported as confirmed.

| Code | Meaning | |---|---| | body_reserialized | The signature matches the JSON at its original formatting, so your framework parsed and re-serialized the body | | body_newline | A trailing newline or CRLF/LF change broke the signature | | body_compact_json | A Stripe body arrived compact, though Stripe sends pretty-printed JSON | | empty_body | The body stream was consumed before verification | | secret_whitespace | A newline, spaces or quotes in the secret (typical of .env files) | | secret_prefix | The whsec_ prefix was stripped (Stripe uses the whole string) | | secret_encoding | The sender expects the secret decoded from base64 or hex | | wrong_secret_type | An API key or OAuth token was used instead of the signing secret (sk_…, xoxb-…, shpat_…, an Account SID, …) | | timestamp_skew | The signature is valid but outside the replay window: a replayed request or clock skew | | timestamp_milliseconds | The timestamp is in milliseconds instead of seconds | | url_mismatch | Twilio: the signature matches a different scheme, host, port or path (proxy rewrite) | | wrong_provider / legacy_header / missing_header / malformed_header | Header problems, e.g. a GitHub request with only the SHA-1 header, or a Stripe header without t=/v1= | | encoding_mismatch | Hex digest where base64 is expected (Shopify) |

Library use

import { verify, diagnose, explain, detectProvider, sign } from "@teamshift/webhook-inspect";

const rawBody = await request.text(); // raw bytes, never JSON.stringify(req.body)
const result = await verify({ provider: "stripe", secret: env.STRIPE_WEBHOOK_SECRET, headers: request.headers, body: rawBody });
if (!result.ok) {
  const { diagnoses } = await diagnose({ provider: "stripe", secret: env.STRIPE_WEBHOOK_SECRET, headers: request.headers, body: rawBody });
  console.warn(result.code, diagnoses.map((d) => d.message));
}
console.log(explain("stripe", rawBody).summary);

headers accepts a Fetch Headers instance, a plain object such as Node's req.headers, or an array of [name, value] pairs. verify() never throws on bad input: it returns { ok: false, code, reason }. Comparisons are constant-time.

FAQ

How do I verify a Stripe webhook signature?

Read the Stripe-Signature header (t=<timestamp>,v1=<signature>) and compute HMAC-SHA256 over ${t}.${rawBody}, keyed with your endpoint's full whsec_… secret. Compare the hex result to each v1 value in constant time, and reject timestamps older than your tolerance (Stripe's libraries default to 300 seconds). webhook-inspect verify --provider stripe does exactly this, and verify() does it in code.

Why does my webhook signature verification fail?

Usually for one of three reasons:

  1. The body was changed before verification. A body parser turned it into an object and back (JSON.stringify changes whitespace), or a newline was added.
  2. The secret is wrong. It comes from a different endpoint or environment, a stripe listen session that has its own secret, an API key used instead of the signing secret, or a secret with a trailing newline from .env.
  3. The timestamp is outside the replay window. The request was replayed or the server clock is off.

webhook-inspect verify tests each of these and marks the confirmed cause.

How do I get the raw request body in Express or Next.js?

In Express, mount express.raw({ type: "application/json" }) on the webhook route, before express.json(). In Next.js route handlers, Remix, Hono, Cloudflare Workers and Deno, call await request.text() before anything parses the body. Verify that exact string, and only then JSON.parse it.

How do I verify a GitHub webhook?

Compute sha256= + hex HMAC-SHA256 of the raw body, keyed with your webhook secret, and compare it to X-Hub-Signature-256 in constant time. The older X-Hub-Signature header uses SHA-1. GitHub recommends the SHA-256 header.

How do I verify a Shopify webhook HMAC?

Compute base64 HMAC-SHA256 of the raw body, keyed with your app's client secret (not the access token), and compare it to X-Shopify-Hmac-Sha256.

How do I verify Slack request signatures?

Build the base string v0:{X-Slack-Request-Timestamp}:{rawBody} and compute HMAC-SHA256 with your app's Signing Secret (not the bot token). Compare v0=<hex> to X-Slack-Signature, and reject timestamps older than five minutes.

Why does Twilio signature validation fail behind a proxy or load balancer?

Twilio signs the exact URL it requested, including the scheme, host, port and query string. If a proxy terminates TLS or rewrites the host, your app sees a different URL. Validate against the public URL; diagnose() tries common variants and reports the one that matches.

How do I replay a webhook to my local server?

Capture it with webhook-inspect listen (point the provider or a tunnel at it), then run webhook-inspect replay <file> --to http://localhost:3000/hook. Add --resign --secret … so Stripe and Slack timestamps fall inside the tolerance window again.

Does it work in the browser or on edge runtimes?

Yes. The core uses only WebCrypto, TextEncoder, URL and URLSearchParams, with no Node.js built-ins. The CLI is the only part that uses Node.js.

Related tools

  • workflow-lint: flags webhook triggers that never verify signatures (rule WL008), plus other workflow risks.
  • permission-planner: minimum OAuth scopes and approval policy for agent actions.

Built by TeamShift — AI workers for small-business operations.