How to Schedule Webhooks Without Running Cron Jobs
Cron is still useful. It is just the wrong primitive for most customer-specific webhook scheduling.
A cron job is great when the question is: "Run this report every day at 02:00." It becomes fragile when the question is: "For this customer, send this HTTPS request 19 days from now, retry it if their endpoint is down, show support what happened, and do not call a private network address by accident."
That second problem is not a timer problem. It is a delivery system.
If you are building onboarding reminders, failed-payment follow-ups, delayed CRM syncs, trial expiration checks, renewal notices, or product lifecycle callbacks, the schedule belongs to the user or the event, not to the server. That changes the design.
The simple version works until it matters
The first implementation usually looks like this:
// Every minute, look for due jobs.
const jobs = await db.job.findMany({
where: {
status: "PENDING",
runAt: { lte: new Date() },
},
take: 100,
});
for (const job of jobs) {
await fetch(job.url, {
method: job.method,
headers: job.headers,
body: JSON.stringify(job.payload),
});
}
That is enough for a demo. It is not enough for production.
The moment two workers run at the same time, the same job can be delivered twice. The moment an endpoint times out, you need retry policy. The moment a customer asks what happened to a delivery, you need attempts, response codes, latency, response body limits, and a clean dashboard. The moment users provide destination URLs, you need SSRF protection.
Cron only wakes something up. It does not give you a delivery contract.
Cron vs queue vs webhook scheduler
| Approach | Good fit | Where it breaks | Operational burden |
|---|---|---|---|
| Single cron job | Fixed internal maintenance tasks | Per-user schedules, retries, visibility, multi-worker safety | Low at first, hidden later |
| Database polling worker | Simple delayed jobs in one app | Locking, drift, duplicate delivery, scaling queries | Medium |
| Cloud queue with delayed tasks | Teams comfortable owning infra | Security policy, dashboard, customer-facing logs, support tooling | Medium to high |
| Workflow engine | Multi-step business processes | Overkill for "call this URL later" | Medium |
| Webhook scheduling API | Future HTTP delivery with logs and retries | Not a general compute platform | Low |
The important distinction is this: scheduling an HTTP call is not the same as running arbitrary background code.
If the work is "send this request later and record what happened," a webhook scheduler is a much smaller surface area than a full workflow engine.
The production model
A reliable scheduled webhook system needs five pieces.
First, a durable job record. It should store the destination URL, method, headers, payload, run time, organization, status, retry count, and idempotency key if your API exposes one.
Second, an atomic claim. A worker should move a job from pending to processing in one conditional database update or queue claim. A read-then-send pattern is how duplicate deliveries happen.
Third, strict destination validation. If customers provide URLs, only public HTTPS destinations should be allowed. Internal IPs, localhost, link-local addresses, cloud metadata endpoints, unsafe redirects, and rebinding tricks should be rejected.
Fourth, delivery attempts. Every try should create an attempt record with status code, latency, error class, truncated response body, and timestamp. Support needs facts, not guesses.
Fifth, retry policy. Transient failures should retry with backoff. Permanent failures should stop. Exhausted jobs should become inspectable failures, not disappear into logs.
A clean API shape
A scheduling API should be boring to call:
curl -X POST https://webhookscheduler.com/api/v1/schedule \
-H "Authorization: Bearer wh_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/onboarding",
"method": "POST",
"runAt": "2026-05-21T09:00:00.000Z",
"idempotencyKey": "user_123:onboarding_day_7",
"headers": {
"X-Event-Type": "onboarding.day_7"
},
"body": {
"userId": "user_123",
"plan": "trial"
}
}'
That request should return a job ID and scheduled status. Later, the dashboard or API should show whether the job is pending, processing, delivered, failed, canceled, or retried.
The API is the easy part. The hard part is everything around it: locks, retries, logs, quotas, and safe dispatch.
Where cron gets expensive
Cron becomes expensive when it forces you to rebuild product infrastructure around it.
A few examples:
- You need customer support to inspect why a webhook failed.
- You need to cancel a scheduled callback after a user completes onboarding.
- You need to avoid firing the same billing callback twice.
- You need to retry 5xx responses, timeouts, and temporary 429s without retrying permanent 4xx failures forever.
- You need to prove that a job was attempted at a specific time.
- You need to stop users from scheduling calls to internal addresses.
- You need to keep the dashboard fast when there are thousands or millions of jobs.
Each requirement is reasonable. Together, they turn "just cron" into a small infrastructure product.
That is fine if infrastructure is the business. It is wasteful if the scheduler is only supporting your real product.
A practical checklist
Before you ship scheduled webhooks, make sure you can answer these questions:
- Can two workers ever deliver the same pending job at the same time?
- What happens if the destination times out after receiving the payload?
- Which status codes retry, and which ones fail permanently?
- How many attempts are stored, and for how long?
- Can a customer inspect status code, latency, and response body safely?
- Can a user cancel a pending job before it fires?
- Are destination URLs restricted to public HTTPS?
- Are redirects blocked or revalidated?
- Are payloads and headers size-limited?
- Is the dashboard paginated and searchable?
- Can billing or support see usage without reading sensitive payloads unnecessarily?
If the answer is "we will add that later," later usually arrives during an incident.
When to build it yourself
Build your own scheduler if the job is deeply tied to internal state, needs private network access, or runs arbitrary compute inside your own application. A managed webhook scheduler should not try to become your whole job system.
You should also build it yourself if you already operate a queue platform, have alerting and dashboards in place, and the engineering cost is clearly lower than paying for a focused external tool.
But if your need is future HTTPS delivery with retries and visibility, the managed path is often cleaner.
One managed option is Webhook Scheduler. Disclosure: We built Webhook Scheduler for this exact use case. It is for teams that want scheduled HTTPS delivery with retries and delivery logs, not a general workflow engine.
If you are evaluating that path, start with the API docs. The more important decision is architectural: do you want to own the delivery layer, or is the scheduler only supporting the product you actually sell?
Common mistakes
The most common mistake is treating delivery as binary. A webhook was not simply "sent" or "not sent." It had an attempt time, a latency, a status code, maybe a timeout, maybe a retry, and maybe a response body that explains the failure.
The second mistake is retrying everything. A 500, timeout, or 429 can be transient. A 401 or 404 usually needs customer action. Retrying permanent failures only creates noise.
The third mistake is forgetting that receivers must be idempotent. Even a perfect scheduler cannot guarantee that the receiver did not process a request and then drop the connection before responding. If the action matters, include stable event IDs or idempotency keys.
The fourth mistake is exposing unsafe network access. If users can enter arbitrary URLs, you are building an outbound request system. Treat it like one.
The sharper rule
Use cron for fixed internal routines.
Use a queue for internal background work.
Use a workflow engine for multi-step business processes.
Use a webhook scheduler when your product needs to call an external HTTPS endpoint later and show exactly what happened.
That clarity matters. It keeps the scheduler from becoming a disguised platform project, and it keeps product teams focused on the customer-facing system they actually meant to build.
Related reading: Webhook scheduling topic hub and Webhook Retry Logic: Exponential Backoff, Idempotency, and Dead Letter Queues.
Not sure which tools to pick?
Answer 7 questions and get a personalized stack recommendation with cost analysis - free.
Try Stack AdvisorEnjoyed this?
One email per week with fresh thinking on tools, systems, and engineering decisions. No spam.

