Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Handle iMessage Webhooks in Node.js

Design guide. Figment's iMessage integration provides a REST API for reading, searching, and sending messages. Incoming event delivery—webhook registration, event schemas, and signing contracts—is not a current Figment endpoint. This page is a developer-owned design: the Node.js HMAC receiver, durable inbox pattern, and test suite you build to handle events from your own iMessage bridge adapter. The pattern applies to any HMAC-signed webhook source; replace the placeholder field names (event.id, event.type, event.timestamp) with your sender's actual schema.

Building event-driven iMessage automation starts with a reliable receiver. Before your server acts on any payload, it must do three things in order: confirm the request is genuine, persist it durably, and acknowledge with a 2xx response. Processing happens after the acknowledgement—that way a crash during a downstream call does not cause a lost event or trigger an unintended resend.

This guide covers four steps: capturing raw body bytes, HMAC-SHA256 verification using node:crypto, writing to a durable inbox before returning 202, and local-development replay protection. The final section is the main artifact: a five-case test suite you should have in CI from day one.

Capture the raw body before parsing

HMAC signature verification is computed over the exact transmitted bytes. Any middleware that re-serialises a parsed JSON object can silently reorder keys or normalise whitespace, changing the byte sequence and causing every verification check to fail.

Mount express.raw() on the webhook route before any global body-parser, and set an explicit size limit appropriate for your expected payload:

// server.js
import express from 'express';

const app = express();

// Preserve exact bytes on this route; apply before any express.json() middleware.
// Adjust limit to match your sender's maximum payload size.
app.use('/webhooks/imessage', express.raw({ type: 'application/json', limit: '100kb' }));

req.body is now a Buffer. Parse it into an object only after the signature has passed.

Verify the signature

Node.js ships createHmac and timingSafeEqual in node:crypto—no npm dependency needed (Node.js crypto docs).

timingSafeEqual is not optional. A plain === comparison returns as soon as it finds a mismatch, leaking how many leading bytes matched. An attacker can measure those timing differences and reconstruct a valid signature byte by byte. timingSafeEqual always takes the same time regardless of where values diverge. It throws if both Buffer arguments are not the same byte length, so check lengths before calling it:

// verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Verify an HMAC-SHA256 webhook signature.
 * rawBody  {Buffer}  — Exact request body bytes.
 * provided {string}  — Hex signature from the request header.
 * secret   {string}  — Signing secret from env.
 * returns  {boolean}
 */
export function verifySignature(rawBody, provided, secret) {
  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(provided ?? '', 'utf8');

  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

Add a startup guard so your process fails loudly when a required variable is absent, rather than silently accepting every request:

// startup check — run before app.listen()
['WEBHOOK_SECRET', 'WEBHOOK_SIG_HEADER'].forEach(key => {
  if (!process.env[key]) throw new Error(`${key} must be set`);
});

WEBHOOK_SIG_HEADER is the lowercase header name your sender uses for the HMAC signature (for example, x-hub-signature-256). WEBHOOK_SECRET is the shared signing secret. Both come from environment variables and must never be hardcoded.

Write to a durable inbox before 202

The critical ordering rule: write the event to a durable store before returning 202. If you acknowledge first and your process crashes, the event is gone. If the write fails, return a non-2xx status so your sender retries.

The example below uses illustrative placeholder field names (event.id, event.type). These are your application's schema—replace them with the actual field names emitted by your sender. The inbox upsert keyed on event.id also acts as the production replay guard: a second delivery of the same event ID is a no-op upsert, and still returns 202.

// routes/webhook.js
import { verifySignature } from './verify.js';

// db.inboxEvents is illustrative — adapt to your ORM or database driver (e.g. Prisma, Postgres, SQLite).

app.post('/webhooks/imessage', async (req, res) => {
  const sigHeader = process.env.WEBHOOK_SIG_HEADER.toLowerCase();
  const sig       = req.headers[sigHeader] ?? '';

  if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'invalid_signature' });
  }

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

  try {
    // Upsert by your event's unique ID field.
    // A second delivery of the same event.id is a no-op; both return 202.
    await db.inboxEvents.upsert({
      where:  { eventId: event.id },
      create: { eventId: event.id, payload: req.body, receivedAt: Date.now() },
      update: {},  // already stored — no-op
    });
  } catch (err) {
    console.error('inbox write failed', err);
    return res.status(500).json({ error: 'inbox_write_failed' });
  }

  // Acknowledge only after the durable write succeeds.
  res.status(202).send();
  // A separate worker reads unprocessed rows from the inbox and dispatches events.
});

