PRACTICAL GUIDE / checkout abuse case testing

The checkout total is correct until the request is replayed

Build abuse tests for price tampering, coupon races, stale carts, and payment callbacks, with server-side oracles that prove one valid order exists.

By The Testing AcademyUpdated August 4, 202621 min read
All field guides
In this guide6 sections
  1. Start with the threat model and the money invariants
  2. Where checkout state escapes the happy path
  3. Three abuse sequences that deserve durable oracles
  4. How to tell a blocked request from a protected system
  5. How to roll the controls into an existing checkout
  6. When not to run or automate these tests

What you will learn

  • Start with the threat model and the money invariants
  • Where checkout state escapes the happy path
  • Three abuse sequences that deserve durable oracles
  • How to tell a blocked request from a protected system

Two identical checkout requests leave the browser before either response returns. Both use the same one-time coupon, both appear successful, and the order records show the discount twice. The happy path was correct. The rule failed only when the customer stopped behaving like the page expected.

Start with the threat model and the money invariants

Assume the tester controls a legitimate customer account and can inspect, modify, omit, reorder, repeat, and concurrently send every request that account is authorized to make. The tester can abandon the browser flow and call documented or discovered endpoints directly. The tester does not control the server, database, payment provider, another customer's credentials, or transport security. Broader capabilities need a separate authorization and test plan.

That scope is enough to expose serious business-logic defects. The browser is not a trust boundary. Hidden fields, disabled controls, client-side totals, step indicators, and JavaScript validation describe the intended user experience. They do not prevent a caller from submitting a different payload. The OWASP forged-request scenario covers this exact move from the GUI to direct requests with values the frontend does not offer.

Write the invariants before choosing payloads. A typical checkout may require the server to derive unit prices from an authoritative catalog, accept quantities only inside product rules, and apply promotions within their eligibility and usage limits. It may also need to preserve currency and rounding policy, restrict a cart to its owner, and allow state transitions only in the approved order. One logical checkout operation must not create duplicate orders, inventory reservations, reward grants, or payment attempts.

Those are examples, not universal commerce rules. A coupon may be reusable across orders but limited once per cart. A marketplace may allow sellers to set a price through an authenticated workflow. A preorder may reserve no stock. The product owner and service contract must define the exact asset, actor, limit, and scope. Testing a generic one coupon ever rule against a reusable campaign would be a bad oracle.

State the payment boundary too. Some applications redirect to a hosted gateway, some embed a provider frame, and some send payment data from their backend. The attack surface and evidence differ. The OWASP payment functionality scenario explicitly calls out price tampering, discount-code misuse, broken flow order, repeated processing, and race conditions. It also warns that provider testing may be outside scope. Use the provider sandbox or an approved stub unless written authorization says otherwise.

An operation key is not magic. The application has to define its scope, payload binding, retention period, and result for reuse. A safe contract might say that the same customer, key, and request meaning return the same logical order, while the same key attached to different content is a conflict. Another system may put that contract at the provider boundary instead. Tests should exercise the design actually implemented and inspect durable effects after every branch.

Keep the honest retry and the malicious replay as separate cases. An honest client resends because a response was lost and preserves the same operation identity. An attacker can change keys, accounts, sequence, or payload fields to bypass simple deduplication. A control that handles only the first case is useful reliability work but is not proof against the second.

Where checkout state escapes the happy path

Price tampering begins wherever a client can submit money values that the server trusts. A cart request may contain unitPrice, subtotal, discount, shipping, tax, or grandTotal because the UI needs to render them. The secure contract can ignore those fields and reprice, or reject them as unexpected. The dangerous behavior is using a client value as authority for the order or provider request.

OWASP's business-logic data-validation guidance distinguishes logically invalid data from simple format boundaries. A quantity of negative one is obviously suspicious, but a valid-looking item and quantity can still violate a rule when the item is unavailable to that customer, the promotion excludes it, or the cart changed after a quote was issued.

