Idempotency
Moonlight's ingest API supports the Idempotency-Key header for
exactly-once semantics over flaky networks. Repeating a request with the
same key returns the cached response without re-running the worker.
Header Contract
POST /api/v1/ingest
X-API-Key: <key>
Idempotency-Key: neary:purchase:100:paid:v1
Content-Type: application/json
{ "entries": [...] }
- Keys are arbitrary strings up to 255 characters. Use a stable,
deterministic value per business operation (e.g.
purchase:{id}:paidin Neary). - Records are stored in
idempotency_record(V16 migration) for 24 hours. After that the key is reusable. - Replays are scoped per
(integration_id, request_path, request_method), so the same key can be reused across distinct endpoints.
Conflict Detection
If the same key is sent with a different request body fingerprint, the
server returns 409 Conflict:
{
"statusCode": 409,
"message": "Idempotency-Key was reused with a different payload",
"error": "Conflict"
}
The fingerprint is a SHA-256 of the canonical JSON representation of the request body (key order normalised). Adding optional whitespace or reordering keys does not trigger a conflict.
SDK Support
The MoonlightClient accepts the key as an option:
await client.ingest(
{ entries: [...] },
{ idempotencyKey: 'purchase:100:paid:v1' },
);
The client also retries 5xx/429/network errors with exponential
backoff (configurable via maxRetries), so transient failures don't
require operator intervention.
Storage
idempotency_record (
key text PRIMARY KEY,
integration_id UUID NOT NULL,
request_path text NOT NULL,
request_method text NOT NULL,
request_fingerprint text NOT NULL,
response_status integer NOT NULL,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT NOW(),
expires_at timestamptz NOT NULL
)
A scheduler tick (IDEMPOTENCY_RECORD_GC) garbage-collects expired
rows hourly.
When NOT to Use
- Retries already in-flight — the SDK's built-in retry already covers
transient errors;
Idempotency-Keyis for application-level redoes. - Different operations sharing one key — keys are scoped per body fingerprint, so reuse across distinct payloads is rejected.
- Long workflows — the 24h TTL is intentional; don't try to use idempotency as a permanent dedupe layer.