ScreenshotNeo

BlogEngineering

How to Receive Webhook Events in a Node.js PDF Workflow

Build a secure Node.js webhook that verifies raw requests, prevents duplicates, and generates PDFs reliably with PDFKit or a hosted service.

By the ScreenshotNeo team29 September 202610 min read

How to Receive Webhook Events in a Node.js PDF Workflow

A reliable Node.js PDF workflow starts with a dedicated POST webhook endpoint. Preserve the request body as raw bytes, verify the provider signature and timestamp before parsing JSON, validate the event, deduplicate by event ID, and only then generate or queue the PDF. Return a success status after the event is safely accepted for processing.

This ordering matters because JSON parsing changes the exact bytes that signature verification covers. It also makes retries safe: providers commonly deliver the same event again when your endpoint times out or returns a server error.

1. The webhook-to-PDF flow

  1. Expose a dedicated HTTPS POST /webhooks/events route.
  2. Install raw-body middleware on that route before any global JSON parser.
  3. Read the provider’s signature and timestamp headers.
  4. Verify the untouched body with the provider’s official helper or documented HMAC algorithm.
  5. Reject invalid signatures and stale timestamps before reading event fields.
  6. Parse the verified bytes, validate required fields, and record the provider event ID.
  7. Generate a PDF in the Node.js process or enqueue a hosted conversion job.
  8. Return a 2xx response once the event is accepted. Make repeated deliveries a no-op.

The exact header names, signed string, timestamp tolerance, digest encoding, and secret format belong to the provider. Never substitute a generic HMAC recipe when the provider publishes an SDK or verification helper.

2. Express setup with raw-body verification

Install Express, PDFKit, and a persistence driver appropriate for your application:

A safe webhook pipeline verifies raw bytes before parsing and rendering.
A safe webhook pipeline verifies raw bytes before parsing and rendering.
npm install express pdfkit

Place the webhook route before express.json(). The example below shows the complete control flow, including equal-length checks required by crypto.timingSafeEqual. Replace the illustrative header and canonicalization rules with your provider’s documented scheme.

import express from 'express';
import crypto from 'node:crypto';
import PDFDocument from 'pdfkit';

const app = express();
const port = Number(process.env.PORT || 3000);
const webhookSecret = process.env.WEBHOOK_SECRET;

if (!webhookSecret) {
  throw new Error('WEBHOOK_SECRET is required');
}

// Replace this with the provider's official verification algorithm.
function verifySignature(rawBody, signatureHeader, timestampHeader) {
  if (!signatureHeader || !timestampHeader) return false;

  const timestamp = Number(timestampHeader);
  const now = Math.floor(Date.now() / 1000);
  const toleranceSeconds = 300;
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) {
    return false;
  }

  const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  const received = Buffer.from(signatureHeader, 'utf8');
  const calculated = Buffer.from(expected, 'utf8');
  return received.length === calculated.length &&
    crypto.timingSafeEqual(received, calculated);
}

// This route must be registered before app.use(express.json()).
app.post('/webhooks/events',
  express.raw({ type: 'application/json', limit: '256kb' }),
  async (req, res) => {
    const signature = req.get('x-provider-signature') || '';
    const timestamp = req.get('x-provider-timestamp') || '';
    const rawBody = req.body;

    if (!Buffer.isBuffer(rawBody)) {
      return res.status(400).json({ error: 'raw body is required' });
    }

    if (!verifySignature(rawBody, signature, timestamp)) {
      return res.status(400).json({ error: 'invalid signature' });
    }

    let event;
    try {
      event = JSON.parse(rawBody.toString('utf8'));
    } catch {
      return res.status(400).json({ error: 'malformed JSON' });
    }

    if (typeof event.id !== 'string' || typeof event.type !== 'string') {
      return res.status(400).json({ error: 'invalid event shape' });
    }

    // Replace with an atomic INSERT ... ON CONFLICT / conditional write.
    const alreadySeen = await hasProcessedEvent(event.id);
    if (alreadySeen) {
      return res.sendStatus(200);
    }

    try {
      await markEventProcessing(event.id);
      await createPdfForEvent(event, res);
      await markEventComplete(event.id);
    } catch (error) {
      await markEventFailed(event.id, error);
      // A 5xx tells the provider to retry. Use this only for transient failures.
      if (!res.headersSent) return res.sendStatus(500);
    }
  }
);

// All non-webhook routes can use parsed JSON.
app.use(express.json());

async function hasProcessedEvent(eventId) {
  // Query your database here.
  return false;
}
async function markEventProcessing(eventId) {}
async function markEventComplete(eventId) {}
async function markEventFailed(eventId, error) {
  console.error('event failed', { eventId, message: error.message });
}

