Skip to content

ralphilius/askrift

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Áskrift

A TypeScript/JavaScript utilities library for validating and handling subscription webhooks from popular payment providers.

Áskrift means "subscription" in Icelandic.

Installation

npm install @ralphilius/askrift

Quick start

import express from 'express';
import { initialize, fromExpress } from '@ralphilius/askrift';

const app = express();

app.post('/webhooks/paddle', express.urlencoded({ extended: false }), async (req, res) => {
  const askrift = initialize('paddle', fromExpress(req));

  askrift.on('subscription.created', async (payload, event) => {
    console.log('New subscription:', payload.subscription_id);
    console.log('Provider event:', event.providerEventType);
  });

  askrift.on('payment.succeeded', async (payload) => {
    console.log('Payment succeeded:', payload.subscription_payment_id);
  });

  const result = await askrift.handle();

  if (!result.verified) return res.status(403).json(result);
  if (!result.handled) return res.status(500).json(result);

  return res.status(200).json(result);
});

Supported providers

Provider Status Signature verification Environment variables
Paddle Classic Supported RSA/SHA1 via p_signature PADDLE_PUBLIC_KEY
Paddle Billing Supported HMAC/SHA256 via Paddle-Signature PADDLE_BILLING_WEBHOOK_SECRET
Stripe Supported HMAC/SHA256 via Stripe-Signature STRIPE_WEBHOOK_SECRET
Gumroad Supported HMAC/SHA256 via X-Gumroad-Signature (optional) GUMROAD_WEBHOOK_SECRET
Lemon Squeezy Supported HMAC/SHA256 via X-Signature LEMONSQUEEZY_SIGNING_SECRET
Polar Supported HMAC/SHA256 (base64) via Polar-Signature POLAR_WEBHOOK_SECRET

Provider support matrix

Normalized event Paddle Classic Paddle Billing Stripe Gumroad Lemon Squeezy Polar
subscription.created subscription_created subscription.created customer.subscription.created sale subscription_created subscription.created
subscription.updated subscription_updated subscription.updated customer.subscription.updated subscription_updated subscription_updated subscription.updated
subscription.cancelled subscription_cancelled subscription.canceled customer.subscription.deleted subscription_ended subscription_expired subscription.revoked
subscription.paused subscription_paused
payment.succeeded subscription_payment_succeeded transaction.completed invoice.payment_succeeded sale subscription_payment_success order.paid
payment.failed subscription_payment_failed transaction.payment_failed invoice.payment_failed dispute subscription_payment_failed subscription.past_due
payment.refunded subscription_payment_refunded transaction.refunded refund order_refunded order.refunded

API overview

initialize(type, request, options?)

Creates a typed provider instance.

Argument Type Description
type 'paddle' | 'paddle-classic' | 'paddle-billing' | 'stripe' | 'gumroad' | 'lemon-squeezy' | 'polar' Provider key.
request InternalRequest Request object with method, headers, and body. Use an adapter helper to convert from your framework.
options InitializeOptions | boolean Provider configuration (see below). Pass true as a shorthand for { debug: true }.

Returns a provider instance that implements the Askrift interface.

Askrift interface

Every provider instance exposes these methods:

Method Returns Description
validRequest() boolean Checks HTTP method and content type.
validPayload(options?) boolean Verifies the webhook signature.
on(eventName, handler) this Registers an event handler for the dispatcher API.
handle() Promise<AskriftHandleResult> Validates, verifies, parses, and dispatches.
onSubscriptionCreated() Promise<Payload | null> Returns payload if the current event matches.
onSubscriptionUpdated() Promise<Payload | null> Returns payload if the current event matches.
onSubscriptionCanceled() Promise<Payload | null> Returns payload if the current event matches.
onPaymentSucceeded() Promise<Payload | null> Returns payload if the current event matches.
onPaymentFailed() Promise<Payload | null> Returns payload if the current event matches.
onPaymentRefunded() Promise<Payload | null> Returns payload if the current event matches.
getIdempotencyKey() string | null Provider-prefixed stable event ID (e.g. paddle:120661188).
getEventTimestamp() Date | null Parsed event timestamp from the webhook payload.
debug(msg, ...args) void Logs only when debug mode is enabled.

AskriftHandleResult

The return type of handle():

