Package Exports
- @velapay/webhook
- @velapay/webhook/fixtures
Readme
@velapay/webhook
Isomorphic webhook event verifier for Vela. Runs in Cloudflare Workers, Node 20+, Bun, and browsers via the Web Crypto API. All event payloads are Zod-typed against the canonical Vela event schemas.
Installation
bun add @velapay/webhook @velapay/sdk
# or: npm install @velapay/webhook @velapay/sdk@velapay/sdk is a required peer dependency — this package reuses event schemas from @velapay/sdk/events.
Usage
import { Webhook, SignatureError } from "@velapay/webhook";
// secret comes from your Vela dashboard webhook settings
const secret = process.env.VELA_WEBHOOK_SECRET!;
async function handleWebhook(request: Request) {
const rawBody = await request.arrayBuffer();
let event;
try {
event = await Webhook.constructEvent(rawBody, request.headers, secret);
} catch (err) {
if (err instanceof SignatureError) {
return new Response(err.reason, { status: 400 });
}
throw err;
}
switch (event.type) {
case "mandate.pull_succeeded":
console.log("Payment pulled:", event.data.amount);
break;
case "mandate.pull_failed":
console.warn("Pull failed:", event.data.reason);
break;
case "mandate.created":
console.log("New subscriber:", event.data.mandateAddress);
break;
case "mandate.cancelled":
console.log("Subscription cancelled:", event.data.mandateAddress);
break;
}
return new Response("ok");
}Webhook.constructEvent
Webhook.constructEvent(
rawBody: Uint8Array | ArrayBuffer | string,
headers: Record<string, string | undefined>,
secret: string,
opts?: { toleranceSeconds?: number; now?: () => number },
): Promise<VelaEvent>- Verifies the
vela-signatureheader using HMAC-SHA256 over${timestamp}.${rawBody} - Rejects replays outside
toleranceSeconds(default: 300 s) - Returns a fully-typed, Zod-parsed
VelaEventon success - Throws
SignatureErroron any failure
Raw body requirement
Always pass the original request bytes. Parsing the body before calling constructEvent will break signature verification.
| Runtime | How to get the raw body |
|---|---|
| Cloudflare Workers | await request.arrayBuffer() |
| Hono | await c.req.raw.arrayBuffer() |
| Express | express.raw({ type: "application/json" }) middleware, then req.body |
| Bun | await request.arrayBuffer() |
Node http |
Accumulate data chunks into a Buffer |
Framework examples
Hono (Cloudflare Workers / Bun)
import { Hono } from "hono";
import { Webhook, SignatureError } from "@velapay/webhook";
const app = new Hono();
app.post("/webhooks/vela", async (c) => {
const rawBody = await c.req.raw.arrayBuffer();
const secret = c.env.VELA_WEBHOOK_SECRET;
let event;
try {
event = await Webhook.constructEvent(rawBody, c.req.raw.headers, secret);
} catch (err) {
if (err instanceof SignatureError) {
return c.text(err.reason, 400);
}
throw err;
}
// handle event...
return c.text("ok");
});Express (Node)
import express from "express";
import { Webhook, SignatureError } from "@velapay/webhook";
const app = express();
app.post(
"/webhooks/vela",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const event = await Webhook.constructEvent(
req.body,
req.headers as Record<string, string>,
process.env.VELA_WEBHOOK_SECRET!,
);
// handle event...
res.send("ok");
} catch (err) {
if (err instanceof SignatureError) {
res.status(400).send(err.reason);
return;
}
throw err;
}
},
);Event types
All events share a type discriminant and a data payload typed per event.
Mandate events
type |
Trigger |
|---|---|
mandate.created |
New subscription mandate created |
mandate.updated |
Mandate parameters changed |
mandate.cancelled |
Subscription cancelled |
mandate.upgrade_initiated |
Plan upgrade started |
mandate.upgrade_finalized |
Plan upgrade completed |
mandate.upgrade_cancelled |
Plan upgrade rolled back |
Pull events
type |
Trigger |
|---|---|
mandate.pull_succeeded |
Recurring payment pulled successfully |
mandate.pull_failed |
Pull attempt failed |
Stream events
type |
Trigger |
|---|---|
stream.created |
Per-second stream started |
stream.paused |
Stream paused |
stream.resumed |
Stream resumed |
stream.rate_updated |
Stream rate changed |
stream.accrued |
Streamed balance settled to recipient |
stream.cancelled |
Stream cancelled |
stream.settled |
Final stream settlement |
Error handling
constructEvent throws a SignatureError for all verification failures. Inspect err.reason to distinguish them:
reason |
Meaning |
|---|---|
missing_header |
vela-signature header absent |
malformed_timestamp |
Header t= value is not a valid integer |
timestamp_outside_tolerance |
Event is older (or newer) than toleranceSeconds |
no_signature_match |
HMAC did not match any v1= signature in the header |
schema_parse_failed |
Payload did not match the expected Zod schema |
import { SignatureError, type SignatureErrorReason } from "@velapay/webhook";
try {
const event = await Webhook.constructEvent(rawBody, headers, secret);
} catch (err) {
if (err instanceof SignatureError) {
const reason: SignatureErrorReason = err.reason;
// reason is narrowed to the union above
}
}Testing with fixtures
@velapay/webhook ships pre-built fixture payloads for every event type so you can test your handler without a live Vela instance:
import { pullSucceededFixture, mandateCancelledFixture } from "@velapay/webhook/fixtures";
// Each fixture is a { rawBody, headers, secret } tuple ready for constructEvent
const event = await Webhook.constructEvent(
pullSucceededFixture.rawBody,
pullSucceededFixture.headers,
pullSucceededFixture.secret,
);
// event.type === "mandate.pull_succeeded"Available fixtures:
Development
bun install
bun run check
bun test
bun run build
bun run smoke:package
bun run release:verifyprepublishOnly runs bun run release:verify, so publishing verifies type safety,
tests, build output, and a fresh-consumer tarball smoke test first.
import {
mandateCreatedFixture,
mandateUpdatedFixture,
mandateCancelledFixture,
mandateUpgradeInitiatedFixture,
mandateUpgradeFinalizedFixture,
mandateUpgradeCancelledFixture,
pullSucceededFixture,
pullFailedFixture,
streamCreatedFixture,
streamPausedFixture,
streamResumedFixture,
streamRateUpdatedFixture,
streamAccruedFixture,
streamCancelledFixture,
streamSettledFixture,
SYNTHETIC_FIXTURES, // all fixtures as a keyed map
} from "@velapay/webhook/fixtures";Development
bun install
bun run build # dual ESM + CJS output with type declarations
bun test # run tests
bun run check # TypeScript type check
bun run lint # Biome lint
bun run format # Biome formatRelated packages
@velapay/sdk— full Solana client, instruction builders, and CLI