Skip to main content

Idempotency

Idempotency ensures that the same logical operation produces exactly one effect, even if the request is retried, duplicated by a webhook, or triggered by a user double-clicking a button.

Flare offers idempotency on every write under /flare/v1/* (POST, PUT, PATCH, DELETE) via the standard Idempotency-Key HTTP header. When you replay the same request with the same key, the server returns the original response — same status code, same body — plus an Idempotent-Replay: true header so you know it was a replay rather than a fresh write.

Using the Idempotency-Key header​

Set Idempotency-Key to a value you generate per logical operation (a ULID, a UUID, or a deterministic string like payment-{orderId} — anything ≤ 200 characters that uniquely identifies the operation).

curl -X POST https://api.zeridion.com/flare/v1/jobs \
-H "Authorization: Bearer $FLARE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-order-12345" \
-d '{
"job_type": "ProcessPayment",
"payload": { "order_id": "order-12345", "amount_cents": 4999 }
}'

The TypeScript SDK exposes the same surface as a request option:

await flare.createJob(
{
job_type: "ProcessPayment",
payload: { order_id: "order-12345", amount_cents: 4999 },
},
{ idempotencyKey: "payment-order-12345" },
);

The .NET SDK uses IdempotencyKey on JobOptions:

await jobs.EnqueueAsync<ProcessPayment>(
new { OrderId = order.Id, Amount = order.Total },
new JobOptions
{
IdempotencyKey = $"payment:{order.Id}"
});

Replay semantics​

Three things can happen on the second request with the same Idempotency-Key:

ScenarioServer returnsWhat you see
Same key, same request bodyThe original status code and body, plus Idempotent-Replay: true headerThe first call's outcome (e.g. 201 Created with the same id) — no new job is enqueued.
Same key, different request body409 Conflict with code: idempotency_key_reuseThe SDK throws (FlareConflictException in .NET, ConflictError in TS/Python) — you reused a key for a different operation.
Key never seen beforeThe endpoint's normal responseTreated as a brand-new request.

The replay check is backed by a durable per-project idempotency store with a 24-hour TTL, keyed on (project, key). The request body's hash is stored alongside the cached response so a mismatched body on the same key can be detected. The store reserves the key for the first request in flight: when two concurrent requests race on the same key, the second receives a 409 Conflict with code idempotency_key_in_flight and should be retried once the original request completes. The reservation clears automatically after 30 seconds, so a stalled first request can't block the key indefinitely.

Important. Only successful (2xx) responses are cached for replay. Failed requests (4xx/5xx) are not stored — you must retry them as fresh requests, ideally with a new Idempotency-Key. A replay returns the original status code, not a fresh 200: if your first call returned 201 Created, replays return 201 Created plus the Idempotent-Replay: true header.

Detecting a replay​

Read the Idempotent-Replay response header:

const response = await fetch("/flare/v1/jobs", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": "payment-order-12345",
},
body: JSON.stringify({ job_type: "ProcessPayment", payload: { /* ... */ } }),
});

if (response.headers.get("Idempotent-Replay") === "true") {
// The original request already created this job; we just got its cached response back.
}

The SDK clients all expose this header on their response objects.

Handling 409 idempotency_key_reuse​

A 409 here means you reused an Idempotency-Key that's still in the 24-hour window for a different request body. Either you generated a non-unique key, or you genuinely want a different operation — in which case use a fresh key.

try
{
var jobId = await jobs.EnqueueAsync<ProcessPayment>(payload, new JobOptions
{
IdempotencyKey = $"payment:{order.Id}"
});
}
catch (FlareConflictException ex) when (ex.ErrorCode == "idempotency_key_reuse")
{
// The key was reused with a different payload. Inspect the existing job before retrying.
logger.LogWarning("Idempotency key {Key} was reused with a different body", $"payment:{order.Id}");
}
Legacy idempotency_key JSON body field on POST /jobs

For backwards compatibility, POST /flare/v1/jobs also accepts idempotency_key as a top-level JSON body field. The body-field path has the same replay semantics as the header (same body → original 201 Created + Idempotent-Replay: true; different body → 409 idempotency_key_reuse), but it is deprecated and will be removed in /flare/v2. New clients should use the Idempotency-Key header. The .NET SDK currently sends through the body-field path; it will switch to the header before /flare/v2 ships.

