Skip to content

Idempotency

POST /api/tenants/{tenantId}/orders is the one mutating endpoint that charges money against a payment processor. It’s also the one most likely to be called over a flaky link. The platform supports an idempotency-key field on the order body so a network blip in the middle of a charge doesn’t risk a double-charge.

Include a fresh IdempotencyKey (a Guid) in the create-order body:

{
"contactId": 42,
"departmentId": null,
"items": [],
"donations": [
{ "fundId": "00000000-0000-0000-0000-000000000001", "amount": 100.00 }
],
"billingAddress": { /* ... */ },
"payments": [
{ "paymentMethod": "Cash", "amount": 100.00, "savePaymentMethod": false }
],
"idempotencyKey": "9c4a7b2d-..."
}

On the first call:

  1. The handler checks orders.idempotency_key for an existing match.
  2. No match — the handler runs the full flow: validate, charge any electronic payments, create the Order, attach to a batch, return 201 Created with the OrderDto.

If your client retries with the same body and the same idempotencyKey because the response never reached you:

  1. The handler finds the prior order via idempotency_key lookup.
  2. It returns the original OrderDto exactly — same id, same totals, same status — without re-running validation, re-charging payments, or re-touching inventory.

The match is on the key alone, not on the body. If you reuse a key with a different body, the existing order is what comes back; the new body is silently ignored. So the contract is: for each logical operation, mint one Guid and never reuse it.

Always use idempotencyKey on POST /orders if your integration has any network surface that can lose a response:

  • A scheduled job that retries failed requests.
  • An offline buffer that flushes when connectivity returns.
  • A retry policy in your HTTP client.
  • A user clicking a “submit donation” button twice.

For pure reads (every GET), idempotency is the default — the response is derived from current state. No key needed.

For other writes (PUT, DELETE, most POST), the platform doesn’t currently surface an idempotency contract. The reasoning is that those operations either don’t move money or are naturally idempotent at the data layer (a PUT overwrites; a DELETE is a no-op when the row’s already gone). If you need stronger replay safety on a non-order write, file an issue.

Worked example: payment retry that races a network error

Section titled “Worked example: payment retry that races a network error”
var idempotencyKey = Guid.NewGuid();
var body = new CreateOrderRequest
{
ContactId = 42,
Donations = [new(fundId, 100m)],
Payments = [new(PaymentMethod.CreditCard, 100m, paymentToken: token, savePaymentMethod: false)],
BillingAddress = address,
IdempotencyKey = idempotencyKey,
};
async Task<OrderDto> ChargeWithRetry(int attempts = 3)
{
for (var i = 0; i < attempts; i++)
{
try
{
var response = await http.PostAsJsonAsync(
$"/api/tenants/{tenantId}/orders", body);
if (response.IsSuccessStatusCode)
return (await response.Content.ReadFromJsonAsync<OrderDto>())!;
// 4xx is terminal; don't retry.
if ((int)response.StatusCode is >= 400 and < 500)
throw new InvalidOperationException(
await response.Content.ReadAsStringAsync());
// 5xx — retry with the same body and key.
}
catch (HttpRequestException) { /* network issue — retry */ }
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)));
}
throw new TimeoutException("Order creation never confirmed.");
}

If the first attempt actually completed but the response was lost on the return trip, the second attempt’s idempotencyKey lookup catches it and returns the same OrderDto — the donor isn’t charged twice.

The key lives on the Order row. There’s no separate cache or TTL — once the order exists, the key is bound to it for the order’s lifetime. A revoked or hard-deleted order’s key would be reusable in principle, but the platform soft-deletes all order data and never physically purges, so in practice every key is single-use.

  • No Idempotency-Key HTTP header. The contract is body-field-based, not header-based. A future API revision may align with the Stripe-conventional header but cannot today without breaking existing callers.
  • No idempotency on refunds, voids, or webhook re-deliveries. Refunds carry their own deduplication via the processor; webhook deliveries are idempotent by delivery_id on the receiving side (see Webhooks).
  • No body-content matching. Reusing a key with a different body silently returns the original response. Don’t do this.
  • No client-side key derivation. Mint the Guid randomly per logical operation. Don’t derive it from request fields — that creates accidental collisions when two distinct operations happen to have identical bodies.