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

@puntoycoma/paddlehook

v1.4.1

Published

Lightweight Paddle webhook verification and proxy for any edge runtime

Readme

@puntoycoma/paddlehook

npm version npm bundle size CI License: MIT

Typed Paddle webhook verification for any edge runtime — HMAC-SHA256 signatures, replay protection, and fully typed events in 3.34 KB with zero runtime dependencies.

Why paddlehook?

  • Secure — HMAC-SHA256 signature verification via crypto.subtle.verify() (Web Crypto API, constant-time comparison) with built-in replay attack protection
  • Typed events — Discriminated union of all Paddle billing event types; TypeScript narrows event.data automatically per event
  • Tiny — 3.34 KB minified, zero runtime dependencies, no supply chain risk
  • Universal — Works on Cloudflare Workers, Supabase Edge Functions, Deno, Bun, Vercel Edge, Netlify Edge, Hono, and Node.js 18+
  • Flexible — Proxy mode for instant setup; onVerified callback for queues, databases, or any custom Paddle payments processing
  • Filterable — Pass an events array to ignore event types your app doesn't care about

Architecture

Paddle billing platform
        │
        │  POST /webhook
        │  Paddle-Signature: ts=…;h1=…
        ▼
┌────────────────────┐
│    paddlehook      │  ← HMAC-SHA256 verify + replay check
│                    │
│  proxy mode        │  → forwards raw body to TARGET_URL
│  onVerified mode   │  → calls your handler with typed PaddleWebhookEvent
└────────────────────┘
        │
        ▼
  Your backend / queue / database

Install

npm install @puntoycoma/paddlehook
# or
bun add @puntoycoma/paddlehook
# or
pnpm add @puntoycoma/paddlehook

Quick Start

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

export default {
  fetch: createPaddleWebhookHandler(),
}

Set three environment variables and Paddle webhook verification is live:

