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:
| Scenario | Server returns | What you see |
|---|---|---|
| Same key, same request body | The original status code and body, plus Idempotent-Replay: true header | The first call's outcome (e.g. 201 Created with the same id) — no new job is enqueued. |
| Same key, different request body | 409 Conflict with code: idempotency_key_reuse | The SDK throws (FlareConflictException in .NET, ConflictError in TS/Python) — you reused a key for a different operation. |
| Key never seen before | The endpoint's normal response | Treated 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 uses first-writer-wins semantics: when two concurrent requests race on the same key, the second writer is silently skipped instead of erroring, and the duplicate-detection branch reads the original entry without locking.
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 returned201 Created, replays return201 Createdplus theIdempotent-Replay: trueheader.
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}");
}
:::note 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:
| Scenario | Key pattern | Why |
|---|---|---|
| Payment processing | payment:{orderId} | Prevent double-charging the same order |
| Webhook handlers | webhook:{eventId} | Deduplicate webhook retries from Stripe, GitHub, etc. |
| User actions | action:{userId}:{actionType} | Prevent duplicate form submissions |
| Batch imports | import:{batchId}:{rowIndex} | Ensure each row in a batch is processed exactly once |
| Scheduled reports | report:{reportType}:{date} | One report per type per day |
| Notification dedup | notify:{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:
| Project | IdempotencyKey | Result |
|---|---|---|
| Project A | payment:123 | Created |
| Project B | payment:123 | Created (different project, no conflict) |
| Project A | payment:123 | Rejected (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
- JobOptions —
IdempotencyKeyproperty reference (.NET) - Error Handling — catching
FlareConflictException - Error Responses —
idempotency_key_reuseerror code