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.

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.