[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an Idempotent API with Request Deduplication

Build an Idempotent API with Request Deduplication

Build idempotent API endpoints that safely handle retries and duplicate requests — preventing double charges, duplicate records, and race conditions in payment and order processing.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Maya leads payments at a 30-person marketplace. Users click "Pay" and the request times out, so they click again. Result: double charge. The mobile app retries failed requests automatically — creating duplicate orders. A network hiccup between the API and Stripe means the payment succeeds but the API returns a 500, so the client retries and charges the customer twice. Last month: 47 double charges, $12K in refunds, and angry customer emails. They need every mutating API endpoint to be idempotent — the same request processed twice produces the same result.

Step 1: Build the Idempotency Middleware

typescript
// src/middleware/idempotency.ts — Idempotency key middleware for safe retries
import { Context, Next } from "hono";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface IdempotencyRecord {
  status: "processing" | "completed";
  statusCode: number;
  body: string;
  headers: Record<string, string>;
  createdAt: number;
}

const IDEMPOTENCY_TTL = 86400; // 24 hours

export function idempotency() {
  return async (c: Context, next: Next) => {
    // Only apply to mutating methods
    if (!["POST", "PUT", "PATCH", "DELETE"].includes(c.req.method)) {
      return next();
    }

    const idempotencyKey = c.req.header("Idempotency-Key");

    // No key = no idempotency protection (backward compatible)
    if (!idempotencyKey) return next();

    // Validate key format
    if (idempotencyKey.length > 255) {
      return c.json({ error: "Idempotency-Key too long (max 255 chars)" }, 400);
    }

    const cacheKey = `idempotency:${c.req.method}:${c.req.path}:${idempotencyKey}`;

    // Check if this request was already processed
    const existing = await redis.get(cacheKey);

    if (existing) {
      const record: IdempotencyRecord = JSON.parse(existing);

      if (record.status === "processing") {
        // Request is currently being processed (concurrent duplicate)
        return c.json(
          { error: "Request is currently being processed", retryAfter: 1 },
          409,
          { "Retry-After": "1" }
        );
      }

      // Return cached response
      c.header("X-Idempotent-Replayed", "true");
      for (const [key, value] of Object.entries(record.headers)) {
        c.header(key, value);
      }
      return c.json(JSON.parse(record.body), record.statusCode as any);
    }

    // Mark as processing (with short TTL in case server crashes)
    const lockRecord: IdempotencyRecord = {
      status: "processing",
      statusCode: 0,
      body: "",
      headers: {},
      createdAt: Date.now(),
    };
    const locked = await redis.set(cacheKey, JSON.stringify(lockRecord), "EX", 30, "NX");

    if (!locked) {
      // Another request grabbed the lock between our GET and SET
      return c.json({ error: "Request is currently being processed" }, 409);
    }

    try {
      // Process the request
      await next();

      // Cache the response
      const responseBody = await getResponseBody(c);
      const completedRecord: IdempotencyRecord = {
        status: "completed",
        statusCode: c.res.status,
        body: responseBody,
        headers: {
          "Content-Type": c.res.headers.get("Content-Type") || "application/json",
        },
        createdAt: Date.now(),
      };

      await redis.set(cacheKey, JSON.stringify(completedRecord), "EX", IDEMPOTENCY_TTL);
    } catch (err) {
      // Remove the lock on failure so retries can proceed
      await redis.del(cacheKey);
      throw err;
    }
  };
}

async function getResponseBody(c: Context): Promise<string> {
  const cloned = c.res.clone();
  return cloned.text();
}

// Database-level idempotency for critical operations
export async function withIdempotency<T>(
  key: string,
  pool: any,
  operation: () => Promise<T>
): Promise<{ result: T; replayed: boolean }> {
  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    // Check if operation was already completed
    const { rows } = await client.query(
      "SELECT result FROM idempotency_keys WHERE key = $1 FOR UPDATE",
      [key]
    );

    if (rows.length > 0) {
      await client.query("COMMIT");
      return { result: JSON.parse(rows[0].result), replayed: true };
    }

    // Execute the operation
    const result = await operation();

    // Store the result
    await client.query(
      "INSERT INTO idempotency_keys (key, result, created_at) VALUES ($1, $2, NOW())",
      [key, JSON.stringify(result)]
    );

    await client.query("COMMIT");
    return { result, replayed: false };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Step 2: Apply to Payment Processing

typescript
// src/routes/payments.ts — Idempotent payment processing
import { Hono } from "hono";
import { z } from "zod";
import { idempotency, withIdempotency } from "../middleware/idempotency";
import { pool } from "../db";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = new Hono();

app.use("*", idempotency());

const ChargeSchema = z.object({
  orderId: z.string(),
  amount: z.number().positive(),
  currency: z.string().length(3),
  customerId: z.string(),
});

app.post("/payments/charge", async (c) => {
  const body = ChargeSchema.parse(await c.req.json());
  const idempotencyKey = c.req.header("Idempotency-Key")!;

  // Database-level idempotency ensures exactly-once payment
  const { result, replayed } = await withIdempotency(
    `charge:${idempotencyKey}`,
    pool,
    async () => {
      // Check order hasn't been paid already
      const { rows: [order] } = await pool.query(
        "SELECT id, status FROM orders WHERE id = $1",
        [body.orderId]
      );

      if (!order) throw new Error("Order not found");
      if (order.status === "paid") throw new Error("Order already paid");

      // Charge via Stripe (using Stripe's own idempotency)
      const charge = await stripe.paymentIntents.create({
        amount: Math.round(body.amount * 100),
        currency: body.currency,
        customer: body.customerId,
        metadata: { orderId: body.orderId },
      }, {
        idempotencyKey: `stripe:${idempotencyKey}`,
      });

      // Update order status
      await pool.query(
        "UPDATE orders SET status = 'paid', payment_id = $2, paid_at = NOW() WHERE id = $1",
        [body.orderId, charge.id]
      );

      return {
        paymentId: charge.id,
        status: charge.status,
        amount: body.amount,
      };
    }
  );

  if (replayed) {
    c.header("X-Idempotent-Replayed", "true");
  }

  return c.json(result, 201);
});

export default app;

Results

  • Double charges eliminated completely — same Idempotency-Key returns cached result instead of processing again; 47 double charges/month → zero
  • Mobile app retries are safe — automatic retry on timeout sends the same idempotency key; the server returns the original result without reprocessing
  • Concurrent duplicates handled — if two identical requests arrive simultaneously, one processes and the other gets a 409 with Retry-After; no race condition
  • $12K/month in refund costs eliminated — no duplicate payments means no refund requests; customer trust restored
  • Database-level guarantee for payments — Redis handles general API idempotency; critical payment operations use PostgreSQL transactions with FOR UPDATE for stronger consistency

Related use cases