Idempotency
Idempotency ensures that the same logical operation produces exactly one job, even if the enqueue call is retried, duplicated by a webhook, or triggered by a user double-clicking a button.
Using IdempotencyKey
Set IdempotencyKey on JobOptions when enqueuing a job:
await jobs.EnqueueAsync<ProcessPayment>(
new { OrderId = order.Id, Amount = order.Total },
new JobOptions
{
IdempotencyKey = $"payment:{order.Id}"
});
If a job with the same IdempotencyKey already exists for this project, the API returns 409 Conflict with error code idempotency_conflict, and the SDK throws FlareConflictException.
How it works
When you enqueue a job with an idempotency key, the API checks for an existing job with the same (ProjectId, IdempotencyKey) pair before creating a new one:
The check is backed by a filtered unique index (IX_Jobs_Idempotency) on (ProjectId, IdempotencyKey) where the key is non-null, so the duplicate detection is atomic and race-condition-free.
Handling the conflict
A 409 idempotency_conflict is expected behavior, not an error. The original job already exists and will be processed. Catch it and log:
try
{
var jobId = await jobs.EnqueueAsync<ProcessPayment>(payload, new JobOptions
{
IdempotencyKey = $"payment:{order.Id}"
});
logger.LogInformation("Enqueued payment job {JobId}", jobId);
}
catch (FlareConflictException ex) when (ex.ErrorCode == "idempotency_conflict")
{
logger.LogInformation(
"Payment job for order {OrderId} already exists — duplicate prevented",
order.Id);
}
If you don't need the job ID back and only want to ensure the job is enqueued at-most-once, you can wrap the call in a helper that swallows the conflict:
public static async Task EnqueueIdempotentAsync<TJob>(
this IJobClient jobs, object payload, string idempotencyKey)
where TJob : class
{
try
{
await jobs.EnqueueAsync<TJob>(payload, new JobOptions
{
IdempotencyKey = idempotencyKey
});
}
catch (FlareConflictException) { }
}
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 keys persist as long as the job exists in the database. There is no automatic expiry — a key used to create a job today will still block a duplicate next month.
For operations that should be repeatable over time, include a temporal component in the key:
// 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 - Error Handling — catching
FlareConflictException - Error Responses —
idempotency_conflicterror code