Idempotency and retries
Paylera.Sdk ships sensible defaults for both idempotency-key stamping
and HTTP retries. You get safe behaviour without configuration; you can
override both when you need to.
Idempotency-Key auto-stamping
Every state-changing call (POST, PUT, PATCH, DELETE) carries an
Idempotency-Key header. If the caller doesn’t supply one, the SDK
stamps a fresh UUID v7 in the auth handler before the request leaves
the process.
// Auto-stamped: the SDK generates a UUID v7 for you.await client.Api.CreateCustomerAsync(idempotency_Key: Guid.NewGuid().ToString(), body);
// Explicit: pass your own key for client-side deduplication.var key = $"signup:{userId}";await client.Api.CreateCustomerAsync(idempotency_Key: key, body);Why UUID v7
UUID v7 embeds a millisecond timestamp in the high bits, so:
- Keys sort naturally by creation time, which keeps Paylera’s internal idempotency cache index-friendly.
- Burst-generation collisions are vanishingly unlikely — 12 bits of per-millisecond randomness plus 62 bits of high-entropy randomness.
- Logs sort the way humans expect, which makes “find this call by request id” cheaper at incident time.
If you’re on a stack without native UUID v7, the SDK provides
PayleraIdempotency.NewKey() which emits a string of the right shape:
var key = PayleraIdempotency.NewKey();// "01985f3c-9af3-7e5b-bdc1-30f0c6210e1f"Idempotency on the wire
Paylera’s idempotency middleware caches the response for 24 hours.
A retry with the same key returns the cached response — 200 OK is
the success path, 409 Conflict is what you get when the same key is
replayed with a different body (treat that as a programming bug).
The typed-exception hierarchy maps these to:
| Status | Exception |
|---|---|
409 with paylera.idempotency_conflict | PayleraIdempotencyConflictException |
409 with paylera.idempotency_in_progress | PayleraIdempotencyInProgressException |
IdempotencyInProgressException means another request with the same
key is still executing — retry after a short delay.
Retries
A Polly v8 pipeline is wired into the SDK’s HttpClientFactory
registration. Defaults:
| Condition | Behaviour |
|---|---|
HttpRequestException (transport) | retry |
408 Request Timeout | retry |
429 Too Many Requests | retry, honouring Retry-After |
5xx | retry |
4xx other than 408/429 | no retry |
| Max attempts | 5 (1 initial + 4 retries) |
| Backoff | exponential with full jitter, starting at 100 ms |
The retry handler sits inside the problem-details handler. By the time
your try/catch sees a typed PayleraException, retries are done —
you get the final outcome, not a transient 503 mid-pipeline.
Why retries respect idempotency keys
Because the SDK stamps an idempotency key on every state-changing call, retries are safe by construction: Paylera’s idempotency cache returns the original response on retry rather than performing the side effect a second time.
The combination — auto-stamped UUID v7 keys + Polly retry on 5xx + 24-hour server-side cache — is what makes “fire and forget” semantics safe in practice. You don’t need to write your own retry loop and you don’t need to worry about double-charging.
Configuring the pipeline
builder.Services.AddPaylera(opts =>{ opts.ApiToken = configuration["Paylera:ApiToken"]; opts.Timeout = TimeSpan.FromSeconds(45); // default 30s
// Tune the Polly pipeline; nulls leave defaults intact. opts.Retry.MaxAttempts = 7; opts.Retry.BaseDelay = TimeSpan.FromMilliseconds(250); opts.Retry.MaxDelay = TimeSpan.FromSeconds(10);});To disable automatic retries (you have a smarter outer loop):
opts.Retry.MaxAttempts = 0;Caller-supplied keys for natural deduplication
The most useful pattern is to derive an idempotency key from the
intent, not from a counter or a Guid.NewGuid() at the call site:
// Same logical signup, regardless of how many times the user mashes the button.var key = $"signup:{userId}";await client.Api.CreateSubscriptionAsync(key, body);This collapses retries across process restarts, queue redeliveries, and client refreshes. Paylera’s cache TTL is the upper bound: a key is good for 24 hours, after which a fresh request with the same key is treated as a new call.
Webhook handler idempotency
The receiver in Paylera.AspNetCore does not deduplicate inbound
deliveries — Paylera retries every delivery exactly once per failure,
so handlers must be idempotent on event.Id. Pattern:
opts.On<InvoicePaidEvent>("invoice.paid", async (ev, ctx) =>{ if (!await _seenEvents.TryAddAsync(ev.Id, ctx.RequestAborted)) { return; // Already processed; ack as 200. } await _fulfilment.MarkPaidAsync(ev.Id, ctx.RequestAborted);});The de-dup store can be a database table with a unique constraint on
event_id, a Redis SET NX, or whatever fits your stack. Idempotency
on the receiving side is your responsibility — but the SDK gives you
the event.Id to key off, every time.