AI Prompt Engineering

Getting ChatGPT to Write Accurate Idempotency Keys Without Duplicate Payment Risks

July 20, 2026 9 min read

Duplicate charges are one of the most expensive bugs you can ship. A customer gets charged twice, your support queue fills up, and your payment processor flags your account β€” all because a retry fired on a request that had already succeeded. Idempotency keys are the standard fix, but ChatGPT's default output for this pattern contains subtle gaps that only show up under real network conditions.

What You'll Learn

  • The four specific ways ChatGPT-generated idempotency key logic fails in production
  • How to structure prompts that produce safe, collision-resistant key generation
  • How to get ChatGPT to generate correct storage and deduplication logic
  • How to handle key expiration without accidentally re-processing old requests
  • A review checklist to validate any AI-generated payment code before it ships

Prerequisites

This article assumes you are building against a payment API (Stripe, Adyen, or similar) that supports idempotency keys at the HTTP layer. You should be comfortable with basic REST concepts and have a working understanding of your server's database or cache layer. Code examples use Python, but the prompt patterns apply regardless of language.

What a Correct Idempotency Key Actually Does

An idempotency key is a unique, client-generated token attached to a mutating request. If the server receives two requests with the same key, it processes the first and returns a cached response for every subsequent one β€” no duplicate charge, no duplicate order, no duplicate anything.

For a key to do its job correctly, it must satisfy three properties:

  • Unique per logical operation β€” one key per user intent, not per HTTP call. A retry shares the key with its original attempt.
  • Stable across retries β€” the key must be derived from the same inputs each time, not regenerated on each attempt.
  • Scoped correctly β€” keys should be scoped to the user and the specific transaction context, never reused across different operations or users.

That sounds straightforward, but ChatGPT's default output routinely violates at least one of these three properties.

The Four Failure Modes in ChatGPT-Generated Key Logic

Before you start prompting, you need to recognize what bad output looks like so you can catch it in review.

1. Regenerating the key on every retry

The most common mistake is generating a new UUID each time the request function runs. Every retry gets a fresh key, so the payment processor treats each one as a new charge. You get duplicates even though you're using idempotency keys.

# What ChatGPT generates without guidance
import uuid

def charge_customer(amount, customer_id):
    idempotency_key = str(uuid.uuid4())  # NEW key every call β€” wrong
    stripe.PaymentIntent.create(
        amount=amount,
        currency="usd",
        customer=customer_id,
        idempotency_key=idempotency_key,
    )

2. Generating keys that are too short or low-entropy

ChatGPT sometimes produces keys from truncated hashes, timestamps alone, or sequential integers. These collide under load or when two users perform the same action at the same millisecond.

3. Missing the storage step

The model often assumes the payment API handles all deduplication. In reality, you need your own record: store the key before you call the API, then mark it complete after. Without that, a process crash mid-flight leaves you unable to safely replay the request.

4. No expiration strategy

Keys that live forever accumulate in your database and eventually mask legitimate re-charges. A customer who cancels and re-subscribes a year later shouldn't be blocked by a stale key from their first sign-up. ChatGPT almost never adds expiration logic unless you ask.

Prompting ChatGPT for Safe Idempotency Key Generation

The fix starts with a specific, constrained prompt. Don't ask for "idempotency key logic" in isolation. Instead, frame the problem with explicit requirements around stability and scope.

Here is a prompt template that consistently produces better output:

Generate a Python function that creates an idempotency key for a Stripe payment intent.

Requirements:
- The key must be deterministic: given the same inputs, it must always produce the same key.
- Inputs: user_id (string), order_id (string), amount_cents (integer), currency (string).
- Use HMAC-SHA256 with a secret from environment variable IDEMPOTENCY_SECRET.
- Output must be URL-safe and under 255 characters.
- The function must NOT call uuid4() or time-based functions.
- Add a docstring explaining what happens if the same key is submitted twice.

The key constraints here are "deterministic" and the explicit ban on uuid4() and time-based functions. Without those, ChatGPT defaults to randomness. Here is what that prompt produces:

import hashlib
import hmac
import os