function createPdfForEvent(event, res) {
  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ size: 'A4', margin: 50 });
    res.status(200);
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', `inline; filename="${event.id}.pdf"`);
    doc.on('error', reject);
    res.on('close', resolve);
    doc.pipe(res);
    doc.fontSize(18).text(`Event ${event.id}`);
    doc.moveDown().fontSize(11).text(`Type: ${event.type}`);
    if (event.created_at) doc.text(`Created: ${event.created_at}`);
    doc.end();
  });
}

app.listen(port, () => console.log(`Listening on ${port}`));

PDFKit is a JavaScript PDF generation library for Node.js and the browser. Its stream-based API lets you pipe a document to an HTTP response or a file and call doc.end() to finalize it. See the PDFKit getting-started documentation for fonts, images, pages, and layout.

Do not acknowledge work you have not accepted

If PDF generation can take longer than the provider’s timeout, persist the verified event and enqueue a job. Return 200 or 202 after the database write succeeds. A worker can then render the PDF and update status. If persistence fails, return a retryable 5xx. Do not return success merely because the request reached your process.

3. Signature verification details

Keep verification as a boundary between untrusted input and application logic.

  • Raw bytes: use express.raw({ type: 'application/json' }). A preceding express.json() consumes and transforms the body.
  • Timestamp: reject old requests using the provider’s tolerance. This reduces replay risk.
  • Constant-time comparison: compare buffers with timingSafeEqual, after checking equal lengths.
  • Provider helper: prefer an official helper because providers differ on prefixes, multiple signatures, and canonical strings.
  • Secret handling: load secrets from environment variables or a secret manager, never from source control or request payloads.

SendGrid’s Node.js guidance requires verification against the raw body, not a parsed JSON object. UsePDFMaker’s Express example has the same ordering requirement for HMAC. PDFBolt’s Node SDK exposes a verify-and-parse flow that verifies first and parses second. These are provider-specific implementations of the same boundary rule.

4. Idempotency and event storage

Webhook delivery is at-least-once in practice. The same event may arrive concurrently, after a timeout, or after your process restarts. Put a unique constraint on the provider event ID and make the insert atomic:

CREATE TABLE webhook_events (
  provider_event_id TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  attempts INTEGER NOT NULL DEFAULT 1,
  pdf_job_id TEXT,
  error_message TEXT
);

On receipt, insert status = 'processing'. If the unique-key operation reports a conflict, load the existing row. A completed event should return success without rendering again. A processing event can be treated as a duplicate, or recovered with a lease if the original worker died. Store the provider event ID separately from your internal PDF job ID so callback reconciliation is possible.

5. Generating PDFs in process with PDFKit

In-process rendering keeps data in your environment and avoids a second network dependency. It gives you control over fonts, layout, and latency, but your service owns memory, CPU, font files, page breaks, and storage.

import PDFDocument from 'pdfkit';
import fs from 'node:fs';

export function writeInvoice(invoice, outputPath) {
  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ size: 'A4', margin: 48 });
    const output = fs.createWriteStream(outputPath);
    doc.on('error', reject);
    output.on('error', reject);
    output.on('finish', resolve);
    doc.pipe(output);
    doc.fontSize(22).text(`Invoice ${invoice.number}`);
    doc.moveDown();
    doc.fontSize(11).text(`Customer: ${invoice.customerName}`);
    for (const line of invoice.lines) {
      doc.text(`${line.description} — ${line.quantity} × ${line.amount}`);
    }
    doc.moveDown().fontSize(14).text(`Total: ${invoice.total}`);
    doc.end();
  });
}

For large documents, stream to object storage instead of buffering the entire PDF. Bound the number of concurrent renders, because many simultaneous documents can exhaust memory or CPU. If you need deterministic output, pin font files and rendering dependencies in your deployment image.

6. Hosted asynchronous PDF conversion

A hosted converter moves rendering to vendor infrastructure. Your webhook handler persists the event, submits a conversion request with a callback URL, and stores the returned request ID. The provider later posts a signed terminal event to your callback. This reduces local rendering work but introduces provider credentials, callback availability, service limits, and a second data boundary.

Keep inbound and outbound authentication separate. Verify the webhook signature with the callback provider’s secret; authenticate conversion requests with that service’s API key. Persist the original event ID, conversion request ID, and final status. A callback should be safe to replay and should reject an unknown request ID.

7. Calling a webhook endpoint manually

For local development, send a request with cURL. The signature below is illustrative; use your provider’s exact signed string and header format.

body='{"id":"evt_123","type":"invoice.finalized"}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" | \
  openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')

curl -i https://example.com/webhooks/events \
  -H 'Content-Type: application/json' \
  -H "x-provider-timestamp: $timestamp" \
  -H "x-provider-signature: $signature" \
  --data "$body"

A Python sender is useful for integration fixtures:

import hashlib, hmac, json, os, time, requests

