@paysmith/webhook
v0.0.1
Published
Verify Paysmith signed webhook events from raw request bytes — Ed25519 with timestamp tolerance and pinned key ids — and deduplicate at-least-once deliveries.
Maintainers
Readme
@paysmith/webhook
Receiver-side verification for signed Paysmith events. @paysmith/webhook checks a delivery's
Ed25519 signature against the exact raw request bytes and a timestamp tolerance, parses the result
into a typed paysmith.event/v1 envelope, and deduplicates deliveries by event_id through a
pluggable store. This is the enforcement point for the one rule Paysmith is built around: content
unlocks only after a signed event verifies — never because a return URL or a client claims
success.
Paysmith deliveries are at-least-once. Every webhook handler built on this package must be
idempotent: the deduper exists specifically so a re-delivered event (Paysmith's sandbox has a
duplicate_webhook demo scenario that deliberately redelivers) can never grant, revoke, or record
anything twice.
Install
pnpm add @paysmith/webhook
# or
npm i @paysmith/webhook@paysmith/contracts is a dependency and is installed automatically.
Quick start: a webhook handler (Node / Next.js route)
// app/api/paysmith/webhook/route.ts
import { verifyPaysmithEvent, createEventDeduper, createJsonFileDeduperStore } from "@paysmith/webhook";
import { NextResponse, type NextRequest } from "next/server";
const deduper = createEventDeduper(createJsonFileDeduperStore(".paysmith/seen-events.json"));
// The public key for the sandbox's pinned `public_key_id` — fetched once and
// cached by your app, e.g. via `@paysmith/sdk/server`'s `getSandboxPublicKey()`.
declare const PINNED_SANDBOX_PUBLIC_KEY_PEM: string;
export async function POST(request: NextRequest): Promise<NextResponse> {
// Verification covers the exact raw bytes — read text before any JSON.parse.
const rawBody = await request.text();
const result = verifyPaysmithEvent({
rawBody,
headers: request.headers,
publicKeyPem: PINNED_SANDBOX_PUBLIC_KEY_PEM, // pin by key id — see "Key pinning" below
});
if (!result.ok) {
// result.reason: "missing_headers" | "invalid_timestamp" | "invalid_environment"
// | "timestamp_out_of_tolerance" | "malformed_signature" | "signature_mismatch"
// | "invalid_json" | "invalid_event_envelope"
return NextResponse.json({ error: "invalid_signature", reason: result.reason }, { status: 400 });
}
const { event } = result; // typed EventEnvelope
const isFirstDelivery = await deduper.claim(event.event_id);
if (!isFirstDelivery) {
return NextResponse.json({ received: true, deduplicated: true });
}
if (event.type === "payment.succeeded") {
// Grant the entitlement here — this is the only place in the app that may.
} else if (event.type === "payment.refunded") {
// Revoke it.
}
return NextResponse.json({ received: true });
}The rule this enforces: unlock only after verifyPaysmithEvent returns { ok: true }, and only
once per event_id. Never grant access from a checkout confirm response or a return-URL redirect —
those are UI hints, not proof.
API
verifyPaysmithEvent(input): VerifyPaysmithEventResult
interface VerifyPaysmithEventInput {
rawBody: string | Uint8Array;
headers: Pick<Headers, "get"> | Readonly<Record<string, string | readonly string[] | undefined>>;
publicKeyPem: string;
toleranceSeconds?: number; // defaults to 300 (5 minutes)
nowSeconds?: number; // injectable clock, mainly for tests
}
type VerifyPaysmithEventResult =
| { ok: true; event: EventEnvelope }
| { ok: false; reason: VerifyPaysmithEventFailureReason };headers accepts either a Fetch Headers instance or a plain record (e.g. Node's
IncomingHttpHeaders) — lookups are case-insensitive either way. The function never throws: every
failure mode, from a malformed header to a stale timestamp to a bad signature to a schema mismatch,
comes back as { ok: false, reason }.
Timestamp tolerance. The signed timestamp must be within toleranceSeconds (default 300) of
nowSeconds (default: the real clock). The window is checked before the signature so a stale-but-
otherwise-valid delivery is reported as timestamp_out_of_tolerance rather than a generic mismatch.
Key pinning. verifyPaysmithEvent verifies against whatever publicKeyPem you pass it — it does
not fetch or cache keys itself. Your handler is responsible for pinning: only ever supply the public
key for the public_key_id your sandbox was activated with, and refuse deliveries whose
paysmith-key-id header doesn't match that pinned id. Never re-fetch a key just because a signature
failed to verify — that turns every forged signature into a free key lookup for an attacker.
createEventDeduper(store): EventDeduper
interface EventDeduper {
claim(eventId: string): Promise<boolean>; // true = first time seen; false = already claimed
}Concurrent claim() calls for the same eventId are serialized, so two deliveries racing in can
never both observe themselves as "first."
Deduper stores
function createMemoryDeduperStore(): EventDeduperStore; // in-process only; resets on restart
function createJsonFileDeduperStore(filePath: string): EventDeduperStore; // persists across restartscreateJsonFileDeduperStore writes to a sibling temp file and publishes with a single rename, so a
crash mid-write never leaves a truncated store on disk. Implement EventDeduperStore (has / add)
directly to back the dedupe set with your own database.
readHeader(headers, name): string | undefined
Case-insensitive header lookup across either header shape verifyPaysmithEvent accepts — exported
for callers building their own diagnostics around a delivery.
How it fits
@paysmith/webhook is where the signed event becomes an entitlement: it's the only step
between a payment intent settling and an application deciding to unlock something. A receipt is
retrievable afterward as proof of what happened, but the unlock decision itself is made here, once,
per event_id.
License
MIT
