← Back to Docs

How to Stop a Retried Webhook From Running Twice

To stop a retried webhook from running twice, give each job a unique key, record the key in your database when the work starts, and return 200 without doing anything if the key is already there. A retry then becomes harmless, even when the first attempt timed out from the sender's side but finished on yours.

Why a retry repeats work

The sender only knows what it got back, so it can't tell “the work failed” from “the work finished but the answer never arrived.” RunLater, for example, retries on any of these:

  • A timeout. Your handler took longer than the timeout (default 30 seconds). RunLater stops waiting, but your code can keep running to the end.
  • A non-2xx after the work. The invoice went out, then the next line threw and returned a 500.
  • A dropped connection between your handler finishing and the response arriving.

Make the handler safe to repeat

Put a key in the request body when you create the schedule, such as { "job_id": "invoice-1042" }. Then claim the key with a unique constraint before doing the work:

export async function POST(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.JOB_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  const { job_id } = await req.json();

  // The primary key on processed_jobs.id makes this claim atomic.
  const claim = db.prepare(
    'INSERT INTO processed_jobs (id) VALUES (?) ON CONFLICT (id) DO NOTHING'
  ).run(job_id);
  if (claim.changes === 0) return new Response('Already done', { status: 200 });

  try {
    await sendInvoice(job_id);
    return new Response('Done', { status: 200 });
  } catch (err) {
    // Release the claim so the retry can try again.
    db.prepare('DELETE FROM processed_jobs WHERE id = ?').run(job_id);
    return new Response('Failed', { status: 500 });
  }
}

Return a 2xx for a key you have already seen. RunLater retries a 4xx like any other failure, so answering a duplicate with 409 uses up every remaining attempt and marks the run as failed.

Recurring schedules need a key per run

RunLater sends the same URL, method, headers and body on every attempt and every run, with no attempt number or run ID added. A fixed key in the body would therefore block every run after the first. For a recurring job, build the key in your handler from the period the run belongs to:

// Daily job: all attempts on one UTC date share a key.
const key = `daily-report:${new Date().toISOString().slice(0, 10)}`;

Pick a period longer than the retries last, and don't schedule the job so close to the end of a period that a retry crosses into the next one.

How long retries last

Each wait doubles, starting from the retry delay (1 to 3600 seconds, default 60). This counts only the waits, not the time each attempt takes:

RetriesRetry delayLast attempt, after
3 (default)60 seconds7 minutes
560 seconds31 minutes
1060 secondsAbout 17 hours

Retries can overlap later runs. With the defaults on a */5 * * * * schedule, the last retry of the 9:00 run goes out around 9:07, after the 9:05 run has started.

Other ways to lower the risk

  • Answer fast. Queue the work and return a 2xx right away, so a slow job can't hit the timeout. The handler still needs the key.
  • Raise the timeout to fit the job, up to 300 seconds.
  • Turn retries off with retries: 0 when a missed run is better than a repeated one and the work can't be keyed.

The settings and what triggers a retry are in how retries work. RunLater Starter is $15/month for 250 scheduled requests and 25,000 runs, after a 14-day trial.

Frequently asked questions

What is an idempotent webhook?

One that has the same result whether it is delivered once or several times. The first delivery does the work, and later deliveries of the same job change nothing and return a success.

Should a duplicate webhook return 200 or 409?

Return a 2xx. Many senders treat anything else as a failure, and RunLater retries a 409 like any other non-2xx, so it would use up the remaining attempts on work that is already done.

Does RunLater send an idempotency key or attempt number?

No. Each attempt repeats the URL, method, headers and body you set, with the User-Agent RunLater/1.0. Put your own key in the body or build it in your handler.

Do retries count toward the monthly run limit?

Failed attempts do not. A run counts once, when an attempt succeeds, whether that is the first attempt or a retry.