# Handle iMessage Webhooks in Node.js

Canonical: https://figment.so/blog/imessage-node-webhooks

> **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:

```js
// 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](https://nodejs.org/api/crypto.html)).

`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:

```js
// 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:

```js
// 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.

```js
// 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:

```js
// 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:

```js
// 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`:

```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](/apple-connect/developers). 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](/apple-connect/account).

**Related reading**

- [OAuth for iCloud: Connect Apple Data to Your App](/oauth-for-icloud) — the grant model and scope structure behind the iMessage API