{
  verified: boolean;   // true if request + payload validation passed
  handled: boolean;    // true if at least one handler ran without throwing
  eventType?: string;  // the matched event type
  errors?: Error[];    // any errors thrown by handlers
}

Adapter helpers

Áskrift doesn't import Express, Vercel, or any framework types. Instead, every provider expects the same internal request shape:

type InternalRequest = {
  method: string;
  headers: Record<string, string | string[] | undefined>;
  body: unknown;
  rawBody?: Buffer | string;
}

Adapters convert your framework's request object into this shape. All adapters normalize header names to lowercase.

Adapter For Usage
fromExpress(req) Express Request initialize('paddle', fromExpress(req))
fromVercel(req) Vercel / Next.js Pages API NextApiRequest initialize('stripe', fromVercel(req))
fromRaw(obj) Anything else (Hono, Fastify, Bun, Deno, Cloudflare Workers, etc.) See below

fromRaw is the catch-all. Use it when your framework doesn't have a dedicated adapter — just pass a plain object with method, headers, and body. It lowercases the header names and that's it.

Event dispatcher API

Register handlers by event name and call handle() to validate, verify, and dispatch in one step:

const askrift = initialize('stripe', fromExpress(req));

askrift.on('subscription.created', async (payload, ctx) => {
  // ctx.eventType — normalized event name
  // ctx.providerEventType — provider-specific event name
  // ctx.matchedEventName — the name that matched this handler
  // ctx.provider — provider identifier
});

askrift.on('customer.subscription.created', async (payload) => {
  // Provider-specific event names also work
});

const result = await askrift.handle();

Event names are case-insensitive. Both normalized (subscription.created) and provider-specific (customer.subscription.created, paddle.subscription_payment_succeeded) names are supported. Alias matching means a handler registered for subscription.created fires for any provider's subscription-created event.

Provider setup

Paddle

Paddle has two webhook formats. Áskrift auto-detects which one based on the payload:

Paddle Classic — RSA/SHA1 signature via p_signature field.

PADDLE_PUBLIC_KEY="your-paddle-public-key-without-BEGIN-END-lines"

Paddle Billing — HMAC/SHA256 signature via Paddle-Signature header.

PADDLE_BILLING_WEBHOOK_SECRET="your-paddle-billing-webhook-secret"

To force a specific variant:

const askrift = initialize('paddle-classic', fromExpress(req)); // Classic only
const askrift = initialize('paddle-billing', fromExpress(req)); // Billing only
const askrift = initialize('paddle', fromExpress(req));         // Auto-detect

Configuration options:

const askrift = initialize('paddle', fromExpress(req), {
  publicKey: process.env.PADDLE_PUBLIC_KEY,       // Classic RSA key
  billingSecret: process.env.PADDLE_BILLING_SECRET, // Billing HMAC secret
  debug: true,
});

Stripe

Stripe uses HMAC/SHA256 signature verification via the Stripe-Signature header.

STRIPE_WEBHOOK_SECRET="whsec_your-signing-secret"
const askrift = initialize('stripe', fromExpress(req), {
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
});

Stripe sends JSON bodies. Your route should accept application/json:

app.post('/webhooks/stripe', express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }), async (req, res) => {
  const askrift = initialize('stripe', fromExpress(req));
  // ...
});

Note: Stripe requires rawBody for signature verification. When using Express, pass rawBody via the verify callback in express.json().

Gumroad

Gumroad uses optional HMAC/SHA256 signature verification via the X-Gumroad-Signature header.

GUMROAD_WEBHOOK_SECRET="your-gumroad-secret"
const askrift = initialize('gumroad', fromExpress(req), {
  debug: true,
});

By default, Gumroad payloads without a signature header pass verification. To require signatures:

const askrift = initialize('gumroad', fromExpress(req), {
  requireSignature: true,
});

Lemon Squeezy

Lemon Squeezy uses HMAC/SHA256 signature verification via the X-Signature header.

LEMONSQUEEZY_SIGNING_SECRET="your-lemon-squeezy-signing-secret"
const askrift = initialize('lemon-squeezy', fromExpress(req));

Polar

Polar uses HMAC/SHA256 (base64) signature verification via the Polar-Signature header.

POLAR_WEBHOOK_SECRET="your-polar-webhook-secret"
const askrift = initialize('polar', fromExpress(req));

Framework integration examples

Express

import express from 'express';
import { initialize, fromExpress } from '@ralphilius/askrift';

const app = express();

