Schedule Webhooks from Next.js
Last updated: March 30, 2026
Serverless functions are stateless. You cannot use setTimeout, node-schedule, or in-process workers in a Next.js app deployed to Vercel or any serverless environment. The function exits after the response and any timers are lost.
Posthook handles the scheduling. One API call to schedule a hook from any server-side code (API route, server action, middleware), and the delivery arrives at a Next.js API route when the time comes.
Install
npm install @posthook/node
Create a shared client. The lazy singleton pattern avoids throwing during next build when environment variables are not yet available:
// lib/posthook.ts
import Posthook from '@posthook/node';
let _client: Posthook | null = null;
export function posthook(): Posthook {
if (!_client) {
_client = new Posthook(process.env.POSTHOOK_API_KEY!, {
signingKey: process.env.POSTHOOK_SIGNING_KEY!,
});
}
return _client;
}
Why not
export const posthook = new Posthook(...)? The SDK validates the API key at construction time. Duringnext build, environment variables are not set, so top-level instantiation throws and the build fails. The lazy singleton defers construction to the first request.
Schedule a hook
When a user signs up, schedule an onboarding reminder for 48 hours later:
// app/api/signup/route.ts
import { posthook } from '@/lib/posthook';
export async function POST(req: Request) {
const { userId, email } = await req.json();
await db.createUser({ userId, email });
const hook = await posthook().hooks.schedule({
path: '/api/webhooks/onboarding-reminder',
postIn: '48h',
data: { userId },
});
// Optionally store hook.id to cancel later
await db.updateUser(userId, { onboardingHookId: hook.id });
return Response.json({ ok: true });
}
The hook is persisted by Posthook. It survives deploys, restarts, and scaling events. When the time arrives, Posthook delivers the payload to your endpoint.
Receive the delivery
The handler is a standard Next.js API route. It verifies the signature, checks state, and decides whether to act:
// app/api/webhooks/onboarding-reminder/route.ts
import { posthook } from '@/lib/posthook';
import { SignatureVerificationError } from '@posthook/node';
export const runtime = 'nodejs'; // SDK uses node:crypto for signature verification
export async function POST(req: Request) {
const body = await req.text();
let delivery;
try {
delivery = posthook().signatures.parseDelivery(body, req.headers);
} catch (err) {
if (err instanceof SignatureVerificationError) {
return Response.json({ error: err.message }, { status: 401 });
}
return Response.json({ error: 'Internal error' }, { status: 500 });
}
const user = await db.getUser(delivery.data.userId);
if (user.onboardingCompleted) {
return Response.json({ status: 'skipped' });
}
await sendOnboardingReminder(user);
return Response.json({ status: 'sent' });
}
posthook().signatures.parseDeliveryverifies the request signature using the signing key from the client and returns the typed payload. If the signature is invalid or the timestamp is too old, it throws — the request is rejected before your handler logic runs.
The state check is the key pattern. Posthook guarantees at-least-once delivery. Your handler guarantees safe execution by checking state before acting. If the user already completed onboarding, the handler returns success and does nothing.
Cancellation
If the user completes onboarding before the 48-hour timer fires, the handler already handles this correctly — it checks state and skips. But you can also cancel the hook early to avoid the unnecessary delivery:
// When the user completes onboarding
await posthook().hooks.delete(user.onboardingHookId);
Cancellation is a cleanup optimization, not a correctness mechanism. The handler should be safe to run regardless of whether the hook was cancelled.
Local development
Use the Posthook CLI to forward hook deliveries to your local Next.js dev 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.
Timezone-aware scheduling
For reminders that should arrive at a specific time in the user’s timezone:
await posthook().hooks.schedule({
path: '/api/webhooks/trial-reminder',
postAtLocal: '2026-03-15T09:00:00',
timezone: user.timezone, // e.g., 'America/New_York'
data: { userId: user.id },
});
postAtLocal handles DST transitions automatically. A reminder scheduled for 9am Eastern stays at 9am Eastern whether the clocks have changed or not.
Next steps
- Clone the starter — Dashboard UI, webhook handlers, and scheduling patterns ready to deploy
- Try the live demo — see the patterns in action
- Quickstart guide — full setup walkthrough
- Reminders and follow-ups — the complete pattern with DIY comparison
- Expiration and timeout checks — payment holds, trial expiry, invitation deadlines
- Pricing — free plan includes 1,000 hooks/month
Frequently asked questions
Ready to get started?
Free plan includes 1,000 hooks/month. No credit card required.