Idempotency
Overview
Section titled “Overview”Idempotency ensures duplicate mutation requests are safely handled without creating duplicate records. Critical for offline retry scenarios, network timeouts, and double-tap prevention.
The system uses the X-Idempotency-Key header with a database-backed cache (24-hour TTL).
How It Works
Section titled “How It Works”sequenceDiagram
participant Client as Mobile Client
participant Middleware as Idempotency Middleware
participant Handler as API Handler
Client->>Middleware: POST /resource {key: "uuid"}
Middleware->>Middleware: Check idempotency_keys table
alt Key exists (within 24h, status 2xx)
Middleware-->>Client: 204 No Content (already processed)
else Key new or failed
Middleware->>Handler: Process request
Handler-->>Middleware: Success response
Middleware->>Middleware: Store key + status (upsert)
Middleware-->>Client: 200/201 OK
end
Flow:
- Client generates UUID v4 for each mutation
- UUID included in
X-Idempotency-Keyheader - Server checks workspace-scoped
idempotency_keystable - If exists with 2xx: Return
204 No Content - If new/failed: Process, store key, return success
Client Implementation
Section titled “Client Implementation”Generate UUID
Section titled “Generate UUID”export function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); });}Include in Request
Section titled “Include in Request”const WRITE_METHODS = new Set(['post', 'put', 'patch', 'delete']);
apiClient.interceptors.request.use(async config => { if (WRITE_METHODS.has(config.method?.toLowerCase() ?? '')) { config.headers['X-Idempotency-Key'] = generateUUID(); } return config;});Example Request
Section titled “Example Request”POST /app/v1/outlet-visitsAuthorization: Bearer <token>X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000Content-Type: application/json
{ "outlet_id": 123, "scheduled_date": "2026-03-10"}Server Implementation
Section titled “Server Implementation”Middleware
Section titled “Middleware”func IdempotencyMiddleware(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { key := c.GetHeader("X-Idempotency-Key") if key == "" { c.Next() return }
auth, _ := utils.GetAuthContextValue(c.Request.Context()) if auth == nil { c.Next() return }
workspaceID := auth.UserWorkspaceID
// Lazy eviction — delete keys older than 24h (background) go db.Where("created_at < ?", time.Now().Add(-24*time.Hour)). Delete(&idempotencyRecord{})
// Check for existing key var existing idempotencyRecord result := db.Where("workspace_id = ? AND key = ?", workspaceID, key). First(&existing)
if result.Error == nil && existing.ResponseStatus >= 200 && existing.ResponseStatus < 300 { utils.HandleResponse(c, http.StatusNoContent, "Duplicate request — already processed", "IDEMPOTENT_DUPLICATE", nil) c.Abort() return }
c.Next()
// Store key + response status (upsert) status := c.Writer.Status() db.Where(idempotencyRecord{WorkspaceID: workspaceID, Key: key}). Assign(idempotencyRecord{ResponseStatus: status}). FirstOrCreate(&idempotencyRecord{ WorkspaceID: workspaceID, Key: key, ResponseStatus: status, }) }}Database Schema
Section titled “Database Schema”CREATE TABLE idempotency_keys ( id BIGSERIAL PRIMARY KEY, workspace_id BIGINT NOT NULL, key VARCHAR(64) NOT NULL, response_status INTEGER NOT NULL DEFAULT 200, created_at TIMESTAMPTZ NOT NULL, UNIQUE KEY idx_workspace_idem_key (workspace_id, key), INDEX idx_idempotency_keys_cleanup (created_at));TTL & Eviction
Section titled “TTL & Eviction”TTL: 24 hours
Rationale: Long enough for offline sync, short enough to prevent unbounded growth.
Eviction: Lazy — background goroutine deletes keys older than 24h on each request.
-- Optional manual cleanup (daily cron)DELETE FROM idempotency_keysWHERE created_at < NOW() - INTERVAL '24 hours';Error Handling
Section titled “Error Handling”Duplicate Request Response
Section titled “Duplicate Request Response”HTTP/1.1 204 No Content
{ "success": true, "message": "Duplicate request — already processed", "code": "IDEMPOTENT_DUPLICATE", "data": null}Behavior: Returns 204 No Content (not 409). Client treats as success — no retry needed.
Missing Idempotency Key
Section titled “Missing Idempotency Key”- Request processed normally (no error)
- Critical for: sales orders, visit check-in/out, attendance
Use Cases
Section titled “Use Cases”1. Offline Mutation Retry
Section titled “1. Offline Mutation Retry”try { await createVisit(data);} catch (error) { if (isNetworkError(error)) { // Queue with same UUID — server deduplicates on retry await outbox.enqueue({ type: 'createVisit', data, idempotencyKey: key }); }}2. Network Timeout
Section titled “2. Network Timeout”Client sends → Timeout → RetryWithout idempotency: 2 visits createdWith idempotency: 1 visit, 2nd returns 2043. Double-Tap Prevention
Section titled “3. Double-Tap Prevention”User taps "Submit" twiceWithout idempotency: 2 submissionsWith idempotency: 1 submission, 2nd blockedBest Practices
Section titled “Best Practices”Client-Side
Section titled “Client-Side”✅ DO:
- Generate new UUID per distinct mutation
- Store key with queued outbox operation
- Reuse same key for retries
- Log key for debugging
❌ DON’T:
- Reuse keys across different mutations
- Use predictable keys (timestamps, counters)
- Omit key for critical mutations
Server-Side
Section titled “Server-Side”✅ DO:
- Check idempotency before business logic
- Store key atomically (upsert pattern)
- Use 24h TTL
- Log conflicts for monitoring
❌ DON’T:
- Skip check for write operations
- Use short TTL (<1h)
- Store full response (just status)
- Ignore workspace scoping
Related Topics
Section titled “Related Topics”- Offline Mutation — Outbox pattern and retry logic
- Location Tracking — Batch upload with idempotency
- Rate Limiting — Request throttling and quotas
- Authentication — Token-based auth and headers