SDK

Webhook Receiver

Verify and parse incoming webhook deliveries from the Realtime Platform using the SDK's built-in receiver.

The SDK includes a server-side webhook receiver that validates HMAC-SHA256 signatures, checks for replay attacks, and returns a typed RealtimeEvent you can use directly in your application.

Info

This is a server-side utility. Use it in your Node.js backend to receive webhook deliveries — not in browser code. For client-side real-time events, use Subscribing to Topics instead.

Quick Start

import { WebhookReceiver } from '@smarterservices/realtime';
import express from 'express';

const app = express();
const receiver = new WebhookReceiver('whsec_your_endpoint_secret');

// Use raw body parsing so the signature can be verified against the exact bytes
app.post('/webhooks/realtime', express.text({ type: '*/*' }), (req, res) => {
  try {
    const { event } = receiver.verify(req.body, req.headers);
    console.log('Received:', event.topic, event.type, event.payload);
    res.sendStatus(200);
  } catch (err) {
    console.error('Verification failed:', err.message);
    res.sendStatus(400);
  }
});

app.listen(4100);

WebhookReceiver

The class-based receiver. Create one instance per webhook endpoint secret and call verify() on every incoming request.

Constructor

new WebhookReceiver(secret: string, options?: WebhookReceiverOptions)
ParameterTypeRequiredDescription
secretstringYesThe shared secret for this webhook endpoint, as configured when creating the endpoint via the Webhooks API
optionsWebhookReceiverOptionsNoOptional configuration (see below)

WebhookReceiverOptions:

FieldTypeDefaultDescription
toleranceMsnumber300000 (5 min)Maximum age in milliseconds for a webhook timestamp to be considered valid. Requests older than this are rejected as potential replay attacks
// Default: 5-minute tolerance
const receiver = new WebhookReceiver('whsec_my_secret');

// Custom: 30-second tolerance for stricter security
const strict = new WebhookReceiver('whsec_my_secret', { toleranceMs: 30_000 });

verify(body, headers)

Verify a webhook request and return the parsed event.

verify(
  body: string,
  headers: Record<string, string | string[] | undefined>
): WebhookEvent
ParameterTypeRequiredDescription
bodystringYesThe raw request body string. Must be the exact bytes received — do not re-serialize or parse before verification
headersRecord<string, string | string[] | undefined>YesHTTP headers object. In Express this is req.headers. The receiver looks for X-Webhook-Signature (case-insensitive)

Returns: WebhookEvent

interface WebhookEvent {
  /** The verified and parsed RealtimeEvent */
  event: RealtimeEvent;
  /** Timestamp (ms) from the signature header */
  timestamp: number;
  /** The raw body string that was verified */
  rawBody: string;
}

Throws: WebhookVerificationError if verification fails (see Error Handling below).

const { event, timestamp, rawBody } = receiver.verify(req.body, req.headers);

console.log(event.id);       // "evt_a1b2c3d4"
console.log(event.topic);    // "session.status"
console.log(event.source);   // "database"
console.log(event.type);     // "row.update"
console.log(event.payload);  // { sessionId: "abc123", status: "active" }

verifyWebhook() (Standalone Function)

A convenience function for one-off verification without instantiating a class:

import { verifyWebhook } from '@smarterservices/realtime';

const { event } = verifyWebhook(body, headers, 'whsec_my_secret');
verifyWebhook(
  body: string,
  headers: Record<string, string | string[] | undefined>,
  secret: string,
  options?: WebhookReceiverOptions
): WebhookEvent

Error Handling

All verification failures throw a WebhookVerificationError with a machine-readable code property:

import { WebhookVerificationError } from '@smarterservices/realtime';