Coupon races expose a check-then-act gap. Request A checks that a code is unused. Request B checks the same state before A records redemption. Both then write a benefit. The OWASP function-limit scenario uses a once-per-transaction discount as one example of an application-specific usage limit. The defect is not that requests arrived quickly; it is that the invariant was split across operations that could both observe the old state. A durable uniqueness rule, a conditional write, a suitable transaction, or a provider-supported atomic operation can close that gap. The correct choice depends on where the authoritative state lives.

Workflow bypass has a different shape. A caller invokes finalize before payment confirmation, changes the cart after a payment amount is established, returns directly to a success URL, or repeats a callback. The OWASP workflow-circumvention scenario recommends trying steps in a different order. A page redirect is not evidence that a provider accepted a payment. The server needs an authenticated provider result and an allowed state transition according to its integration.

Consider a hosted-payment flow with an order in pending_payment state. The browser leaves for the provider and later returns to a merchant success page. A useful abuse case requests that success page directly, before the provider stub has approved anything. The page can display a waiting message, but the durable order must not become paid, enter fulfillment, or grant rewards. Next, send the approved provider notification twice through the integration's authenticated test path. The first valid notification can advance the order; the duplicate must not repeat downstream effects. This sequence proves that the browser return and provider notification have different authority.

The evidence for that case spans more than the order row. Capture the state-transition history, provider operation identifier, accepted notification identifier, fulfillment message, inventory reservation, and reward ledger. Keep provider payloads sanitized and never attach the signing secret. If the order remains singular but two fulfillment messages exist, order uniqueness did not protect the whole workflow. If the duplicate notification is rejected before authentication, the idempotency branch was never tested. Drive an authenticated duplicate through the approved stub so each control is observed separately.

Stale carts often resemble tampering. A legitimate customer opens the same cart on two devices. One device changes quantity after the other receives a quote. If finalize silently uses the old quote, the displayed amount, charged amount, and stored lines can diverge. A cart version, quote identifier, or fresh server calculation can make the conflict explicit. The test should know whether the product promises automatic repricing or rejection.

Cross-account access is not primarily a calculation defect. Changing cartId or orderId may expose or modify another customer's resources if authorization is checked only when the page loads. Exercise every money-affecting endpoint with an object belonging to a second test account and verify both the response and unchanged durable state. A generic not found response can be a deliberate information-hiding choice; status alone does not prove protection.

Rounding needs a written rule and appropriate money representation. Do not assume every currency uses two fractional digits. Avoid binary floating-point as an independent oracle for decimal money. Use the product's approved minor-unit or decimal model and test line-level versus order-level rounding where the contract places it. Expected totals must come from controlled catalog and promotion fixtures or an independent rules implementation, not from the response under test.

Three abuse sequences that deserve durable oracles

The first worked example changes a server-owned price. This illustrative API contract accepts item identifiers and quantities, rejects client price fields with 400, and creates no order on rejection. A different secure API could ignore the field and reprice; its test would assert the authoritative total instead. The status here is part of the example's documented contract, not a universal OWASP requirement.

TypeScript
import { expect, test } from '@playwright/test';

test('client-supplied price is rejected without an order side effect', async ({ request }) => {
  const seeded = await request.post('/test-support/carts', {
    data: {
      customer: 'price-tamper-user',
      lines: [{ sku: 'MUG-ALPINE', quantity: 1 }]
    }
  });
  expect(seeded.status()).toBe(201);
  const cart = await seeded.json() as { id: string };

  const checkout = await request.post('/api/checkouts', {
    data: {
      cartId: cart.id,
      lines: [{
        sku: 'MUG-ALPINE',
        quantity: 1,
        unitPriceMinor: 1
      }]
    }
  });
  expect(checkout.status()).toBe(400);

  const orders = await request.get('/test-support/orders?cartId=' + cart.id);
  expect(orders.status()).toBe(200);
  expect(await orders.json()).toEqual([]);
});

What application change makes this test fail? Accepting the extra field changes the response. Creating an order before validation makes the durable query fail even if the handler later returns 400. That second assertion matters because a rejected HTTP response can coexist with a committed side effect when error handling occurs after a write.

The test-support endpoints belong only in an isolated environment and should require strong test-only authorization. They are shown to make seed and durable-state checks explicit. In a real suite, use an approved fixture API or direct test database adapter with equivalent isolation. Never expose administrative inspection endpoints to production just to make automation convenient.