app.post('/webhooks/paddle', express.urlencoded({ extended: false }), async (req, res) => {
  const askrift = initialize('paddle', fromExpress(req));

  if (!askrift.validRequest()) return res.status(400).send('Invalid request');
  if (!askrift.validPayload()) return res.status(403).send('Invalid signature');

  const created = await askrift.onSubscriptionCreated();
  if (created) {
    // Provision subscription
  }

  const payment = await askrift.onPaymentSucceeded();
  if (payment) {
    // Record successful payment
  }

  return res.status(200).send('ok');
});

Next.js App Router (app/api/.../route.ts)

import { initialize } from '@ralphilius/askrift';

export async function POST(request: Request) {
  const rawBody = await request.text();
  const headers = Object.fromEntries(request.headers.entries());

  const askrift = initialize('paddle', {
    method: 'POST',
    headers,
    body: rawBody,
    rawBody,
  });

  if (!askrift.validRequest()) return new Response('Bad request', { status: 400 });
  if (!askrift.validPayload()) return new Response('Forbidden', { status: 403 });

  const payment = await askrift.onPaymentSucceeded();
  if (payment) {
    // Handle payment
  }

  return new Response('ok', { status: 200 });
}

Next.js Pages API (pages/api/...)

import type { NextApiRequest, NextApiResponse } from 'next';
import { initialize, fromVercel } from '@ralphilius/askrift';

export const config = { api: { bodyParser: false } };

async function readRawBody(req: NextApiRequest): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks).toString('utf8');
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const rawBody = await readRawBody(req);
  req.body = rawBody;

  const askrift = initialize('stripe', fromVercel(req));
  // ...
}

Vercel serverless function

import type { VercelRequest, VercelResponse } from '@vercel/node';
import { initialize } from '@ralphilius/askrift';

export default async function handler(req: VercelRequest, res: VercelResponse) {
  const askrift = initialize('stripe', req);

  askrift.on('subscription.created', async (payload) => {
    // Provision subscription
  });

  const result = await askrift.handle();
  return res.status(result.verified && result.handled ? 200 : result.verified ? 500 : 403).json(result);
}

Any other framework (using fromRaw)

If your framework isn't Express or Vercel, use fromRaw. It accepts any plain object with method, headers, and body — it just lowercases the header names. That's it.

import { initialize, fromRaw } from '@ralphilius/askrift';

// Bun.serve
const askrift = initialize('stripe', fromRaw({
  method: req.method,
  headers: Object.fromEntries(req.headers),
  body: await req.text(),
}));

// Hono
const askrift = initialize('polar', fromRaw({
  method: c.req.method,
  headers: Object.fromEntries(c.req.raw.headers),
  body: await c.req.text(),
}));

// Cloudflare Workers
const askrift = initialize('paddle', fromRaw({
  method: request.method,
  headers: Object.fromEntries(request.headers),
  body: await request.text(),
}));

Idempotency and replay protection

Webhook providers retry delivery. Use the idempotency key to process each event exactly once.

Getting the key

const askrift = initialize('paddle', fromExpress(req));
const key = askrift.getIdempotencyKey();
// Paddle alert_id "120661188" becomes "paddle:120661188"

On normalized event payloads

const payment = await askrift.onPaymentSucceeded();
if (payment) {
  const key = payment.getIdempotencyKey();
  const timestamp = payment.getEventTimestamp();
  const fresh = payment.isFresh({ maxAgeMs: 5 * 60 * 1000 });
}

Standalone helper

import { extractStableEventId } from '@ralphilius/askrift';

const eventId = extractStableEventId('paddle', req.body);

Implementing idempotent handlers

Use any store that supports atomic create-if-not-exists:

const payment = await askrift.onPaymentSucceeded();
if (!payment) return;

const key = payment.getIdempotencyKey();
const ttlSeconds = 60 * 60 * 24 * 7;

// Database (UNIQUE constraint)
try {
  await db.webhookDeliveries.create({ data: { key } });
} catch {
  return; // Already handled
}

// Redis (SET NX EX)
const reserved = await redis.set(key, '1', { NX: true, EX: ttlSeconds });
if (!reserved) return;

// Cloudflare KV
const created = await kv.set(key, '1', { ifNotExists: true, expirationTtl: ttlSeconds });
if (!created) return;

await provisionSubscription(payment);

Event freshness validation

