Postato Docs
GuidesCookbook

Webhook handler (Node.js)

Signature verification, replay protection, event dispatch.

Webhook handler (Node.js)

A production-ready webhook endpoint: verifies HMAC signatures, rejects replays, and dispatches events to your app logic. Tested against Express, but the core is framework-agnostic.

Verification helper

// webhook-verify.ts
import crypto from 'node:crypto';

type VerifyResult =
  | { ok: true; timestamp: number }
  | { ok: false; reason: string };

export function verifyPostatoSignature(
  rawBody: string,
  signatureHeader: string | undefined,
  secret: string,
  maxSkewSeconds = 300
): VerifyResult {
  if (!signatureHeader) return { ok: false, reason: 'missing_signature' };

  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('=') as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return { ok: false, reason: 'malformed_signature' };

  const skew = Math.abs(Math.floor(Date.now() / 1000) - t);
  if (skew > maxSkewSeconds) return { ok: false, reason: 'timestamp_skew' };

  const payload = `${t}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');

  const expectedBuf = Buffer.from(expected, 'hex');
  const actualBuf = Buffer.from(v1, 'hex');
  if (expectedBuf.length !== actualBuf.length) return { ok: false, reason: 'length_mismatch' };
  if (!crypto.timingSafeEqual(expectedBuf, actualBuf)) return { ok: false, reason: 'signature_mismatch' };

  return { ok: true, timestamp: t };
}

Notes:

  • timingSafeEqual is non-negotiable; plain === leaks timing.
  • The 5-minute window protects against replays. Tune if your infra has higher clock skew.
  • Raw body, not the parsed JSON. Any JSON middleware mutation breaks the hash.

Express endpoint

// app.ts
import express from 'express';
import { verifyPostatoSignature } from './webhook-verify';
import { handleEvent } from './event-dispatcher';

const app = express();
const WEBHOOK_SECRET = process.env.POSTATO_WEBHOOK_SECRET!;

// IMPORTANT: raw body parser on the webhook route only
app.post(
  '/hooks/postato',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf8');
    const result = verifyPostatoSignature(
      rawBody,
      req.header('x-webhook-signature'),
      WEBHOOK_SECRET
    );

    if (!result.ok) {
      return res.status(401).json({ error: result.reason });
    }

    const event = JSON.parse(rawBody) as { type: string; data: unknown; id: string };

    // Fire-and-forget: respond 200 fast, process async
    res.status(200).send();

    try {
      await handleEvent(event);
    } catch (err) {
      // Log but don't throw — we already 200'd. Retries come via the next at-least-once delivery.
      console.error('event handler failed', event.id, err);
    }
  }
);

app.listen(3000);

Event dispatcher (pattern match)

// event-dispatcher.ts
type PostatoEvent =
  | { type: 'post.published'; data: { postId: string; externalUrl: string } }
  | { type: 'post.failed'; data: { postId: string; error: { code: string; message: string } } }
  | { type: 'approval.decided'; data: { postId: string; decision: 'approved' | 'rejected'; comment?: string } }
  | { type: 'account.disconnected'; data: { accountId: string; reason: string } };

export async function handleEvent(event: PostatoEvent) {
  switch (event.type) {
    case 'post.published':
      await updatePostRecord(event.data.postId, { status: 'published', url: event.data.externalUrl });
      break;
    case 'post.failed':
      await updatePostRecord(event.data.postId, { status: 'failed', error: event.data.error });
      await notifyAuthor(event.data.postId, event.data.error.message);
      break;
    case 'approval.decided':
      if (event.data.decision === 'rejected') {
        await notifyAuthor(event.data.postId, event.data.comment ?? 'rejected');
      }
      break;
    case 'account.disconnected':
      await alertWorkspaceOwner(event.data.accountId, event.data.reason);
      break;
  }
}

Idempotency on your side

Deliveries are at-least-once. If your handler is non-idempotent (e.g., sends a notification email), you'll occasionally send duplicates. Defense: remember recent event IDs:

const seen = new LRUCache<string, boolean>({ max: 10_000 });

async function handleEvent(event: PostatoEvent) {
  if (seen.has(event.id)) return;
  seen.set(event.id, true);
  // ... normal handling
}

For stricter guarantees, persist event IDs with a unique index in your DB and let duplicates fail insertion.

Responding fast

Always return 2xx before doing expensive work. Postato times out a delivery at 5 seconds. If your handler synchronously writes to a slow downstream, the delivery is marked failed and retried, potentially creating a retry storm. Queue the work, respond immediately.

Local testing

Use ngrok or Cloudflare Tunnel to expose localhost. Create a webhook pointing at the tunnel URL, publish a test post, watch your handler receive the event.

Fake signature for unit tests:

function signPayload(body: string, secret: string, t = Math.floor(Date.now() / 1000)) {
  const payload = `${t}.${body}`;
  const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  return { header: `t=${t},v1=${v1}`, t };
}

Exercise both success and failure paths (mismatch, stale timestamp, missing header) so regressions don't leak into production.

On this page