Posthook with Express

Schedule durable timers from Express route handlers. No cron library, no Redis, no polling loop — just an API call and an endpoint.

In-memory timers — setTimeout, node-schedule, node-cron — do not survive deploys or restarts. BullMQ solves this with Redis and workers, but adds infrastructure for what is fundamentally a timing problem.

Posthook provides durable scheduling without the operational weight. Schedule a hook from any Express route handler, and receive the delivery at an Express endpoint when the time arrives. No Redis, no workers, no polling loop.

Install

npm install @posthook/node

Create the client:

import Posthook from '@posthook/node';

const posthook = new Posthook(process.env.POSTHOOK_API_KEY, {
  signingKey: process.env.POSTHOOK_SIGNING_KEY,
});

Schedule a hook

When an order is placed, schedule a payment timeout check for 30 minutes later:

app.post('/orders', async (req, res) => {
  const order = await db.createOrder(req.body);

  await posthook.hooks.schedule({
    path: '/webhooks/order-timeout',
    postIn: '30m',
    data: { orderId: order.id },
  });

  res.json({ orderId: order.id });
});

The hook is persisted by Posthook. It survives deploys, restarts, and scaling events.

Receive the delivery

The handler uses route-level express.raw() for signature verification, checks state, and decides whether to act:

app.post('/webhooks/order-timeout', express.raw({ type: '*/*' }), async (req, res) => {
  let delivery;
  try {
    delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  } catch {
    return res.sendStatus(401);
  }

  const order = await db.getOrder(delivery.data.orderId);

  if (order.status !== 'pending') {
    // Already paid, cancelled, or expired — safe to skip
    return res.json({ status: 'skipped' });
  }

  await db.expireOrder(order.id);
  await notifyCustomer(order, 'payment_timeout');
  res.json({ status: 'expired' });
});

The express.raw({ type: '*/*' }) middleware is applied to this route only, so req.body is a raw Buffer for signature verification. Your other routes can use express.json() normally.

The state check makes this handler idempotent. Posthook guarantees at-least-once delivery — the handler may receive the same delivery more than once. Since it checks order.status before acting, duplicates resolve as no-ops.

Retry override for time-sensitive work

Payment timeouts are more critical than marketing reminders. Use retryOverride to get more aggressive retries without changing project defaults:

await posthook.hooks.schedule({
  path: '/webhooks/order-timeout',
  postIn: '30m',
  data: { orderId: order.id },
  retryOverride: {
    minRetries: 5,
    delaySecs: 10,
    strategy: 'exponential',
    backoffFactor: 2.0,
    maxDelaySecs: 300,
  },
});

This hook retries up to 5 times with exponential backoff — 10s, 20s, 40s, 80s, 160s — while other hooks in the project use the default strategy. Per-hook retry overrides are set at scheduling time.

Local development

Use the Posthook CLI to forward hook deliveries to your local Express server — no deploy, no public URL:

npx posthook listen --forward http://localhost:3000

The CLI connects via WebSocket and forwards each delivery as an HTTP POST to your local server with the same headers and payload as production. It automatically accepts or fails hooks based on your handler’s response status (2xx = accept, anything else = fail and retry). Schedule hooks normally from your app — they arrive at your local endpoint.

Next steps

Frequently asked questions

Ready to get started?

Create your free account and start scheduling hooks in minutes. No credit card required.