Reject stale webhook deliveries to guard against replay attacks:

const askrift = initialize('paddle', fromExpress(req));

if (!askrift.validRequest()) return res.status(400).end();

if (!askrift.validPayload({ maxAgeMs: 5 * 60 * 1000 })) {
  return res.status(403).end(); // Invalid signature or event too old
}

Normalized event types

Normalized events include provider-agnostic fields:

import type { NormalizedSubscriptionCreatedEvent } from '@ralphilius/askrift';

// Common fields on all normalized events:
// type, provider, raw, eventId, occurredAt, subscriptionId,
// subscriptionPlanId, customerId, customerEmail, currency, status,
// subscriptionStatus, previousSubscriptionStatus, paymentStatus

// SubscriptionCreated also has: nextBillDate
// SubscriptionUpdated also has: nextBillDate, previousStatus, previousSubscriptionPlanId
// SubscriptionCancelled also has: cancellationEffectiveDate
// SubscriptionPaused also has: resumesAt
// PaymentSucceeded also has: paymentId, orderId, amount, nextBillDate, receiptUrl
// PaymentFailed also has: paymentId, orderId, amount, nextRetryDate, attemptNumber
// PaymentRefunded also has: paymentId, orderId, amount, refundType, refundReason

The library also exports SubscriptionStatus and PaymentStatus enums for mapping provider-specific statuses to normalized values:

import { SubscriptionStatus, PaymentStatus } from '@ralphilius/askrift';

// SubscriptionStatus: Active, Trialing, PastDue, Paused, Canceled, Unpaid, Incomplete, Expired, Pending, Unknown
// PaymentStatus: Paid, Pending, Failed, Refunded, PartiallyRefunded, Canceled, RequiresAction, Unknown

Configuration reference

Options per provider

Option Providers Description
publicKey Paddle Classic, Stripe, Gumroad, Lemon Squeezy, Polar Public key or signing secret. Falls back to env var.
billingSecret Paddle Billing HMAC webhook secret. Falls back to PADDLE_BILLING_WEBHOOK_SECRET.
webhookSecret Stripe Webhook signing secret. Falls back to STRIPE_WEBHOOK_SECRET.
requireSignature Gumroad When true, rejects payloads without a signature header. Default: false.
debug All Enables debug logging.
kind Paddle Force 'paddle', 'paddle-classic', or 'paddle-billing'. Set internally by initialize().

Environment variables

Variable Provider Required
PADDLE_PUBLIC_KEY Paddle Classic Yes (if using Classic)
PADDLE_BILLING_WEBHOOK_SECRET Paddle Billing Yes (if using Billing)
STRIPE_WEBHOOK_SECRET Stripe Yes
GUMROAD_WEBHOOK_SECRET Gumroad Only if requireSignature: true
LEMONSQUEEZY_SIGNING_SECRET Lemon Squeezy Yes
POLAR_WEBHOOK_SECRET Polar Yes

Security

  • Always call validRequest() and validPayload() before trusting webhook data.
  • Keep secrets in server-side environment variables only.
  • Return non-2xx responses for invalid signatures.
  • Treat webhook URLs as secrets. Rotate them if they leak.
  • Make handlers idempotent. Providers retry events.
  • Avoid logging full payloads in production.

Troubleshooting

validPayload() returns false

  • Confirm the signing secret / public key is correct and matches the provider dashboard.
  • For Paddle Classic: key must be the base64 body without PEM header/footer lines.
  • For Paddle Billing: requires rawBody to be available on the request.
  • For Stripe: requires rawBody — use express.json({ verify: ... }) to capture it.
  • Do not modify fields before validation.

validRequest() returns false

  • Paddle Classic requires application/x-www-form-urlencoded.
  • Stripe, Gumroad, Lemon Squeezy, Polar, and Paddle Billing require application/json.
  • Content-type parameters (e.g. ; charset=utf-8) are allowed.

Missing environment variable errors

  • Set the required variable in your server environment.
  • Restart or redeploy after adding the variable.
  • For Paddle Classic, store the key without PEM header/footer lines.

Body parser issues

  • For Paddle Classic, use express.urlencoded({ extended: false }).
  • For Stripe/Billing, use express.json() with a verify callback to capture rawBody.
  • For Next.js Pages API, disable the built-in body parser and read the raw body manually.

License

MIT

About

Webhooks library for popular subscription services

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors