Node.js SDK
Official Node.js SDK for HostWebhook. Verify signatures, parse headers, send webhooks, and plug into your framework — all from a single package.
Install
npm install @hostwebhook/nodeVerify signatures
Timing-safe HMAC-SHA256 verification with replay protection.
Quick start
import { verify, parseHeaders } from '@hostwebhook/node';
// Verify the signature (throws VerificationError if invalid)
verify(rawBody, req.headers, process.env.SIGNING_SECRET!);
// Parse all X-HostWebhook-* headers into a typed object
const { eventId, attempt, chainStep } = parseHeaders(req.headers);whsec_.Express
Use the built-in middleware — it handles verification and returns a 401 automatically on failure.
import express from 'express';
import { expressMiddleware } from '@hostwebhook/node';
const app = express();
app.post(
'/webhooks',
express.raw({ type: 'application/json' }),
expressMiddleware(process.env.SIGNING_SECRET!),
(req, res) => {
const payload = JSON.parse(req.body.toString());
console.log('Received webhook:', payload);
res.status(200).json({ received: true });
},
);
app.listen(3000);express.raw() so the raw body is available for signature verification. Using express.json() first will break the signature check.NestJS
Create a guard and apply it to your webhook controller.
import { Controller, Post, Req, Res, UseGuards, RawBodyRequest } from '@nestjs/common';
import { createNestGuard } from '@hostwebhook/node';
import { Request, Response } from 'express';
const WebhookGuard = createNestGuard(process.env.SIGNING_SECRET!);
@Controller('webhooks')
export class WebhookController {
@Post()
@UseGuards(WebhookGuard)
handleWebhook(@Req() req: RawBodyRequest<Request>, @Res() res: Response) {
const payload = JSON.parse(req.rawBody!.toString());
console.log('Received webhook:', payload);
return res.json({ received: true });
}
}NestFactory.create(AppModule, { rawBody: true })Fastify
import Fastify from 'fastify';
import { fastifyHook } from '@hostwebhook/node';
const app = Fastify();
// Parse JSON as buffer for raw body access
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => done(null, body),
);
app.post('/webhooks', {
preHandler: fastifyHook(process.env.SIGNING_SECRET!),
}, (req, reply) => {
const payload = JSON.parse((req.body as Buffer).toString());
console.log('Received webhook:', payload);
reply.send({ received: true });
});
app.listen({ port: 3000 });Next.js (App Router)
import { NextRequest, NextResponse } from 'next/server';
import { verify, parseHeaders } from '@hostwebhook/node';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
try {
verify(rawBody, Object.fromEntries(req.headers), process.env.SIGNING_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const { eventId, attempt } = parseHeaders(Object.fromEntries(req.headers));
const payload = JSON.parse(rawBody);
console.log(`Event ${eventId} (attempt ${attempt}):`, payload);
return NextResponse.json({ received: true });
}Manual verification (no SDK)
If you prefer not to install a package, here is the raw verification logic. See the Verifying Signatures page for examples in Python, Go, PHP, and more.
import crypto from 'crypto';
function verifyWebhook(rawBody: string, headers: Record<string, string>, secret: string): boolean {
const sig = headers['x-hostwebhook-signature'];
if (!sig) return false;
const parts = Object.fromEntries(
sig.split(',').map(p => p.trim().split('=', 2) as [string, string]),
);
const { t, v1 } = parts;
if (!t || !v1) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(v1, 'hex');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}API Reference
verify(payload, headers, secret, options?)
Verifies the X-HostWebhook-Signature header against the raw request body. Throws VerificationError if the signature is missing, malformed, expired, or invalid.
| Parameter | Type | Description |
|---|---|---|
payload | string | Buffer | Raw request body |
headers | Record<string, string> | Request headers object |
secret | string | Your signing secret (whsec_...) |
options.maxAgeSec | number | Max signature age in seconds (default: 300). Set 0 to disable. |
parseHeaders(headers)
Parses all X-HostWebhook-* headers into a typed object. Chain headers are automatically base64-decoded.
| Field | Type | Header |
|---|---|---|
eventId | string | X-HostWebhook-Event-Id |
attempt | number | X-HostWebhook-Attempt |
timestamp | number | t= from Signature |
signature | string | v1= from Signature |
chainStep | number? | X-HostWebhook-Chain-Step |
chainOriginal | unknown? | X-HostWebhook-Chain-Original (base64) |
chainPrevious | unknown? | X-HostWebhook-Chain-Previous (base64) |
Middleware helpers
| Function | Framework | Usage |
|---|---|---|
expressMiddleware(secret) | Express | app.post('/wh', raw(), expressMiddleware(s), handler) |
createNestGuard(secret) | NestJS | @UseGuards(createNestGuard(s)) |
fastifyHook(secret) | Fastify | preHandler: fastifyHook(s) |
Send webhooks
Send webhooks through HostWebhook. Instead of building your own retry queue, call send() and HostWebhook handles delivery, retries, monitoring, transforms, and signing.
Fire & forget (default)
import { HostWebhook } from '@hostwebhook/node';
const hw = new HostWebhook({
token: 'your_ingress_token', // from webhook settings
});
// Returns immediately with event ID — pipeline runs in background
const { eventId } = await hw.send({
event: 'order.created',
data: { orderId: '123', amount: 99.99 },
});
console.log('Event created:', eventId);Wait for pipeline result (blocking)
Use waitForResult to wait for the full pipeline to complete. The SDK opens an SSE stream behind the scenes and returns the result with all pipeline steps. This blocks until the pipeline finishes (~120s max).
const result = await hw.send(
{ event: 'order.created', orderId: '123' },
{ waitForResult: true },
);
console.log(result.syncStatus); // 200 = delivered, 422 = validation failed, 502 = failed
console.log(result.steps); // array of pipeline steps
console.log(result.response); // response body from target URLNon-blocking result (recommended for UIs)
Use onResult to get the pipeline result in a background callback. send() returns immediately so your UI never blocks. Perfect for forms and user-facing applications.
const { eventId } = await hw.send(
{ event: 'order.created', orderId: '123' },
{
onResult: (result) => {
// Fires when the pipeline completes (SSE stream in background)
if (result.awaitingApproval) {
showToast('Awaiting approval from ' + result.approvalNodeName);
} else if (result.syncStatus === 200) {
showToast('Delivered successfully');
} else {
showToast('Pipeline failed: ' + result.error);
}
},
onStep: (step) => {
// Optional: fires for each pipeline node as it executes
updateProgress(step.nodeName, step.status, step.durationMs);
},
},
);
// UI shows "Sent!" immediately here — never blocksonResult requires the webhook to be in None response mode. In Sync mode, the server holds the HTTP connection open, which blocks send() regardless of callbacks. See Response Mode for details on when to use each mode.awaitingApproval, awaitingMerge, or delayed so your code can handle each case specifically.Deduplication detection
When deduplication is enabled on the webhook and the event is a duplicate, the response includes deduplicated: true with the original event ID.
const result = await hw.send({ event: 'order.created', orderId: '123' });
if (result.deduplicated) {
console.log('Duplicate! Original event:', result.originalEventId);
}With signing
If your webhook has incoming signature verification enabled (custom type), provide the secret and the SDK signs every request automatically.
const hw = new HostWebhook({
token: 'your_ingress_token',
signingSecret: 'your_incoming_secret', // HMAC-SHA256 custom format
});
await hw.send({ event: 'user.updated', userId: '456' });
// X-Webhook-Signature: sha256=<hex> is added automaticallyBatch sending
// Send multiple webhooks in parallel
const results = await hw.sendBatch([
{ event: 'order.created', orderId: '1' },
{ event: 'order.created', orderId: '2' },
{ event: 'order.created', orderId: '3' },
]);
results.forEach(r => console.log('Event:', r.eventId));Standalone function
For one-off sends without creating a client instance:
import { send } from '@hostwebhook/node';
await send(
{ token: 'your_ingress_token' },
{ event: 'ping', timestamp: Date.now() },
);Self-hosted
const hw = new HostWebhook({
token: 'your_token',
baseUrl: 'https://your-hostwebhook-instance.com',
});Error handling
import { HostWebhook, SendError } from '@hostwebhook/node';
const hw = new HostWebhook({ token: 'your_token' });
try {
await hw.send({ event: 'test' });
} catch (err) {
if (err instanceof SendError) {
console.error(`Status: ${err.statusCode}, Body: ${err.responseBody}`);
}
}Send SDK API Reference
Constructor: new HostWebhook(config)
| Option | Type | Description |
|---|---|---|
token | string | Ingress token from webhook settings (required) |
baseUrl | string | API base URL (default: https://api.hostwebhook.com) |
signingSecret | string | Signs requests with HMAC-SHA256 (custom format) |
defaultHeaders | Record | Headers included in every request |
timeoutMs | number | Request timeout (default: 10000) |
hw.send(payload, options?)
Sends a webhook payload. Returns SendResult. Throws SendError on non-2xx response.
| Option | Type | Description |
|---|---|---|
headers | Record<string, string> | Per-request headers (merged with defaultHeaders) |
contentType | string | Override Content-Type (default: application/json) |
rawBody | string | Buffer | Send raw body instead of JSON-stringifying payload |
waitForResult | boolean | Wait for pipeline result via SSE (blocking, ~120s max) |
onResult | (result) => void | Non-blocking pipeline result callback via SSE. send() returns immediately. |
onStep | (step) => void | Real-time callback for each pipeline node execution. Works with onResult or waitForResult. |
SendResult
| Field | Type | Description |
|---|---|---|
eventId | string | The event ID assigned by HostWebhook |
streamToken | string? | Short-lived token for SSE stream (120s TTL) |
syncStatus | number? | Pipeline result: 200=delivered, 422=validation failed, 502=failed, 408=timeout |
steps | PipelineStep[]? | Pipeline execution steps (only with waitForResult) |
response | string? | Response body from target URL (truncated to 4KB) |
deduplicated | boolean? | True if the event was a duplicate |
originalEventId | string? | Original event ID (when deduplicated) |
hw.sendBatch(payloads, options?)
Sends multiple payloads in parallel. Returns array of SendResult.
SSE Pipeline Stream
Every webhook response includes a streamToken that lets you open a real-time SSE stream of pipeline execution steps. This is what waitForResult uses internally, but you can also use it directly for custom integrations.
How it works
1. Send a webhook — receive eventId and streamToken.
2. Open an SSE stream to GET /api/events/:eventId/stream?token=:streamToken.
3. Receive real-time events as the pipeline executes. The stream closes automatically when the pipeline completes or after 120 seconds.
Example with curl
# Step 1: Send webhook
curl -s -X POST https://api.hostwebhook.com/api/in/your_token \
-H "Content-Type: application/json" \
-d '{"event": "order.created", "amount": 5000}'
# Response: {"eventId":"abc123","streamToken":"uuid-here"}
# Step 2: Open SSE stream
curl -N "https://api.hostwebhook.com/api/events/abc123/stream?token=uuid-here"
# Output:
# event: step
# data: {"nodeType":"filter","nodeName":"Validate","status":"success","durationMs":12}
#
# event: step
# data: {"nodeType":"transform","nodeName":"Clean","status":"success","durationMs":8}
#
# event: done
# data: {"status":200,"body":{"deliveryStatus":"delivered","statusCode":200,"latencyMs":340}}SSE event types
| Event | Description | Action |
|---|---|---|
step | A pipeline node started or finished executing | Accumulate for progress tracking |
done | Pipeline completed (success or failure) | Read result, stream closes |
timeout | 120 seconds elapsed without completion | Stream closes, pipeline continues in background |
Step payload
| Field | Type | Description |
|---|---|---|
eventId | string | The event being processed |
nodeType | string | Type of node (filter, transform, httpAction, etc.) |
nodeId | string | Node instance ID |
nodeName | string? | Human-readable node name |
status | string | running, success, error, or blocked |
statusCode | number? | HTTP status code (for HTTP/Sheets/Mongo actions) |
durationMs | number? | Execution time in milliseconds |
lastPayload | object? | Output payload (on success) |
error | string? | Error message (on failure) |
streamToken expires after 120 seconds and can only be used for the specific event it was issued for. It does not require API key authentication — the token itself is the credential.Response Mode (Sync vs Async)
Each webhook has a Response Mode setting that controls how the ingress responds when a webhook is received:
| Mode | HTTP Status | Behavior |
|---|---|---|
none (default) | 202 Accepted | Returns immediately with eventId + streamToken. Pipeline runs in background. |
sync | 200 OK | Holds the HTTP connection open until the pipeline completes (max 120s). Returns the full pipeline result inline. |
send() and freezes the UI until the pipeline completes. If the pipeline has approval nodes, merge nodes, or long delays, the request will timeout. Use None mode with onResult instead — the user sees "Sent!" immediately and the pipeline result arrives in the background.When to use Sync mode
| Scenario | Webhook mode | SDK option | Behavior |
|---|---|---|---|
| Forms / UIs | none | onResult | Returns instantly. Result arrives in background callback. |
| Backend scripts | none | waitForResult | Blocks until pipeline completes via SSE (~120s max). |
| API proxy / Stripe | sync | none | Blocks until pipeline completes via HTTP (~120s max). |
| Testing (Postman) | sync | none | Single request with full pipeline result. |
Sync mode is designed for server-to-server integrations where the caller needs the pipeline result in the same HTTP request (e.g., Stripe expects a specific response body). The pipeline should be fast and linear — no approval nodes, merge nodes, or long delays.
None mode + onResult is designed for user-facing applications. The user sees instant feedback and the pipeline result arrives asynchronously. Works with any pipeline complexity, including approval nodes and delays.