The second example applies one coupon concurrently. The product contract allows this seeded single-use code to be redeemed exactly once. One request returns 200, and the conflicting request returns 409. RFC 9110 defines 409 Conflict as a response to a request that cannot be completed because of a conflict with the current state of the target resource, which matches this chosen API behavior. The decisive assertion is still the final discount ledger.

TypeScript
import { expect, test } from '@playwright/test';

test('a cart receives a single discount under concurrent redemption', async ({ request }) => {
  const seeded = await request.post('/test-support/carts', {
    data: {
      customer: 'coupon-race-user',
      coupon: { code: 'ONCE-CART-25', useLimit: 1 }
    }
  });
  expect(seeded.status()).toBe(201);
  const cart = await seeded.json() as { id: string };

  const attempts = await Promise.all([
    request.post('/api/carts/' + cart.id + '/coupons', {
      data: { code: 'ONCE-CART-25' },
      headers: { 'x-operation-id': 'coupon-attempt-a' }
    }),
    request.post('/api/carts/' + cart.id + '/coupons', {
      data: { code: 'ONCE-CART-25' },
      headers: { 'x-operation-id': 'coupon-attempt-b' }
    })
  ]);

  expect(attempts.map((response) => response.status()).sort((a, b) => a - b))
    .toEqual([200, 409]);

  const state = await request.get('/test-support/carts/' + cart.id);
  expect(state.status()).toBe(200);
  const body = await state.json() as {
    discounts: Array<{ code: string }>;
  };
  expect(body.discounts).toEqual([{ code: 'ONCE-CART-25' }]);
});

This test can reveal the race, but it should not be the only proof of the fix. Scheduling is not deterministic. A database integration test should exercise the conditional write directly, and the schema should preserve the invariant even when application instances run concurrently.

For PostgreSQL, one narrowly scoped fix is an atomic conditional update within a transaction. The function below uses the same node-postgres client for every statement, as its official transaction documentation requires. At the default Read Committed isolation level, PostgreSQL waits on a concurrently updated row and re-evaluates the UPDATE condition against the new row version. Only the transaction that changes redeemed_cart_id from null gets a row from RETURNING.

TypeScript
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export class CouponUnavailableError extends Error {}
export class CartNotEligibleError extends Error {}