body = json.dumps({'id': 'evt_123', 'type': 'invoice.finalized'}, separators=(',', ':'))
timestamp = str(int(time.time()))
signed = f'{timestamp}.{body}'.encode()
signature = hmac.new(os.environ['WEBHOOK_SECRET'].encode(), signed, hashlib.sha256).hexdigest()

response = requests.post(
    'https://example.com/webhooks/events',
    data=body.encode(),
    headers={
        'Content-Type': 'application/json',
        'x-provider-timestamp': timestamp,
        'x-provider-signature': signature,
    },
    timeout=20,
)
response.raise_for_status()

Node.js can send the same fixture with the built-in fetch API:

import crypto from 'node:crypto';
const body = JSON.stringify({ id: 'evt_123', type: 'invoice.finalized' });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update(`${timestamp}.${body}`).digest('hex');
const response = await fetch('https://example.com/webhooks/events', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-provider-timestamp': timestamp,
    'x-provider-signature': signature,
  },
  body,
});
console.log(response.status, await response.text());

8. Or skip the browser setup

If the PDF workflow also needs screenshots of web pages, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. It accepts cookie banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

Clean capture removes consent banners and overlays before the document is produced.
Clean capture removes consent banners and overlays before the document is produced.

See the ScreenshotNeo documentation for all options. A one-call example:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Use full-page capture with lazy images, CSS element selection, dark mode, device presets, custom viewports, retina scale, PDF paper and margin options, custom CSS or JavaScript, clicks, selector or network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs, and the usage API. Existing parameter names used by other screenshot APIs also work.

There are 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account to add clean page captures or PDFs to your webhook pipeline.

9. Troubleshooting common failures

Symptom Cause Fix
Every signature is invalid express.json() ran first, or the provider signs a different canonical string. Move express.raw() before the global parser and copy the provider’s exact algorithm.
timingSafeEqual throws Received and calculated signatures have different lengths. Check lengths before comparing; normalize documented prefixes and encodings.
Old requests are accepted No timestamp validation. Parse the signed timestamp and enforce the provider’s tolerance.
Duplicate PDFs No unique event constraint or concurrent check. Use an atomic insert keyed by provider event ID and make workers idempotent.
Provider retries constantly Handler exceeds timeout or returns 5xx after doing work. Persist and enqueue quickly, acknowledge accepted work, and make retries no-ops.
Empty or corrupt PDF doc.end() was omitted, or the response was closed early. Always finalize the document and monitor stream errors and response close events.
Webhook route sees an object instead of Buffer A body parser consumed the request. Exclude the route from earlier parsers and use the matching content type.
Memory climbs during bursts Too many in-process renders or buffered output. Stream output, cap concurrency, queue work, and apply request size limits.
Callback cannot be reconciled Only the webhook payload was stored. Persist both the original event ID and hosted conversion request ID.

10. Performance, reliability, and cost

  • Latency: acknowledge after durable acceptance, then render asynchronously when documents or remote conversions are slow.
  • Throughput: use a bounded worker pool. Measure PDF render time, queue age, callback delay, and retry count.
  • Reliability: add a dead-letter state after repeated failures and provide a reconciliation job for events whose provider retries have stopped.
  • Timeouts: set an inbound server timeout below the provider’s retry window and use explicit outbound timeouts for conversion APIs.
  • Security: redact payloads containing personal or financial data, rotate secrets, and restrict callback endpoints to the required methods.
  • Cost: in-process PDFKit uses your compute, storage, and egress. Hosted conversion charges according to its plan and adds network dependency. ScreenshotNeo bills only clean shots; failed loads, bot checks, blank pages, timeouts, and cache hits cost nothing.

11. Testing checklist

  • Valid signature and valid JSON.
  • Malformed JSON with a valid signature.
  • Missing signature or timestamp.
  • Incorrect signature and stale timestamp.
  • Repeated event ID, including concurrent deliveries.
  • Provider retry after a simulated 500 response.
  • PDF stream error, worker crash, and restart during processing.
  • Oversized request body and unexpected content type.
  • Hosted callback for success, failure, unknown job ID, and duplicate callback.

FAQ

Should I parse JSON before checking the signature?

No. Verify the untouched raw bytes first, then parse the verified body.

Is a 2xx response required for duplicate events?

Yes, once the duplicate is recognized and the original event is safely recorded. Returning success prevents needless retries.

When is PDFKit the better choice?

Use it when rendering must stay in your environment and you need control over fonts, layout, and predictable local behavior.

When should rendering be asynchronous?

Queue it when rendering can exceed the provider timeout, when bursts are expected, or when you need independent retry and scaling controls.

Can a webhook endpoint return the PDF immediately?

Yes for small, fast documents, provided you still verify first and finalize the stream. For heavier work, acknowledge durable queueing and deliver the PDF separately.