A TypeScript/JavaScript utilities library for validating and handling subscription webhooks from popular payment providers.
Áskrift means "subscription" in Icelandic.
npm install @ralphilius/askriftimport 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);
});| 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 |
| 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 |
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.
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. |
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
}Á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.
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.
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-detectConfiguration 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 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
rawBodyfor signature verification. When using Express, passrawBodyvia theverifycallback inexpress.json().
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 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 uses HMAC/SHA256 (base64) signature verification via the Polar-Signature header.
POLAR_WEBHOOK_SECRET="your-polar-webhook-secret"const askrift = initialize('polar', fromExpress(req));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');
});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 });
}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));
// ...
}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);
}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(),
}));Webhook providers retry delivery. Use the idempotency key to process each event exactly once.
const askrift = initialize('paddle', fromExpress(req));
const key = askrift.getIdempotencyKey();
// Paddle alert_id "120661188" becomes "paddle:120661188"const payment = await askrift.onPaymentSucceeded();
if (payment) {
const key = payment.getIdempotencyKey();
const timestamp = payment.getEventTimestamp();
const fresh = payment.isFresh({ maxAgeMs: 5 * 60 * 1000 });
}import { extractStableEventId } from '@ralphilius/askrift';
const eventId = extractStableEventId('paddle', req.body);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);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 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, refundReasonThe 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| 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(). |
| 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 |
- Always call
validRequest()andvalidPayload()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.
- 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
rawBodyto be available on the request. - For Stripe: requires
rawBody— useexpress.json({ verify: ... })to capture it. - Do not modify fields before validation.
- 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.
- 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.
- For Paddle Classic, use
express.urlencoded({ extended: false }). - For Stripe/Billing, use
express.json()with averifycallback to capturerawBody. - For Next.js Pages API, disable the built-in body parser and read the raw body manually.
MIT