def generate_idempotency_key(
    user_id: str,
    order_id: str,
    amount_cents: int,
    currency: str,
) -> str:
    """
    Generate a deterministic idempotency key for a Stripe payment intent.

    If this key is submitted to Stripe for a second request, Stripe returns
    the cached response from the first request without processing a new charge.
    The key is stable across retries: same inputs always produce the same key.
    """
    secret = os.environ["IDEMPOTENCY_SECRET"].encode()
    payload = f"{user_id}:{order_id}:{amount_cents}:{currency}"
    digest = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()
    return f"pay-{user_id}-{digest[:48]}"

This is production-ready. The key is deterministic, scoped to the user and order, and impossible to guess without the secret. Note that hmac.new should be hmac.new β€” always review generated code for method name accuracy; ChatGPT occasionally confuses hmac.new with hmac.HMAC or the constructor form.

Prompting for Storage and Deduplication Logic

Key generation is only half the problem. You also need to store the key and the outcome so you can answer the question: "Did this request already succeed?" without calling the payment API again.

Ask for this separately with a prompt that specifies the state machine:

Write a Python function that wraps a Stripe payment intent creation with idempotency storage in PostgreSQL.

Requirements:
- Before calling Stripe, insert a row into payment_idempotency_keys with columns:
  key (text, primary key), status (text: 'pending'|'succeeded'|'failed'),
  stripe_response (jsonb, nullable), created_at (timestamptz), expires_at (timestamptz).
- If a row with that key already exists and status='succeeded', return the cached stripe_response.
- If status='pending', raise a RetryableError β€” another process may be in flight.
- If status='failed', allow a retry by updating status back to 'pending'.
- After Stripe returns, update status and stripe_response in the same transaction.
- Handle the case where Stripe throws a network timeout: status should remain 'pending'.
- Use asyncpg for database access.

