Idempotent Webhooks
rrule.net makes one normal attempt for each due webhook occurrence. In rare cases — such as a worker restart at the wrong moment — your endpoint may receive the same scheduled occurrence twice. This guide explains how to handle that safely in a few lines of code.
Why can redelivery happen?
The scheduler sends a webhook over the network. Two things can go wrong:
- 1. A worker can stop after your endpoint processes the request but before the successful result is persisted.
- 2. A stale or internally failed reservation can be released, allowing the same occurrence to be reserved again.
In both cases, your handler may be called twice for the same occurrence. A normal HTTP failure is recorded as failed and currently advances the schedule; rare redelivery is primarily crash and internal-error recovery. If your handler sends an email or charges a card, the distinction still matters.
Attempt ID vs occurrence key
Every webhook delivery carries a unique identifier for that individual attempt: the X-RRule-Execution-Id header. It is also present in the request body as execution_id. A redelivery of the same occurrence receives a new execution ID. Build the stable idempotency key from schedule_id, the target id (or default when absent), and scheduled_for.
POST https://your-app.com/webhook
User-Agent: rrule.net-scheduler/1.0
X-RRule-Execution-Id: 7f3a1c2d-4e5b-6f7a-8b9c-0d1e2f3a4b5c
X-RRule-Scheduled-For: 2026-03-10T17:00:00.000Z
X-RRule-Schedule-Id: a1b2c3d4-...
X-RRule-Target-Id: paris
Content-Type: application/json
{
"schedule_id": "a1b2c3d4-...",
"execution_id": "7f3a1c2d-4e5b-6f7a-8b9c-0d1e2f3a4b5c",
"scheduled_for": "2026-03-10T17:00:00.000Z",
"executed_at": "2026-03-10T17:00:02.341Z",
"timezone": "Europe/Paris",
"target": {
"id": "paris",
"label": "Paris customers",
"timezone": "Europe/Paris",
"metadata": { "market": "fr" }
},
"input": {
"type": "targets",
"value": "Monthly Paris customer notification"
}
}The scheduler considers a delivery successful when your endpoint returns any 2xx status code within 30 seconds. The execution ID remains useful for tracing and support, but it is not a cross-attempt deduplication key.
The pattern: store before act
The core idea is simple: atomically claim the occurrence tuple before doing any work. On another delivery, the same tuple conflicts even though its execution ID differs; return 200 OK immediately so the business action is not repeated.
┌─────────────────────────────────────────────────────┐
│ Receive webhook │
│ │
│ 1. Read schedule_id, target_id, scheduled_for │
│ 2. Atomically insert that occurrence tuple │
│ → Conflict: return 200, do nothing │
│ → Inserted: claim accepted │
│ 3. Commit business state or a durable outbox job │
│ 4. Return 200 │
└─────────────────────────────────────────────────────┘The claim must use an atomic insert with a unique constraint — not a read-then-write — to be safe under concurrent requests. Put database-side business changes in the same transaction; use a transactional outbox or durable queue for email, payments, and other external effects.
Examples
Node.js / TypeScript (Hono + Postgres)
Uses a composite unique constraint on the stable occurrence tuple. The execution ID is retained only for tracing the first accepted attempt.
-- Migration: one-time setup
CREATE TABLE processed_schedule_occurrences (
schedule_id TEXT NOT NULL,
target_id TEXT NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
first_execution_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (schedule_id, target_id, scheduled_for)
);
-- Optional: clean up old records after 30 days
CREATE INDEX ON processed_schedule_occurrences (processed_at);// webhook.ts
import { Hono } from 'hono'
import { sql } from './db'
const app = new Hono()
app.post('/webhook', async (c) => {
const scheduleId = c.req.header('X-RRule-Schedule-Id')
const scheduledFor = c.req.header('X-RRule-Scheduled-For')
const targetId = c.req.header('X-RRule-Target-Id') || 'default'
const executionId = c.req.header('X-RRule-Execution-Id')
if (!scheduleId || !scheduledFor || !executionId) {
return c.text('Missing rrule.net delivery context', 400)
}
const body = await c.req.json()
// Claim and durable business work share one DB transaction.
const accepted = await sql.begin(async (tx) => {
const claimed = await tx`
INSERT INTO processed_schedule_occurrences (
schedule_id, target_id, scheduled_for, first_execution_id
)
VALUES (${scheduleId}, ${targetId}, ${scheduledFor}, ${executionId})
ON CONFLICT (schedule_id, target_id, scheduled_for) DO NOTHING
RETURNING schedule_id
`
if (claimed.length === 0) return false
// Transactional outbox: a separate worker performs the external effect.
await enqueueEmailJob(tx, { scheduleId, targetId, body })
return true
})
if (!accepted) {
// Same occurrence, possibly a different execution ID.
return c.text('Already processed', 200)
}
return c.text('OK', 200)
})Python (FastAPI + SQLAlchemy)
Same pattern, using PostgreSQL's ON CONFLICT DO NOTHING.
from fastapi import FastAPI, Request, Header
from sqlalchemy import text
from db import engine # your SQLAlchemy engine
app = FastAPI()
@app.post("/webhook")
async def handle_webhook(
request: Request,
x_rrule_schedule_id: str = Header(...),
x_rrule_scheduled_for: str = Header(...),
x_rrule_execution_id: str = Header(...),
x_rrule_target_id: str | None = Header(default=None)
):
target_id = x_rrule_target_id or "default"
body = await request.json()
async with engine.begin() as conn:
result = await conn.execute(
text("""
INSERT INTO processed_schedule_occurrences (
schedule_id, target_id, scheduled_for, first_execution_id
)
VALUES (:schedule_id, :target_id, :scheduled_for, :execution_id)
ON CONFLICT (schedule_id, target_id, scheduled_for) DO NOTHING
RETURNING schedule_id
"""),
{
"schedule_id": x_rrule_schedule_id,
"target_id": target_id,
"scheduled_for": x_rrule_scheduled_for,
"execution_id": x_rrule_execution_id,
}
)
if result.rowcount == 0:
return {"status": "already_processed"}
# Enqueue external work durably in this same transaction.
await enqueue_email_job(conn, body)
return {"status": "ok"}Without a database (Redis SET NX)
Redis SET NX provides atomic duplicate suppression. For crash-safe processing, claim and enqueue work atomically with a Lua script or use a durable stream/queue.
import { createClient } from 'redis'
const redis = createClient()
app.post('/webhook', async (req, res) => {
const scheduleId = req.headers['x-rrule-schedule-id']
const targetId = req.headers['x-rrule-target-id'] || 'default'
const scheduledFor = req.headers['x-rrule-scheduled-for']
const occurrenceKey = `${scheduleId}:${targetId}:${scheduledFor}`
// SET only if Not eXists — atomic, race-condition safe
// Keep the key longer than your maximum duplicate-risk window.
const isNew = await redis.set(
`rrule-occurrence:${occurrenceKey}`,
'1',
{ NX: true, EX: 60 * 60 * 24 * 7 }
)
if (!isNew) {
return res.status(200).send('already processed')
}
// This simple example suppresses duplicates, but SET + enqueue is not one
// atomic operation. Use Lua or a durable stream/queue when loss is unacceptable.
await enqueueEmailDurably(req.body)
res.status(200).send('ok')
})What to return
The scheduler interprets the response status as follows:
| Status | Scheduler behaviour |
|---|---|
| 2xx | Success — failure counter reset, next occurrence scheduled. |
| 404 / 410 | Terminal endpoint failure — auto-paused after 1 failure. |
| 400 / 401 / 403 | Likely configuration failure — auto-paused after 2 consecutive failures. |
| 408 / 429 / 5xx | Transient failure — counter incremented, auto-paused after the configured threshold. |
| Timeout (> 30s) | Treated as transient failure. |
Always return 200 for already-processed deliveries. Returning 4xx or 5xx marks the delivery as failed and increments the failure counter, which could eventually auto-pause your schedule.
rrule.net sends schedule, target, scheduled-time, and attempt context on every delivery. A unique constraint on the stable occurrence tuple prevents the same scheduled action from being accepted twice.