A worker process reads unprocessed inbox rows, calls the appropriate handler, and marks each row complete. This decoupling means a downstream failure does not forfeit the event: the row remains until processing succeeds.

Guard against replay in local development

In production, the durable inbox upsert is your replay guard. For local development or unit tests where no database is running, a time-windowed in-memory store illustrates the same logic:

// replay.js — local dev and unit tests only; replace with durable-store query in production
const seen      = new Map();
const WINDOW_MS = 5 * 60_000;   // 5-minute window

export function isReplay(eventId, deliveredAt) {
  const age = Date.now() - deliveredAt;
  if (age > WINDOW_MS || age < -30_000) return true;   // too old or clock skew

  if (seen.has(eventId)) return true;
  seen.set(eventId, Date.now());
  return false;
}

Replace the Map with an inbox table query or a Redis SETNX with a TTL equal to your window length in production. Never mark an event ID as seen before the durable write succeeds—doing so loses the event if the enqueue fails.

Route events to handlers

A worker dispatches on the event type your sender defines. The switch cases below use your application's placeholder event-type strings—they are not Figment event names. Define them alongside your sender schema and keep the worker aligned to the same enum:

// worker/process-event.js

// handleNewMessage and handleDeliveryUpdate are your application's handlers.
export async function processInboxEvent(row) {
  const event = JSON.parse(row.payload.toString('utf8'));

  switch (event.type) {
    case 'new_message':
      await handleNewMessage(event.data);
      break;

    case 'delivery_update':
      // For messages sent via Figment's REST API, confirm delivery by polling
      // GET /v1/connections/{id}/messages/receipt with the original idempotency key.
      // Never resend an unknown-state action under a new key.
      await handleDeliveryUpdate(event.data);
      break;

    default:
      console.warn('unhandled event type:', event.type);
  }

  await db.inboxEvents.markProcessed(row.eventId);
}

Test your handler

The five cases below cover every failure mode your verification and replay logic must handle. They use local in-memory logic and require no running server or database—add them to CI and run them against every change to verify.js and replay.js:

// webhook.test.js — Node.js built-in assert
import assert             from 'node:assert/strict';
import { createHmac }     from 'node:crypto';
import { verifySignature } from './verify.js';
import { isReplay }        from './replay.js';

const SECRET   = 'test-only-secret';
const makeBody = (data) => Buffer.from(JSON.stringify(data));
const sign     = (buf)  => createHmac('sha256', SECRET).update(buf).digest('hex');

const body = makeBody({ type: 'new_message', id: 'evt_1', timestamp: Date.now() });

// 1. Valid signature — must pass
assert.ok(verifySignature(body, sign(body), SECRET),
  'Case 1: valid signature should pass');

// 2. Tampered body — digest changes, must fail
const fakeBody = makeBody({ type: 'new_message', id: 'evt_2', timestamp: Date.now() });
assert.ok(!verifySignature(fakeBody, sign(body), SECRET),
  'Case 2: tampered body should fail');

// 3. Wrong secret — must fail
assert.ok(!verifySignature(body, sign(body), 'wrong-secret'),
  'Case 3: wrong secret should fail');

// 4. First delivery passes; duplicate is blocked
const now = Date.now();
assert.ok(!isReplay('evt_x', now),  'Case 4a: first delivery should pass');
assert.ok( isReplay('evt_x', now),  'Case 4b: replay should be blocked');

// 5. Stale timestamp (6 minutes old) — must be rejected
assert.ok(isReplay('evt_z', Date.now() - 6 * 60_000),
  'Case 5: stale timestamp should be rejected');

console.log('All webhook handler tests passed.');

Each case maps to a real attack or failure mode: JSON tampering, key rotation errors, replay from a captured delivery, and clock-drift abuse. Add equivalent tests for the durable inbox upsert path using a test database transaction once your database client is in place.

Using Figment iMessage with this design

Figment's iMessage access is REST-based: read conversation history, search messages, send, and check receipt state through the Apple Connect API. To build event-driven behaviour today, poll the conversation list and message history endpoints on a schedule, then route matching events through your own receiver using the pattern above.

For each outbound send, persist a stable idempotency key and the exact payload before the first HTTP request. When the receipt state comes back as unknown, poll the receipt endpoint again using the same key—never resend under a new key. Receipt states can be submitted, delivered, read, failed, unknown, or simulated in sandbox.

Register your app and test the full send/receipt cycle in sandbox at Figment Apple Connect. A live Apple connection costs $29 per month per active connection; sandbox access is free, and access starts after setup. Manage your grants at /apple-connect/account.

Related reading


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms