JSPM

@velapay/webhook

0.2.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 9
  • Score
    100M100P100Q66269F
  • License MIT

Isomorphic webhook event verifier for Vela Protocol

Package Exports

  • @velapay/webhook
  • @velapay/webhook/fixtures

Readme

@velapay/webhook

npm license

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-signature header using HMAC-SHA256 over ${timestamp}.${rawBody}
  • Rejects replays outside toleranceSeconds (default: 300 s)
  • Returns a fully-typed, Zod-parsed VelaEvent on success
  • Throws SignatureError on 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:verify

prepublishOnly 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 format
  • @velapay/sdk — full Solana client, instruction builders, and CLI

License

MIT