@sylphx/hookyard
v0.1.0
Published
Typed TypeScript SDK for Hookyard — verify inbound webhook signatures and administer bins.
Readme
@sylphx/hookyard
Typed TypeScript SDK for Hookyard — the reliable
webhook infrastructure. Verify inbound deliveries in two lines and
administer your bins with a fully-typed REST client. Zero runtime
dependencies; ESM + CommonJS; works on Node 18+, Bun, Deno, and edge runtimes
with node:crypto.
npm install @sylphx/hookyard
# or: bun add @sylphx/hookyard · pnpm add @sylphx/hookyardVerify a webhook (the 2-line path)
Hookyard signs every delivery with X-Hookyard-Signature: t=<unix>,v1=<hmac>
(HMAC-SHA256 over "<t>.<rawBody>", the same scheme as Stripe/GitHub).
constructEvent checks the signature and the replay window, then hands back
your typed payload. It throws HookyardSignatureError on anything suspicious, so
a successful return is always genuine.
import { constructEvent, HookyardSignatureError } from '@sylphx/hookyard'
interface PaymentEvent {
event: 'payment.succeeded'
amount: number
}
const event = constructEvent<PaymentEvent>(rawBody, headers, process.env.HOOKPOST_SECRET!)
event.payload.amount // ✅ verified & typed⚠️ Always pass the raw request body — the exact bytes Hookyard sent. If your framework has already parsed it to an object, the signature won't match.
Express
import express from 'express'
import { constructEvent, HookyardSignatureError } from '@sylphx/hookyard'
const app = express()
// `express.raw` keeps the body as a Buffer so the signature still matches.
app.post('/webhooks/hookyard', express.raw({ type: '*/*' }), (req, res) => {
try {
const event = constructEvent(req.body, req.headers, process.env.HOOKPOST_SECRET!)
console.log('delivery', event.id, 'attempt', event.attempt, event.payload)
res.sendStatus(200)
} catch (err) {
if (err instanceof HookyardSignatureError) return res.status(400).send(err.reason)
throw err
}
})Hono
import { Hono } from 'hono'
import { constructEvent, HookyardSignatureError } from '@sylphx/hookyard'
const app = new Hono()
app.post('/webhooks/hookyard', async (c) => {
const rawBody = await c.req.text()
try {
const event = constructEvent(rawBody, c.req.raw.headers, process.env.HOOKPOST_SECRET!)
return c.json({ received: event.id })
} catch (err) {
if (err instanceof HookyardSignatureError) return c.text(err.reason, 400)
throw err
}
})Next.js (App Router)
// app/api/webhooks/hookyard/route.ts
import { constructEvent, HookyardSignatureError } from '@sylphx/hookyard'
export async function POST(req: Request) {
const rawBody = await req.text()
try {
const event = constructEvent(rawBody, req.headers, process.env.HOOKPOST_SECRET!)
return Response.json({ received: event.id })
} catch (err) {
if (err instanceof HookyardSignatureError) return new Response(err.reason, { status: 400 })
throw err
}
}Bun
import { constructEvent, HookyardSignatureError } from '@sylphx/hookyard'
Bun.serve({
port: 3000,
async fetch(req) {
const rawBody = await req.text()
try {
const event = constructEvent(rawBody, req.headers, process.env.HOOKPOST_SECRET!)
return Response.json({ received: event.id })
} catch (err) {
if (err instanceof HookyardSignatureError) return new Response(err.reason, { status: 400 })
throw err
}
},
})Just need a boolean?
import { verifyWebhook } from '@sylphx/hookyard'
const result = verifyWebhook({
rawBody,
signature: headers['x-hookyard-signature'],
secret: process.env.HOOKPOST_SECRET!,
})
if (!result.ok) console.warn('rejected:', result.reason)Administer bins (REST client)
createClient wraps the Hookyard v1 REST API with typed methods, authenticated
with a user API key (hh_live_… from Settings → API Keys).
import { createClient } from '@sylphx/hookyard'
const hookyard = createClient(process.env.HOOKPOST_API_KEY!)
// Whoami (also a cheap key check)
const me = await hookyard.users.me()
// Create a bin — the response carries its bin-level API key exactly once
const bin = await hookyard.bins.create({ name: 'Stripe events' })
// List, fetch, update, delete
const { bins, total } = await hookyard.bins.list()
const detail = await hookyard.bins.get(bin.slug)
await hookyard.bins.update(bin.slug, { forwardUrl: 'https://example.com/in' })
await hookyard.bins.delete(bin.slug)Non-2xx responses throw HookyardApiError (.status, .body):
import { HookyardApiError } from '@sylphx/hookyard'
try {
await hookyard.bins.get('does-not-exist')
} catch (err) {
if (err instanceof HookyardApiError && err.status === 404) {
// handle missing bin
}
}Sending (relaying) a signed payload
Signing is exported too — useful for relaying a verified delivery onward, or for tests:
import { signWebhook } from '@sylphx/hookyard'
const { headers } = signWebhook(rawBody, secret)
await fetch(downstream, { method: 'POST', headers: { ...headers }, body: rawBody })API
| Export | Description |
| --- | --- |
| constructEvent(rawBody, headers, secret, opts?) | Verify + parse → HookyardEvent<T>; throws on failure. |
| verifyWebhook({ rawBody, signature, secret, toleranceSeconds?, nowSeconds? }) | Low-level verify → { ok } / { ok: false, reason }. |
| signWebhook(rawBody, secret, atSeconds?) | Produce { signature, timestamp, headers }. |
| parseSignatureHeader(header) | Parse t=…,v1=… → { t, v1 } or null. |
| createClient(apiKey, opts?) | Typed v1 REST client (users.me, bins.*). |
| HookyardError / HookyardSignatureError / HookyardApiError | Error types. |
Options: toleranceSeconds defaults to 300 (5-minute replay window).
createClient accepts { baseUrl?, fetch?, timeoutMs? }; baseUrl defaults to
https://hookyard.dev.
Compatibility guarantee
verifyWebhook is byte-for-byte compatible with the Hookyard delivery engine. A
parity test imports the server's own signer and cross-verifies both directions,
so the SDK and server can never silently drift.
License
MIT © SylphxAI