| Variable | Description | |----------|-------------| | PADDLE_WEBHOOK_SECRET | Signing secret from Paddle Dashboard > Developer Tools > Notifications | | TARGET_URL | Your backend endpoint (e.g. https://api.example.com/webhooks/paddle) | | INTERNAL_AUTH_TOKEN | Your secret token (plain, without Bearer prefix). Sent to your backend as Authorization: Bearer <token> automatically. |

Two Modes

Proxy mode (default)

Verifies the Paddle webhook signature, then forwards the raw body to your backend with a Bearer token. Your backend receives the same JSON Paddle sent — no transformation.

Use proxy mode when your backend already handles Paddle event logic and you just need a verified relay at the edge.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

// Requires: PADDLE_WEBHOOK_SECRET, TARGET_URL, INTERNAL_AUTH_TOKEN
export default {
  fetch: createPaddleWebhookHandler(),
}

Custom mode (onVerified)

Verifies the signature, parses the payload into a typed PaddleWebhookEvent, then calls your function. You own the response — enqueue, store, process inline, anything.

Use custom mode when you want to react to Paddle payments events directly at the edge: push to a queue, write to a database, or run conditional logic based on event type.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

// Only requires: PADDLE_WEBHOOK_SECRET
const handler = createPaddleWebhookHandler({
  onVerified: (event, env) => {
    // event is a fully typed PaddleWebhookEvent — no JSON.parse needed
    console.log(event.event_type, event.data)
    return new Response(null, { status: 202 })
  },
})

export default { fetch: handler }

Typed Events

PaddleWebhookEvent is a discriminated union — TypeScript narrows event.data automatically when you check event.event_type.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"
import type { PaddleWebhookEvent, SubscriptionData, TransactionData } from "@puntoycoma/paddlehook"

const handler = createPaddleWebhookHandler({
  onVerified: (event) => {
    switch (event.event_type) {
      case "subscription.activated":
      case "subscription.canceled":
      case "subscription.updated": {
        // event.data is SubscriptionData here
        const sub = event.data as SubscriptionData
        console.log(sub.id, sub.status, sub.customer_id)
        break
      }

      case "transaction.completed":
      case "transaction.paid": {
        // event.data is TransactionData here
        const tx = event.data as TransactionData
        console.log(tx.id, tx.status, tx.customer_id)
        break
      }

      default: {
        // All other Paddle billing events — log and acknowledge
        console.log("unhandled event:", event.event_type)
      }
    }

    return new Response(null, { status: 200 })
  },
})

Filter to specific events

Use the events option to tell paddlehook which Paddle event types to process. Other event types receive a 200 { ok: true, skipped: true } response immediately — no work done, Paddle stays happy.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

const handler = createPaddleWebhookHandler({
  events: ["subscription.activated", "subscription.canceled", "transaction.completed"],
  onVerified: (event) => {
    // Only called for the three event types above
    return new Response(null, { status: 200 })
  },
})

PaddleEventType covers all documented Paddle events — your editor will autocomplete valid values.

Runtime Examples

Cloudflare Workers

The runtime injects env per request automatically — no setup beyond the handler.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

export default {
  fetch: createPaddleWebhookHandler(),
}

Supabase Edge Functions / Deno / Netlify Edge

All Deno-based runtimes read env vars with Deno.env.get().

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

const env = {
  PADDLE_WEBHOOK_SECRET: Deno.env.get("PADDLE_WEBHOOK_SECRET")!,
  TARGET_URL: Deno.env.get("TARGET_URL")!,
  INTERNAL_AUTH_TOKEN: Deno.env.get("INTERNAL_AUTH_TOKEN")!,
}

const handler = createPaddleWebhookHandler()

Deno.serve((request) => handler(request, env))

Bun / Node.js 18+ / Vercel Edge

All process.env runtimes follow the same pattern.

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

const env = {
  PADDLE_WEBHOOK_SECRET: process.env.PADDLE_WEBHOOK_SECRET!,
  TARGET_URL: process.env.TARGET_URL!,
  INTERNAL_AUTH_TOKEN: process.env.INTERNAL_AUTH_TOKEN!,
}

const handler = createPaddleWebhookHandler()

// Bun
Bun.serve({ fetch: (req) => handler(req, env) })

// Node.js 18+
import { serve } from "@hono/node-server" // or any http adapter
serve({ fetch: (req) => handler(req, env) })

// Vercel Edge
export default (req: Request) => handler(req, env)
export const config = { runtime: "edge" }

Hono (any runtime)

Hono runs on Cloudflare Workers, Deno, Bun, Node.js — anywhere Hono runs, paddlehook works.

import { Hono } from "hono"
import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

const app = new Hono()
const handler = createPaddleWebhookHandler()

app.post("/webhook/paddle", (c) =>
  handler(c.req.raw, {
    PADDLE_WEBHOOK_SECRET: c.env.PADDLE_WEBHOOK_SECRET,
    TARGET_URL: c.env.TARGET_URL,
    INTERNAL_AUTH_TOKEN: c.env.INTERNAL_AUTH_TOKEN,
  })
)

export default app

Using onVerified

Enqueue to a queue system

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

// Cloudflare Queue — env is typed to include the binding
const handler = createPaddleWebhookHandler<Env>({
  onVerified: (event, env) => {
    // event is already parsed — send it directly to the queue
    env.PADDLE_QUEUE.send(event)
    return new Response(null, { status: 202 })
  },
})

// Same pattern for AWS SQS, Redis, BullMQ, Upstash, etc.
// Verification happens first; your callback only runs on valid Paddle payloads.

Custom event processing

import { createPaddleWebhookHandler } from "@puntoycoma/paddlehook"

const handler = createPaddleWebhookHandler({
  events: ["subscription.canceled", "subscription.updated"],
  onVerified: async (event, env) => {
    // No JSON.parse needed — event is already a typed PaddleWebhookEvent
    if (event.event_type === "subscription.canceled") {
      await db.subscriptions.update({
        where: { paddleId: event.data.id },
        data: { status: "canceled" },
      })
    }

    return new Response(null, { status: 200 })
  },
})

Low-level: verifyPaddleSignature

Use the verification function directly if you manage your own request lifecycle.

import { verifyPaddleSignature } from "@puntoycoma/paddlehook"

const isValid = await verifyPaddleSignature(
  request.headers.get("paddle-signature"),
  await request.text(),
  env.PADDLE_WEBHOOK_SECRET,
  { maxAge: 300 } // optional — default 300s, set 0 to disable replay protection
)

if (!isValid) {
  return new Response("Unauthorized", { status: 401 })
}

Response Mapping (proxy mode)

| Backend response | paddlehook returns | Paddle behavior | |------------------|--------------------|-----------------| | 2xx | 200 | Success, no retry | | 4xx | 400 | Client error, no retry | | 5xx | 500 | Server error, Paddle retries | | Unreachable | 502 | Gateway error, Paddle retries |

API Reference

createPaddleWebhookHandler(options?)

Factory that creates a Paddle webhook handler for any edge runtime. Verifies HMAC-SHA256 signatures and either proxies the payload to your backend or calls your onVerified callback.

const handler = createPaddleWebhookHandler<TEnv>(options?)
// Returns: (request: Request, env: TEnv) => Promise<Response>

| Parameter | Type | Description | |-----------|------|-------------| | options.events | PaddleEventType[] | Optional. Only call onVerified for these event types. Others receive 200 { ok: true, skipped: true }. | | options.onVerified | (event: PaddleWebhookEvent, env: TEnv) => Response \| Promise<Response> | Optional. Custom handler called after successful verification. Omit to use proxy mode. |

verifyPaddleSignature(header, rawBody, secret, options?)

Low-level Paddle webhook HMAC-SHA256 signature verification using the Web Crypto API.

| Parameter | Type | Description | |-----------|------|-------------| | header | string \| null | Paddle-Signature header value | | rawBody | string | Raw request body (unparsed string) | | secret | string | Paddle webhook signing secret | | options.maxAge | number | Max signature age in seconds. Default 300. Set 0 to disable replay protection. |

Returns Promise<boolean>.

Types

// Environment shapes
interface PaddleBaseEnv {
  PADDLE_WEBHOOK_SECRET: string
}

interface PaddleWorkerEnv extends PaddleBaseEnv {
  TARGET_URL: string
  INTERNAL_AUTH_TOKEN: string
}

// Handler options
interface HandlerOptions<TEnv extends PaddleBaseEnv> {
  events?: PaddleEventType[]
  onVerified?: (event: PaddleWebhookEvent, env: TEnv) => Response | Promise<Response>
}

// Verify options
interface VerifyOptions {
  maxAge?: number
}

// Event envelope — discriminated union on event_type
type PaddleWebhookEvent =
  | PaddleEvent<"subscription.activated", SubscriptionData>
  | PaddleEvent<"subscription.canceled", SubscriptionData>
  | PaddleEvent<"subscription.created", SubscriptionData>
  | PaddleEvent<"subscription.updated", SubscriptionData>
  | PaddleEvent<"transaction.completed", TransactionData>
  | PaddleEvent<"transaction.paid", TransactionData>
  | PaddleEvent<"customer.created", CustomerData>
  | PaddleEvent<"adjustment.created", AdjustmentData>
  // ... all Paddle billing event types

// Available data types
// SubscriptionData, TransactionData, CustomerData, AdjustmentData

All types are exported from @puntoycoma/paddlehook.

Security

  • HMAC-SHA256 verification via crypto.subtle.verify() — constant-time comparison, no timing attacks
  • Replay protection — rejects signatures older than 5 minutes by default (configurable via maxAge)
  • Method guard — non-POST requests are rejected with 405 before any processing
  • Zero runtime dependencies — no third-party code executes in your edge function
  • npm provenance — published with attestation for verifiable, auditable builds

License

MIT — free to use, modify, and distribute. See LICENSE for details.

Contributing

Issues and PRs welcome at github.com/PuntoyComaTech/paddlehook.

bun install     # install dependencies
bun test        # run tests
bun run build   # build for production

Developed by PuntoyComaTech

Instagram X YouTube TikTok LinkedIn

A lightweight, type-safe Paddle webhook handler for edge runtimes.