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:

StatusScheduler behaviour
2xxSuccess — failure counter reset, next occurrence scheduled.
404 / 410Terminal endpoint failure — auto-paused after 1 failure.
400 / 401 / 403Likely configuration failure — auto-paused after 2 consecutive failures.
408 / 429 / 5xxTransient 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. The schedule detail page and notification email identify the response that opened the circuit breaker so you can edit the webhook and resume safely.

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.