export async function redeemSingleUseCoupon(
  cartId: string,
  customerId: string,
  code: string
): Promise<void> {
  const client = await pool.connect();

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

    const cart = await client.query(
      'SELECT id FROM carts ' +
      'WHERE id = $1 AND customer_id = $2 AND status = $3 FOR UPDATE',
      [cartId, customerId, 'open']
    );
    if (cart.rowCount !== 1) {
      throw new CartNotEligibleError();
    }

    const redemption = await client.query(
      'UPDATE coupons SET redeemed_cart_id = $1, redeemed_at = NOW() ' +
      'WHERE code = $2 AND redeemed_cart_id IS NULL RETURNING code',
      [cartId, code]
    );
    if (redemption.rowCount !== 1) {
      throw new CouponUnavailableError();
    }

    await client.query(
      'INSERT INTO cart_discounts (cart_id, coupon_code) VALUES ($1, $2)',
      [cartId, code]
    );
    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

This control has costs. Concurrent attempts wait on locked rows. Long transactions increase contention. The query assumes one PostgreSQL database owns both coupon and cart state. It does not solve a coupon managed by a remote promotion service, and it does not make a payment call atomic with a database commit. Those cases need a design that matches the actual owner, such as a provider idempotency feature plus a local state machine and reconciliation.

The third sequence is an honest checkout retry. The example contract binds an operation key to the customer and canonical request content. Two concurrent attempts with the same key and payload return the same order identifier. One response reports creation with 201, while the replay returns the existing representation with 200. Exactly one order and one payment attempt remain.

TypeScript
import { randomUUID } from 'node:crypto';
import { expect, test } from '@playwright/test';

test('a retried checkout resolves to one logical order', async ({ request }) => {
  const seeded = await request.post('/test-support/carts', {
    data: {
      customer: 'checkout-retry-user',
      lines: [{ sku: 'MUG-ALPINE', quantity: 1 }]
    }
  });
  expect(seeded.status()).toBe(201);
  const cart = await seeded.json() as { id: string; quoteId: string };

  const operationKey = randomUUID();
  const payload = { cartId: cart.id, quoteId: cart.quoteId };

  const responses = await Promise.all([
    request.post('/api/checkouts', {
      data: payload,
      headers: { 'idempotency-key': operationKey }
    }),
    request.post('/api/checkouts', {
      data: payload,
      headers: { 'idempotency-key': operationKey }
    })
  ]);

  expect(responses.map((response) => response.status()).sort((a, b) => a - b))
    .toEqual([200, 201]);
  const results = await Promise.all(
    responses.map(async (response) => await response.json() as { orderId: string })
  );
  expect(new Set(results.map((result) => result.orderId)).size).toBe(1);

  const evidence = await request.get(
    '/test-support/checkout-effects?operationKey=' + operationKey
  );
  expect(evidence.status()).toBe(200);
  expect(await evidence.json()).toMatchObject({
    orders: 1,
    paymentAttempts: 1
  });
});

No timing figures are implied by this example. The two calls are merely released without waiting for one response before starting the other. A stronger load exercise can vary concurrency and scheduling, but any number used there must be a stated test input or an actual measured result, never a made-up production observation.

How to tell a blocked request from a protected system

An error response is only the first observation. Query the cart, promotion ledger, order store, inventory reservation, reward balance, outbox, and payment stub according to the operation's possible effects. A handler can return 409 after an order insert commits. A timeout can hide a successful provider call. A 200 can safely return an earlier result. Status and durable state answer different questions.

Preserve request order and identity without collecting secrets. Useful evidence includes the test customer, cart identifier, operation key, request shape with money fields named but sensitive values excluded, response status, server calculation breakdown, order identifier, and side-effect counts. Do not attach Authorization headers, session cookies, payment tokens, raw provider payloads, card data, or unfiltered bodies.

A structured attachment makes race review easier. Build an allowlisted object rather than passing arbitrary request and response data through a hopeful redaction function:

TypeScript
import type { TestInfo } from '@playwright/test';

type CheckoutEvidence = {
  cartId: string;
  operationIds: string[];
  statuses: number[];
  orderIds: string[];
  discountCodes: string[];
  durableOrderCount: number;
  durablePaymentAttemptCount: number;
};

export async function attachCheckoutEvidence(
  testInfo: TestInfo,
  evidence: CheckoutEvidence
): Promise<void> {
  const allowlistedEvidence = {
    cartId: evidence.cartId,
    operationIds: [...evidence.operationIds],
    statuses: [...evidence.statuses],
    orderIds: [...evidence.orderIds],
    discountCodes: [...evidence.discountCodes],
    durableOrderCount: evidence.durableOrderCount,
    durablePaymentAttemptCount: evidence.durablePaymentAttemptCount
  } satisfies CheckoutEvidence;

  await testInfo.attach('checkout-evidence', {
    body: Buffer.from(JSON.stringify(allowlistedEvidence, null, 2)),
    contentType: 'application/json'
  });
}

The fresh object copies only explicitly allowlisted fields, so an extra enumerable property such as requestUrl or token on a structurally compatible argument is not passed to JSON.stringify. The allowlist does not make approved values safe if a caller puts secrets in them, so review field additions and data provenance as security-sensitive changes. An all-purpose serialize response helper tends to grow until it captures data the article explicitly says not to store.

Distinguish a calculation defect from a stale-state defect. If the server breakdown uses the wrong catalog price for the current version, repricing is wrong. If it correctly rejects a quote because the cart version changed, the conflict handling may be right even though the browser shows an error. Capture catalog version, cart version, quote identifier, and stored order version to locate the disagreement.

Separate duplicate acceptance from duplicate effect. Two responses may both look successful because idempotent replay returns the original result. Compare order identifiers and durable counts. Conversely, one visible success can hide two provider attempts if the deduplication happens after the external call. The payment stub or sandbox request log is required for that boundary.

A state timeline is often more useful than a large server log. Record each accepted transition with its logical operation identifier and durable sequence, for example cart_open to quoted to payment_pending to paid. Then compare the attempted sequence with the stored one. A finalize request that receives 409 while the timeline remains payment_pending is a clean state conflict. The same response followed by paid indicates that error handling and state ownership disagree. Do not invent a standard transition vocabulary; use the application's real state names and allowed-transition table.

Calculation evidence should show inputs and rule identities without borrowing the final total as the expected result. Capture controlled SKU versions, quantities, promotion identifiers, shipping service, tax fixture, currency, rounding policy, and the server's line breakdown. Calculate the expected value from the seeded contract or an independently maintained rules fixture. If both expected and actual values are read from the same checkout response, a wrong subtotal can satisfy every comparison downstream.

Watch asynchronous effects long enough to observe the system's documented completion condition, not an arbitrary sleep. An order may commit before an outbox worker creates the fulfillment event. Poll the approved test-support view for that named state with a bounded timeout, and preserve the last observed state on failure. Calling the test green immediately after seeing one order can miss a later duplicate message. Waiting a fixed interval makes the result depend on environment speed without expressing what completion means.

A near-miss occurs when rate limiting accepts one call and rejects the other at the perimeter. The business invariant has not been exercised because the second request never reached it. Disable or raise the test-environment rate limit for the focused integration case, then run a separate rate-control test. Do not claim a coupon race is fixed because a perimeter rejected this particular pair.

Another near-miss is test-data collision. Reusing one coupon or cart across parallel workers creates conflicts unrelated to the application flaw. Give each test a unique customer, cart, promotion scope, operation key, and cleanup path. Retain failed data long enough for evidence collection, then remove it through an approved test fixture process.

How to roll the controls into an existing checkout

Map every place where money or irreversible state crosses a boundary. Include catalog lookup, cart mutation, promotion redemption, quote creation, shipping selection, tax calculation, payment initiation, provider callback, order finalization, inventory reservation, reward credit, cancellation, and refund. For each transition, name the authoritative service and durable record.

Add characterization tests before changing semantics. Record whether the current API rejects unknown money fields, ignores them, or accepts them. Record how stale versions behave and what an honest retry receives. A security fix can break mobile clients or provider callbacks if teams silently assumed different contracts. Characterization is not approval; it makes the migration impact visible.

Move authority server-side in narrow steps. First stop consuming client totals for new order calculations. Next return a server breakdown the UI can display. Then add version checks for stale carts and durable constraints for one-time effects. Finally bind retries to a documented operation identity and reconcile provider outcomes. Each step needs backward-compatibility and observability planning.

For a checkout already serving several client versions, run the new calculation in shadow mode before enforcing it. Compare the old accepted total with the new server-owned result on approved test and internal traffic, using sanitized identifiers and rule versions. Differences become cases for analysis, not automatic production corrections. A mismatch may reveal an old client defect, a promotion rule missing from the new service, or a real exploit path. Label any counts and latency from this exercise as measured data from that environment; do not turn illustrative figures into release claims.

Version the external contract when rejection behavior changes. An older mobile client may still submit display totals because the previous API required them. The server can ignore deprecated fields during a compatibility window while publishing a newer schema that does not accept them. Security remains anchored in server authority, while telemetry shows when old clients can be retired. The trade-off is a longer migration surface and more branches to test.

Turn each confirmed defect into a named misuse sequence, not a vague regression called checkout security. Preserve the actor, owned data, request order, changed fields, expected statuses, allowed state transitions, and every durable effect. That record prevents a later rewrite from protecting only the visible order while reopening a coupon, reward, or fulfillment side effect.

Database constraints and conditional writes add contention and failure paths. Server repricing adds catalog access and can expose cache-consistency questions. Version checks create conflicts users must recover from. Idempotency records consume storage and need retention rules. Provider reconciliation adds operational work. These costs are concrete reasons to design the boundary carefully, not reasons to leave money rules in the browser.

Put fast rule matrices and database integration tests on every relevant change. Keep a smaller API layer for forged fields, authorization, stale state, and replay. Use a focused browser path to compare the total shown to the customer with the server-created order. Schedule heavier concurrency and provider-sandbox exercises in an isolated environment where their state and expense are controlled.

The API examples use relative paths, so their Playwright project needs an explicit base URL. Fail during configuration when the isolated environment variable is absent rather than accidentally targeting an unintended host:

TypeScript
import { defineConfig } from '@playwright/test';

const baseURL = process.env.BASE_URL;
if (!baseURL) {
  throw new Error('BASE_URL must identify the isolated checkout environment');
}

export default defineConfig({
  testDir: './tests/security/checkout',
  retries: 0,
  use: {
    baseURL,
    trace: 'retain-on-failure'
  }
});

One possible CI job is deliberately plain. It installs the committed dependency lock, supplies that repository variable, runs only the security contract project, and uploads the Playwright report even after a failure:

YAML
name: checkout-abuse-contracts

on:
  pull_request:
    paths:
      - "src/checkout/**"
      - "tests/security/checkout/**"

permissions:
  contents: read

jobs:
  checkout-contracts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright test tests/security/checkout
        env:
          BASE_URL: ${{ vars.CHECKOUT_TEST_BASE_URL }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: checkout-abuse-report
          path: playwright-report/
          if-no-files-found: ignore

The sample assumes the repository uses npm and already declares its Playwright dependency. A pnpm or yarn project should use its committed package-manager workflow instead. Do not copy caching options without verifying when that package manager becomes available on PATH.

When not to run or automate these tests

Do not probe a live payment provider, production coupon inventory, or real customer carts without written scope and operational coordination. A seemingly harmless one-cent attempt can create fees, fraud alerts, fulfillment, accounting entries, or customer communication. Use sandbox instruments and cancellable test products.

Do not brute-force promotion codes merely because the endpoint exists. Code enumeration changes the threat model, traffic profile, and authorization needed. A focused test with owned codes can verify rate and usage controls without searching real campaigns.

Avoid treating every repeated request as hostile. Networks retry, users double-click, and clients recover after timeouts. The checkout should handle those reliability cases according to its contract while still preventing an attacker from changing identity or payload to gain another effect.

Do not ship a secret in browser JavaScript to sign prices or totals. Anything delivered to the client is available to the client. Provider-signed or server-signed artifacts can be useful when the verifier holds the trust anchor, but that is an architecture decision that needs expiry, binding, and replay analysis.

Skip browser automation for atomicity that can be proven more directly at the service and database boundary. A browser is useful for the displayed-versus-stored journey, not for generating every interleaving. Keep the end-to-end case small and let integration tests control concurrent calls and inspect durable records.

Finally, do not assert behavior from a payment SDK or gateway you have not verified. Provider idempotency, callback retries, signature verification, and status transitions differ. Read the exact provider documentation and use its sandbox evidence. A generic checkout article can define the questions and local invariants, but it cannot truthfully invent a provider's answers.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 26, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official owasp.org reference

    owasp.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official owasp.org reference

    owasp.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official owasp.org reference

    owasp.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official owasp.org reference

    owasp.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What should I try first when testing checkout abuse?

Replay one money-affecting action that the rules allow only once, such as redeeming a single-use coupon. Verify the response and the durable cart, discount ledger, order, and payment state.

Is changing a price field in DevTools enough to test price tampering?

A browser edit is one useful path, but an attacker can call the endpoint directly and omit or add fields the UI never sends. Exercise the API boundary and prove that server-owned catalog prices determine the order.

How do I test two checkout requests at the same time?

Release two prepared requests together against isolated data, then inspect every durable side effect. The important oracle is the number of logical orders, reservations, discounts, and provider operations rather than which response arrived first.

Should every replay return an error?

Not necessarily. An honest retry with the same operation key and payload can return the original logical result, while a conflicting reuse can be rejected. Define that contract explicitly and still prove that only one side effect exists.

Can rate limiting prevent coupon and order races?

Rate limits reduce request volume but do not make a check-and-update operation atomic. Enforce the business invariant in durable state, then use rate controls as an additional defense against automated misuse.