The state machine detail is critical. Without specifying the pending state and what to do with it, ChatGPT usually produces a two-state system (exists/doesn't exist) that has a race condition between the check and the insert. The explicit pending + RetryableError requirement closes that gap.

This pattern is closely related to building safe retry logic more broadly. If you're also generating message queue consumers that process payment events, the same state-machine thinking applies β€” see how to get ChatGPT to write safe message queue consumer logic without the poison-pill loops that permanently block your queue.

Prompting for Expiration and Cleanup Without Data Loss

Keys need a finite lifetime. Most payment processors define this (Stripe's is 24 hours for identical-parameter matching), but your own deduplication store should be tuned to your business rules.

A good prompt for this:

Write a PostgreSQL function and a Python scheduled job that expires idempotency keys safely.

Requirements:
- Expire keys where expires_at < NOW() AND status IN ('succeeded', 'failed').
- Never expire keys where status='pending' β€” they may represent in-flight requests.
- Before deleting, archive rows to payment_idempotency_keys_archive with the same schema.
- The Python job should run every hour using APScheduler.
- Log the count of archived rows per run.
- Include a comment explaining why pending rows are excluded.

The explicit instruction to skip pending rows is essential. A naive cleanup job that deletes all expired rows will wipe records of in-flight requests, leading to exactly the double-charge scenario you're trying to prevent.

-- Generated SQL cleanup function
CREATE OR REPLACE FUNCTION archive_expired_idempotency_keys()
RETURNS INTEGER AS $$
DECLARE
  archived_count INTEGER;
BEGIN
  -- Only archive terminal states. 'pending' rows may represent requests
  -- currently in flight; deleting them would allow a duplicate charge
  -- if the original request completes after this job runs.
  WITH moved AS (
    DELETE FROM payment_idempotency_keys
    WHERE expires_at < NOW()
      AND status IN ('succeeded', 'failed')
    RETURNING *
  )
  INSERT INTO payment_idempotency_keys_archive
  SELECT * FROM moved;

  GET DIAGNOSTICS archived_count = ROW_COUNT;
  RETURN archived_count;
END;
$$ LANGUAGE plpgsql;

If you're building distributed payment systems and already using ChatGPT for other infrastructure code, the same discipline applies to secrets and credential rotation. The article on getting ChatGPT to write accurate secrets rotation scripts without downtime gaps covers a closely related failure pattern where missing state machines cause gaps.

Common Pitfalls to Watch For

Even with well-structured prompts, you should run every piece of ChatGPT-generated payment code through this checklist before merging:

  • Key generated inside a retry loop? The key must be generated once, before the loop starts, and passed in as a parameter.
  • Key includes mutable data? If your key includes fields like updated_at or attempt_number, it changes on each retry. Only include immutable inputs: user ID, order ID, amount, currency.
  • No ON CONFLICT clause? Any INSERT for idempotency keys must use ON CONFLICT DO NOTHING or a similar mechanism. A bare INSERT will throw on concurrent retries.
  • Key reuse across operation types? A refund and a charge for the same order should have different keys. Prefix them explicitly: charge-{order_id}-... vs refund-{order_id}-....
  • No logging? Log every key issued and every cache hit. This makes debugging duplicate-charge complaints in production dramatically faster.

ChatGPT tends to produce clean happy-path code. The gaps almost always appear in concurrent scenarios and error paths. For a parallel example of how this plays out with distributed locking, see getting ChatGPT to write accurate distributed lock logic without split-brain bugs β€” the failure modes are structurally identical.

It is also worth reviewing your API integration more broadly for spec gaps. The article on getting ChatGPT to write accurate API rate limit headers without spec gaps shows how the same pattern of under-specified prompts produces broken behavior at the edges.

A Prompt Review Checklist

Before you submit a prompt about payment logic to ChatGPT, confirm it includes:

Requirement Why it matters
Explicit inputs for key derivation Prevents random or time-based keys
"Deterministic" stated explicitly ChatGPT defaults to random without this
Three-state machine (pending/succeeded/failed) Closes the concurrent-retry race condition
Expiration strategy with pending exclusion Prevents in-flight request data loss
Archive before delete instruction Maintains audit trail for dispute resolution
ON CONFLICT handling required Prevents duplicate key insertion errors

Next Steps

You now have the prompt patterns to get production-safe idempotency key logic from ChatGPT. Here is what to do with it:

  1. Audit existing payment code first. Check whether any current idempotency keys are generated inside retry loops or derived from mutable fields. Fix those before adding new logic.
  2. Run the prompts above against your actual stack β€” substitute your ORM, your database client, and your payment provider. Verify the output against the checklist in this article.
  3. Add an integration test that fires the same request twice concurrently and asserts only one charge appears. This is the fastest way to catch race conditions in your implementation.
  4. Set up key expiration monitoring: alert if the count of pending rows older than your maximum expected processing time exceeds a threshold. Stale pending rows are a signal of silent failures.
  5. Document the key schema in your API spec so future developers (and future AI-assisted code generation) know the format, scope, and lifetime of your keys.

Frequently Asked Questions

Why does using uuid4() to generate idempotency keys cause duplicate charges?

uuid4() generates a new random key every time the function runs, so each retry is treated as a brand-new request by the payment processor. To prevent duplicates, your key must be deterministic β€” derived from stable inputs like user ID and order ID β€” so retries always produce the exact same key.

How long should idempotency keys be stored before expiring them?

Most payment processors define their own matching window (Stripe's is 24 hours), but you should retain keys in your own store longer for dispute resolution and audit purposes. A common approach is 7 to 30 days for succeeded or failed keys, while pending keys should never be expired automatically.

What is the safest way to handle a Stripe network timeout when using idempotency keys?

Leave the key's status as 'pending' and do not mark it failed. When the retry fires with the same key, your system will detect the pending status and either wait for the original request to resolve or query Stripe directly using the key to retrieve the outcome.

Can the same idempotency key be reused for a refund after a successful charge?

No. Reusing a charge key for a refund will either be rejected by the payment processor or return the cached charge response, which is not the result you want. Always prefix keys by operation type, for example 'charge-{order_id}-...' versus 'refund-{order_id}-...'.

How do I test that my idempotency key logic actually prevents duplicate charges?

Write an integration test that fires two concurrent requests with identical keys and asserts that only one charge record appears in your database and on your payment processor's dashboard. Testing sequentially is not enough β€” the race condition only appears under true concurrency.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.