Key design patterns​

Choose a key structure that reflects the business operation you want to deduplicate:

ScenarioKey patternWhy
Payment processingpayment-{orderId}Prevent double-charging the same order
Webhook handlerswebhook-{eventId}Deduplicate webhook retries from Stripe, GitHub, etc.
User actionsaction-{userId}:{actionType}Prevent duplicate form submissions
Batch importsimport-{batchId}:{rowIndex}Ensure each row in a batch is processed exactly once
Scheduled reportsreport-{reportType}:{date}One report per type per day
Notification dedupnotify-{userId}:{eventType}:{eventId}One notification per event per user

Naming conventions​

  • Use a descriptive prefix (payment:, webhook:, import:) so keys are self-documenting in the database
  • Include the minimum set of identifiers needed to uniquely represent the operation
  • Keep keys under 200 characters (server validation limit)

Key scoping​

Idempotency keys are scoped per project (identified by your API key). Two different projects can use the same key string without conflict:

ProjectIdempotencyKeyResult
Project Apayment:123Created
Project Bpayment:123Created (different project, no conflict)
Project Apayment:123Rejected (409, duplicate in same project)

Key lifetime​

Idempotency records expire after 24 hours — a key used today is eligible for reuse 24 hours later. Replay semantics only apply within that window. For operations that should be repeatable on a fixed cadence (daily reports, hourly syncs) regardless of the 24-hour TTL, include a temporal component in the key so each occurrence has its own unique value:

// Reusable daily: one report per day
new JobOptions { IdempotencyKey = $"daily-report:{DateTime.UtcNow:yyyy-MM-dd}" }

// Reusable hourly: one sync per hour
new JobOptions { IdempotencyKey = $"sync:{DateTime.UtcNow:yyyy-MM-dd-HH}" }

When to use idempotency keys​

Use them when duplicates are harmful:

  • Payment processing (double-charges)
  • Account creation (duplicate accounts)
  • Webhook processing (provider retries)
  • Critical business events (order placement, subscription changes)

Skip them when duplicates are acceptable:

  • Fire-and-forget email notifications (sending twice is harmless)
  • Analytics event tracking (duplicates are filtered downstream)
  • Cache warming jobs (re-running is cheap and safe)
  • Retry-safe jobs where the work itself is idempotent

Making job execution idempotent​

Idempotency keys prevent duplicate job creation, but your job's ExecuteAsync may still run more than once due to retries after transient failures. Make sure the job body is also idempotent:

Database upserts​

Use INSERT ... ON CONFLICT UPDATE or EF Core's ExecuteUpdateAsync instead of plain inserts:

public async Task ExecuteAsync(SyncPayload payload, JobContext ctx)
{
await _db.Customers
.Where(c => c.ExternalId == payload.ExternalId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.Name, payload.Name)
.SetProperty(c => c.Email, payload.Email),
ctx.CancellationToken);
}

Check-before-write​

For operations with side effects (sending emails, calling external APIs), check if the work was already done:

public async Task ExecuteAsync(SendReceiptPayload payload, JobContext ctx)
{
var alreadySent = await _db.ReceiptLog
.AnyAsync(r => r.OrderId == payload.OrderId, ctx.CancellationToken);

if (alreadySent)
{
ctx.Logger.LogInformation("Receipt already sent for order {OrderId}", payload.OrderId);
return;
}

await _emailService.SendReceiptAsync(payload, ctx.CancellationToken);

_db.ReceiptLog.Add(new ReceiptLogEntry { OrderId = payload.OrderId });
await _db.SaveChangesAsync(ctx.CancellationToken);
}

External API idempotency​

Many external APIs (Stripe, Twilio, etc.) accept their own idempotency keys. Pass a deterministic key derived from your job context:

public async Task ExecuteAsync(ChargePayload payload, JobContext ctx)
{
await _stripe.Charges.CreateAsync(new ChargeCreateOptions
{
Amount = payload.AmountCents,
Currency = "usd",
Source = payload.SourceToken,
}, new RequestOptions
{
IdempotencyKey = $"flare-{ctx.JobId}"
});
}

Using ctx.JobId as the downstream idempotency key ensures that retries of the same Flare job produce the same Stripe charge.

See also​