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

bashterminal
npm install @hostwebhook/node

Verify signatures

Timing-safe HMAC-SHA256 verification with replay protection.

Quick start

typescriptverify-example.ts
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);
Your signing secret is available in the webhook settings or in the Signing Secrets page. It starts with whsec_.

Express

Use the built-in middleware — it handles verification and returns a 401 automatically on failure.

typescriptexpress-webhook.ts
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);
You must use 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.

typescriptwebhook.controller.ts
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 });
  }
}
Enable raw body parsing in NestJS: NestFactory.create(AppModule, { rawBody: true })

Fastify

typescriptfastify-webhook.ts
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)

typescriptapp/api/webhooks/route.ts
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.

typescriptmanual-verify.ts
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.

ParameterTypeDescription
payloadstring | BufferRaw request body
headersRecord<string, string>Request headers object
secretstringYour signing secret (whsec_...)
options.maxAgeSecnumberMax 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.

FieldTypeHeader
eventIdstringX-HostWebhook-Event-Id
attemptnumberX-HostWebhook-Attempt
timestampnumbert= from Signature
signaturestringv1= from Signature
chainStepnumber?X-HostWebhook-Chain-Step
chainOriginalunknown?X-HostWebhook-Chain-Original (base64)
chainPreviousunknown?X-HostWebhook-Chain-Previous (base64)

Middleware helpers

FunctionFrameworkUsage
expressMiddleware(secret)Expressapp.post('/wh', raw(), expressMiddleware(s), handler)
createNestGuard(secret)NestJS@UseGuards(createNestGuard(s))
fastifyHook(secret)FastifypreHandler: 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)

typescriptsend-example.ts
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).

typescriptwait-for-result.ts
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 URL

Non-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.

typescripton-result.ts
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 blocks
onResult 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.
If the pipeline hits an approval node, merge node, or long delay, the result includes 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.

typescriptdedup-detection.ts
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.

typescriptsend-signed.ts
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 automatically

Batch sending

typescriptsend-batch.ts
// 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:

typescriptsend-standalone.ts
import { send } from '@hostwebhook/node';

await send(
  { token: 'your_ingress_token' },
  { event: 'ping', timestamp: Date.now() },
);

Self-hosted

typescriptself-hosted.ts
const hw = new HostWebhook({
  token: 'your_token',
  baseUrl: 'https://your-hostwebhook-instance.com',
});

Error handling

typescripterror-handling.ts
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)

OptionTypeDescription
tokenstringIngress token from webhook settings (required)
baseUrlstringAPI base URL (default: https://api.hostwebhook.com)
signingSecretstringSigns requests with HMAC-SHA256 (custom format)
defaultHeadersRecordHeaders included in every request
timeoutMsnumberRequest timeout (default: 10000)

hw.send(payload, options?)

Sends a webhook payload. Returns SendResult. Throws SendError on non-2xx response.

OptionTypeDescription
headersRecord<string, string>Per-request headers (merged with defaultHeaders)
contentTypestringOverride Content-Type (default: application/json)
rawBodystring | BufferSend raw body instead of JSON-stringifying payload
waitForResultbooleanWait for pipeline result via SSE (blocking, ~120s max)
onResult(result) => voidNon-blocking pipeline result callback via SSE. send() returns immediately.
onStep(step) => voidReal-time callback for each pipeline node execution. Works with onResult or waitForResult.

SendResult

FieldTypeDescription
eventIdstringThe event ID assigned by HostWebhook
streamTokenstring?Short-lived token for SSE stream (120s TTL)
syncStatusnumber?Pipeline result: 200=delivered, 422=validation failed, 502=failed, 408=timeout
stepsPipelineStep[]?Pipeline execution steps (only with waitForResult)
responsestring?Response body from target URL (truncated to 4KB)
deduplicatedboolean?True if the event was a duplicate
originalEventIdstring?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

bashterminal
# 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

EventDescriptionAction
stepA pipeline node started or finished executingAccumulate for progress tracking
donePipeline completed (success or failure)Read result, stream closes
timeout120 seconds elapsed without completionStream closes, pipeline continues in background

Step payload

FieldTypeDescription
eventIdstringThe event being processed
nodeTypestringType of node (filter, transform, httpAction, etc.)
nodeIdstringNode instance ID
nodeNamestring?Human-readable node name
statusstringrunning, success, error, or blocked
statusCodenumber?HTTP status code (for HTTP/Sheets/Mongo actions)
durationMsnumber?Execution time in milliseconds
lastPayloadobject?Output payload (on success)
errorstring?Error message (on failure)
The 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:

ModeHTTP StatusBehavior
none (default)202 AcceptedReturns immediately with eventId + streamToken. Pipeline runs in background.
sync200 OKHolds the HTTP connection open until the pipeline completes (max 120s). Returns the full pipeline result inline.
Never use Sync mode for user-facing forms or UIs. Sync mode holds the HTTP connection open on the server, which blocks 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

ScenarioWebhook modeSDK optionBehavior
Forms / UIsnoneonResultReturns instantly. Result arrives in background callback.
Backend scriptsnonewaitForResultBlocks until pipeline completes via SSE (~120s max).
API proxy / StripesyncnoneBlocks until pipeline completes via HTTP (~120s max).
Testing (Postman)syncnoneSingle 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.