Install
openclaw skills install hookflo-ternUse this skill for requests touching webhooks, event-driven infrastructure, or real-time HTTP callbacks — from beginner setup to advanced security and archit...
openclaw skills install hookflo-ternThis skill covers two tightly related tools in the Hookflo ecosystem:
@hookflo/tern) — an open-source, zero-dependency TypeScript library for
verifying webhook signatures. Algorithm-agnostic, supports all major platforms.Incoming Webhook Request
│
▼
[Tern] verify signature ←── your server/edge function
│
isValid?
│
yes │ no
│──────► 400 / reject
│
▼
process payload
│
(optionally forward to)
▼
[Hookflo] alert + log
Slack / Email / Dashboard
Use Tern when you need programmatic signature verification in your own code. Use Hookflo when you want no-code / low-code alerting and centralized event logs. They can be used together or independently.
npm install @hookflo/tern
No other dependencies required. Full TypeScript support.
WebhookVerificationService.verify(request, config)The primary method. Returns a WebhookVerificationResult.
import { WebhookVerificationService } from '@hookflo/tern';
const result = await WebhookVerificationService.verify(request, {
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
toleranceInSeconds: 300, // replay attack protection window (optional, default 300)
});
if (result.isValid) {
console.log('Verified payload:', result.payload);
console.log('Metadata:', result.metadata); // timestamp, id, etc.
} else {
console.error('Rejected:', result.error);
// return 400
}
WebhookVerificationService.verifyWithPlatformConfig(request, platform, secret, tolerance?)Shorthand that accepts just a platform name + secret.
const result = await WebhookVerificationService.verifyWithPlatformConfig(
request,
'github',
process.env.GITHUB_WEBHOOK_SECRET!
);
WebhookVerificationService.verifyTokenBased(request, webhookId, webhookToken)For token-based platforms (Supabase, GitLab).
const result = await WebhookVerificationService.verifyTokenBased(
request,
process.env.SUPABASE_WEBHOOK_ID!,
process.env.SUPABASE_WEBHOOK_TOKEN!
);
WebhookVerificationResult typeinterface WebhookVerificationResult {
isValid: boolean;
error?: string;
platform: WebhookPlatform;
payload?: any; // parsed JSON body
metadata?: {
timestamp?: string;
id?: string | null;
[key: string]: any;
};
}
| Platform | Algorithm | Signature Header | Format |
|---|---|---|---|
stripe | HMAC-SHA256 | stripe-signature | t={ts},v1={sig} |
github | HMAC-SHA256 | x-hub-signature-256 | sha256={sig} |
clerk | HMAC-SHA256 (base64) | svix-signature | v1,{sig} |
supabase | Token-based | custom | — |
gitlab | Token-based | x-gitlab-token | — |
shopify | HMAC-SHA256 | x-shopify-hmac-sha256 | raw |
vercel | HMAC-SHA256 | custom | — |
polar | HMAC-SHA256 | custom | — |
dodo | HMAC-SHA256 (svix) | webhook-signature | v1,{sig} |
Always use the lowercase string name (e.g., 'stripe', 'github').
For any provider not in the list, supply a full signatureConfig:
import { WebhookVerificationService } from '@hookflo/tern';
// Standard HMAC-SHA256 with prefix
const result = await WebhookVerificationService.verify(request, {
platform: 'acmepay',
secret: 'your_secret',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-acme-signature',
headerFormat: 'prefixed',
prefix: 'sha256=',
payloadFormat: 'raw',
},
});
// Timestamped payload (signs "{timestamp}.{body}")
const result2 = await WebhookVerificationService.verify(request, {
platform: 'mypay',
secret: 'your_secret',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-webhook-signature',
headerFormat: 'raw',
timestampHeader: 'x-webhook-timestamp',
timestampFormat: 'unix',
payloadFormat: 'timestamped',
},
});
// Svix/StandardWebhooks compatible (Clerk, Dodo, etc.)
const result3 = await WebhookVerificationService.verify(request, {
platform: 'my-svix-platform',
secret: 'whsec_abc123...',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'webhook-signature',
headerFormat: 'raw',
timestampHeader: 'webhook-timestamp',
timestampFormat: 'unix',
payloadFormat: 'custom',
customConfig: {
payloadFormat: '{id}.{timestamp}.{body}',
idHeader: 'webhook-id',
},
},
});
SignatureConfig fields:
algorithm: 'hmac-sha256' | 'hmac-sha1' | 'hmac-sha512' | customheaderName: the HTTP header that carries the signatureheaderFormat: 'raw' | 'prefixed' | 'comma-separated' | 'space-separated'prefix: string prefix to strip before comparing (e.g. 'sha256=')timestampHeader: header name for the timestamp (if any)timestampFormat: 'unix' | 'iso' | 'ms'payloadFormat: 'raw' | 'timestamped' | 'custom'customConfig.payloadFormat: template like '{id}.{timestamp}.{body}'customConfig.idHeader: header supplying the {id} valuecustomConfig.encoding: 'base64' if the provider base64-encodes the keyimport express from 'express';
import { WebhookVerificationService } from '@hookflo/tern';
const app = express();
// IMPORTANT: use raw body parser for webhook routes
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const result = await WebhookVerificationService.verifyWithPlatformConfig(
req,
'stripe',
process.env.STRIPE_WEBHOOK_SECRET!
);
if (!result.isValid) {
return res.status(400).json({ error: result.error });
}
const event = result.payload;
// handle event.type, e.g. 'payment_intent.succeeded'
res.json({ received: true });
}
);
Common mistake: Express's default
json()middleware consumes and re-serializes the body, breaking HMAC. Always useexpress.raw()on webhook endpoints.
// app/api/webhooks/github/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { WebhookVerificationService } from '@hookflo/tern';
export async function POST(req: NextRequest) {
const result = await WebhookVerificationService.verifyWithPlatformConfig(
req,
'github',
process.env.GITHUB_WEBHOOK_SECRET!
);
if (!result.isValid) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
const event = req.headers.get('x-github-event');
// handle event
return NextResponse.json({ received: true });
}
// Disable body parsing so Tern gets the raw body
export const config = { api: { bodyParser: false } };
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request: Request): Promise<Response> {
if (request.method === 'POST' && new URL(request.url).pathname === '/webhooks/clerk') {
const result = await WebhookVerificationService.verifyWithPlatformConfig(
request,
'clerk',
CLERK_WEBHOOK_SECRET
);
if (!result.isValid) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ received: true }));
}
return new Response('Not Found', { status: 404 });
}
import { platformManager } from '@hookflo/tern';
// Verify using the platform manager directly
const result = await platformManager.verify(request, 'stripe', 'whsec_...');
// Get the config for a platform (for inspection)
const config = platformManager.getConfig('stripe');
// Get docs/metadata for a platform
const docs = platformManager.getDocumentation('stripe');
// Run built-in tests for a platform
const passed = await platformManager.runPlatformTests('stripe');
npm test # run all tests
npm run test:platform stripe # test one platform
npm run test:all # test all platforms
Hookflo requires no library installation. The integration is:
Step 1 — Go to hookflo.com/dashboard and create a new webhook. You'll receive:
Step 2 — Set up the provider to send to that Hookflo URL:
| Provider | Where to paste the URL |
|---|---|
| Stripe | Dashboard → Developers → Webhooks → Add endpoint |
| Supabase | Dashboard → Database → Webhooks → Create webhook |
| Clerk | Dashboard → Webhooks → Add endpoint |
| GitHub | Repo/Org Settings → Webhooks → Add webhook |
Step 3 — In the Hookflo dashboard, configure:
payment_intent.succeeded, user.created)If you want both programmatic verification (Tern) AND logging/alerting (Hookflo), use a proxy pattern:
// Your server receives the webhook, verifies it with Tern, then forwards to Hookflo
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify with Tern
const result = await WebhookVerificationService.verifyWithPlatformConfig(
req, 'stripe', process.env.STRIPE_WEBHOOK_SECRET!
);
if (!result.isValid) return res.status(400).json({ error: result.error });
// 2. Process locally
handleStripeEvent(result.payload);
// 3. Forward to Hookflo for alerting/logging (optional)
await fetch(process.env.HOOKFLO_WEBHOOK_URL!, {
method: 'POST',
headers: { ...req.headers, 'Content-Type': 'application/json' },
body: req.body,
});
res.json({ received: true });
});
Alternatively, point Stripe directly at your Hookflo URL and keep Tern for a different endpoint.
HMAC signatures are computed over the exact raw bytes of the request body. Any re-serialization (e.g., by a JSON body parser) will break verification. Always ensure:
express.raw({ type: 'application/json' }) on webhook routesexport const config = { api: { bodyParser: false } }Request objectAlways pass toleranceInSeconds (default is 300 = 5 minutes). This rejects requests
with timestamps too far in the past, preventing replay attacks.
process.env.STRIPE_WEBHOOK_SECRETwrangler secret put STRIPE_WEBHOOK_SECRETAlways return HTTP 400 (not 500) for failed verification — this signals to the sender that the request was rejected (not that your server crashed).
Webhook endpoints must use HTTPS in production. Never accept webhook traffic over HTTP.
| Symptom | Likely Cause | Fix |
|---|---|---|
isValid: false, error about signature | Body was parsed before Tern | Use raw body parser |
isValid: false, error about timestamp | Clock skew or replay attack | Check server clock; increase tolerance if dev |
isValid: false for Clerk | Missing svix headers | Ensure svix-id, svix-timestamp, svix-signature are forwarded |
isValid: false for GitHub | Wrong secret | Re-copy secret from GitHub Webhooks settings |
| Tern not finding platform | Typo in platform name | Use lowercase: 'stripe', 'github', 'clerk' |
| Hookflo not receiving events | Wrong URL pasted | Re-copy URL from Hookflo dashboard |