Skip to main content

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);
}
tip

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:

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 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