Schedule Webhooks from Express

Schedule webhooks from Express route handlers. Retries, delivery tracking, and alerting are built in. No BullMQ, no Redis, no polling loop.

Last updated: March 30, 2026

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 handles the scheduling. One API call to schedule a hook from any Express route handler, and the delivery arrives at an Express endpoint when the time comes. 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:

import { SignatureVerificationError } from '@posthook/node';

app.post('/webhooks/order-timeout', express.raw({ type: '*/*' }), async (req, res) => {
  let delivery;
  try {
    delivery = posthook.signatures.parseDelivery(req.body.toString(), req.headers);
  } catch (err) {
    if (err instanceof SignatureVerificationError) {
      return res.status(401).json({ error: err.message });
    }
    return res.status(500).json({ error: 'Internal error' });
  }

  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. The .toString() converts it to the string that parseDelivery expects 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. Your handler’s response determines the outcome: 200 means delivered, 202 starts an async hook flow, and anything else triggers a retry. Retries work the same way as in production, with the same backoff and attempt limits.

Next steps

Frequently asked questions

Ready to get started?

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