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.
How it works
Section titled “How it works”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:
- The handler checks
orders.idempotency_keyfor an existing match. - No match — the handler runs the full flow: validate, charge any
electronic payments, create the
Order, attach to a batch, return201 Createdwith theOrderDto.
If your client retries with the same body and the same
idempotencyKey because the response never reached you:
- The handler finds the prior order via
idempotency_keylookup. - It returns the original
OrderDtoexactly — sameid, 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.
When to use it
Section titled “When to use 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.
When you don’t need it
Section titled “When you don’t need it”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.
Key lifetime
Section titled “Key lifetime”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.
Deliberate non-goals
Section titled “Deliberate non-goals”- No
Idempotency-KeyHTTP 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_idon 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
Guidrandomly per logical operation. Don’t derive it from request fields — that creates accidental collisions when two distinct operations happen to have identical bodies.