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, 500 = 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, 500=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 / 422 / 500 / 408Holds the HTTP connection open until a Schema Validator judges the payload or an AI Node answers — 20s at most, 55s when an AI Node can answer, not the length of the pipeline. Returns { accepted: true, eventId } on a valid payload, the validation errors on an invalid one, the model's output from an AI Node, and 500 with the reason if the AI Node or the delivery to the target URL fails. A non-2xx makes send() throw SendError.
⚠A Sync 200 means the payload was accepted, not that the workflow finished. The answer comes from the Schema Validator (or an AI Node), which runs before anything downstream of it. A Slack step that fails afterwards cannot change a response that has already gone out. If you need the outcome of the whole pipeline, use None mode with onResult or waitForResult, or follow the eventId afterwards. The complete contract — detail levels, status codes, the timeout and the X-HostWebhook-Event-Id header — is on Sync responses.
ℹSync mode still blocks send() — the server holds the connection — but only until the validation or the model's answer, not for the length of the pipeline. Raise timeoutMs (10s by default) if an AI Node answers. It also cannot be enabled on a flow with neither a Schema Validator nor an AI Node in it: the save is refused. For forms and UIs, None mode with onResult is still the right shape — 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).
Rejecting bad payloads at the doorsyncnoneBlocks until the Schema Validator answers: 200 accepted, or the validation errors with a 4xx.
Testing (Postman)syncnoneSingle request, with the validation verdict in the status code.

Sync mode is designed for server-to-server integrations where the caller needs to know, in the same HTTP request, whether the payload was acceptable. It answers that and only that. If the sender needs the outcome of the pipeline itself, sync mode is the wrong tool.

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.