How to schedule a webhook on Next.js and Vercel
You're building on Next.js, deployed to Vercel, and you need to call one of your own endpoints at a specific future moment — three days from now, an hour after signup, the morning a trial ends. On a traditional server you'd reach for a cron job or an in-process timer. On Vercel, neither works the way you'd expect. This guide shows the approaches that fail, why they fail, and the clean pattern that works.
The two approaches that don't work
setTimeout in a route handler
The instinct is to schedule the call inline:
// app/api/checkout/route.ts
export async function POST(req: Request) {
// ... handle checkout ...
// ❌ never runs
setTimeout(() => {
fetch("https://api.myapp.com/send-reminder");
}, 3 * 24 * 60 * 60 * 1000);
return Response.json({ ok: true });
}
A serverless function exists only to produce a response. The moment you return, Vercel freezes or discards the instance — the timer goes with it. Even a few-second delay is unreliable; a three-day one is hopeless. (See why cron doesn't work in serverless for the underlying reason.)
Vercel Cron Jobs
Vercel Cron is real and useful — but it fires on a fixed recurring schedule you define in vercel.json:
{
"crons": [{ "path": "/api/digest", "schedule": "0 9 * * *" }]
}
That's perfect for "every day at 9am." It is not a way to say "call this URL once, at 2026-07-01T14:32:00Z, for this one user." The schedule is static config, the granularity is one minute, and there's a limit on how many you can have. The fire time you need is computed at runtime and different for every user — it can't live in vercel.json.
What you actually need
For a one-off call at an arbitrary future time, something durable and always-on has to hold the job until it's due. You have three honest options:
- A database + a one-minute Vercel Cron poller. Store
{ run_at, url, payload }rows; the cron job sweeps for due rows each minute. Works, but you inherit up to a minute of slack and a table to maintain. - A delayed message queue (SQS, QStash, BullMQ on your own Redis). Durable and precise, but it's infrastructure to wire up and pay for just to "call a URL later."
- A dedicated webhook scheduler that takes the timestamp, URL, and payload and fires the signed HTTP call for you. Nothing to host.
The rest of this guide uses the third option with SendItWhenever, because it's the least code, but the receiving pattern is identical for any of them.
Step 1 — Install the SDK
npm install @sendithq/sdk
Add your API key to .env.local:
SENDIT_API_KEY=live_...
SENDIT_SIGNING_SECRET=whsec_...
Step 2 — Schedule from a route handler
Anywhere in your app — a checkout handler, a signup action, a form submission — compute the fire time and hand off the job. This runs as part of the normal request, so it completes before the function returns:
// app/api/start-trial/route.ts
import { SendIt } from "@sendithq/sdk";
const sendit = new SendIt(process.env.SENDIT_API_KEY!);
export async function POST(req: Request) {
const { userId, email } = await req.json();
// fire 3 days from now — an arbitrary, per-user future second
const fireAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
await sendit.schedule({
url: "https://your-app.vercel.app/api/reminders/trial-ending",
fireAt,
payload: { userId, email },
idempotencyKey: `trial-reminder:${userId}`, // safe to retry
});
return Response.json({ ok: true });
}
The idempotencyKey means that if this handler runs twice (a retry, a double-click), you still only get one scheduled reminder.
Step 3 — Receive and verify the webhook
At the scheduled second, the scheduler POSTs your payload back to the receiver URL with an HMAC signature in the X-SendIt-Signature header. Verify it before trusting the request — and verify against the raw body, because parsing to JSON and re-serializing changes the bytes and breaks the signature.
In the App Router, read the raw text yourself:
// app/api/reminders/trial-ending/route.ts
import { SendIt } from "@sendithq/sdk";
const sendit = new SendIt(process.env.SENDIT_API_KEY!, {
signingSecret: process.env.SENDIT_SIGNING_SECRET!,
});
export async function POST(req: Request) {
const rawBody = await req.text(); // exact bytes — do not JSON.parse first
const ok = sendit.verifySignature({
headers: Object.fromEntries(req.headers),
rawBody,
});
if (!ok) return new Response("bad signature", { status: 401 });
const { userId, email } = JSON.parse(rawBody);
// …send the "your trial ends soon" email
return Response.json({ ok: true });
}
That's the whole loop: schedule during a request, receive a signed call at the right second.
A note on localhost
A scheduler runs on the public internet, so it can't POST to http://localhost:3000. While developing, expose your machine with a tunnel (ngrok http 3000, cloudflared tunnel) and use the public URL as your receiver. See Local Development & Tunnels for the full pattern.
What you get beyond the happy path
The reason to use a dedicated scheduler rather than a homegrown poller is everything around the fire:
- Retries with backoff. A failed delivery retries up to five times with exponential backoff before landing in a Dead Letter Queue you can inspect.
- Signed, every time. Every delivery carries an HMAC signature, so your receiver can reject anything that isn't genuinely from the scheduler.
- Encryption at rest. Payloads are encrypted with AES-256-GCM while they wait.
- Second-level precision. Firing is precise to the second, with Early Firing to offset network round-trip time.
For recurring jobs, Vercel Cron is the right tool. For a one-off call at a per-user future timestamp, hand the job to something that's built to hold it and fire it — and keep your route handlers stateless, the way serverless wants them.