try {
  const { event } = receiver.verify(body, headers);
  // Process event...
} catch (err) {
  if (err instanceof WebhookVerificationError) {
    console.error(`Webhook rejected [${err.code}]: ${err.message}`);
  }
}
CodeMeaning
MISSING_SIGNATUREThe X-Webhook-Signature header was not found in the request
INVALID_SIGNATURE_FORMATThe header is not in the expected t=<timestamp>,v1=<hex> format
TIMESTAMP_EXPIREDThe signature timestamp is outside the tolerance window (replay protection)
SIGNATURE_MISMATCHThe computed HMAC does not match — the secret is wrong or the body was tampered with
INVALID_PAYLOADThe body is not valid JSON or is missing required RealtimeEvent fields (id, topic)

Signature Format

The platform signs every webhook delivery with HMAC-SHA256. The signature is sent in the X-Webhook-Signature header using this format:

t=<timestamp>,v1=<hex-encoded HMAC-SHA256>

The signed content is ${timestamp}.${rawBody} — the timestamp concatenated with a dot and the raw JSON body. Including the timestamp in the signed content prevents replay attacks even if an attacker captures a valid signature.

Headers sent with each delivery:

HeaderDescription
X-Webhook-SignatureSignature in t=<ts>,v1=<sig> format
X-Webhook-TimestampSigning timestamp (milliseconds)
Content-Typeapplication/json

Framework Examples

Express

import express from 'express';
import { WebhookReceiver } from '@smarterservices/realtime';

const app = express();
const receiver = new WebhookReceiver(process.env.WEBHOOK_SECRET!);

// IMPORTANT: Use raw/text body parsing — not JSON parsing —
// so the signature can be verified against the original bytes.
app.post(
  '/webhooks/realtime',
  express.text({ type: 'application/json' }),
  (req, res) => {
    try {
      const { event } = receiver.verify(req.body, req.headers);

      switch (event.topic) {
        case 'session.status':
          handleSessionUpdate(event.payload);
          break;
        case 'order.created':
          handleNewOrder(event.payload);
          break;
        default:
          console.log('Unhandled topic:', event.topic);
      }

      res.sendStatus(200);
    } catch (err) {
      console.error('Webhook error:', err);
      res.sendStatus(400);
    }
  }
);

Fastify

import Fastify from 'fastify';
import { WebhookReceiver } from '@smarterservices/realtime';

const fastify = Fastify();
const receiver = new WebhookReceiver(process.env.WEBHOOK_SECRET!);

// Fastify provides the raw body via request.body when using
// content-type-parser for text
fastify.addContentTypeParser(
  'application/json',
  { parseAs: 'string' },
  (req, body, done) => done(null, body)
);

fastify.post('/webhooks/realtime', async (request, reply) => {
  try {
    const { event } = receiver.verify(
      request.body as string,
      request.headers as Record<string, string>
    );
    console.log('Event:', event.topic, event.payload);
    return reply.code(200).send({ ok: true });
  } catch {
    return reply.code(400).send({ error: 'Invalid webhook' });
  }
});

Next.js API Route (App Router)

import { NextRequest, NextResponse } from 'next/server';
import { WebhookReceiver, WebhookVerificationError } from '@smarterservices/realtime';

const receiver = new WebhookReceiver(process.env.WEBHOOK_SECRET!);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const headers: Record<string, string> = {};
  req.headers.forEach((value, key) => { headers[key] = value; });

  try {
    const { event } = receiver.verify(body, headers);
    console.log('Webhook event:', event.topic, event.type);
    return NextResponse.json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return NextResponse.json({ error: err.code }, { status: 400 });
    }
    return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  }
}

Important: Raw Body Requirement

The webhook signature is computed over the exact raw bytes of the request body. If your framework parses the body as JSON before your handler runs, you'll need to ensure the raw string is also available.

Most frameworks provide a way to access the raw body:

FrameworkApproach
ExpressUse express.text({ type: 'application/json' }) middleware on the webhook route
FastifyRegister a custom content-type parser with parseAs: 'string'
Next.jsUse await req.text() in the App Router
KoaUse koa-body with { text: true } or ctx.request.rawBody

TypeScript Types

All types are exported from the SDK:

import type { WebhookReceiverOptions, WebhookEvent } from '@smarterservices/realtime';
import { WebhookReceiver, verifyWebhook, WebhookVerificationError } from '@smarterservices/realtime';