Idempotency

How to safely retry Scan to Pay requests without creating duplicate transactions — using merchantReference as your idempotency key.

The Scan to Pay platform enforces transaction uniqueness via the merchantReference field on every code-creation request. A merchantReference is unique per merchant — the database has a UNIQUE (merchantReference, merchantId) constraint. Reuse the same reference and the second request is rejected.

You don't need a separate Idempotency-Key HTTP header — merchantReference is the idempotency key. Your job is to choose a stable, deterministic value and reuse it across retries.


Why this matters

Real-world systems experience:

  • Network blips that drop responses but commit the server-side write
  • Client retries triggered by timeouts where the original request actually succeeded
  • Concurrent webhook deliveries that race with your foreground request
  • Crashes between sending a request and persisting the response

Without an idempotency strategy, any of these can produce duplicate transactions — your customer pays twice and you have to refund one. With idempotency, you safely retry and the platform guarantees at-most-once execution per merchantReference.


The pattern

For every payment attempt:

  1. Generate a merchantReference once, when the customer first initiates the payment intent in your system. Use a UUID, an order ID prefix + sequence, or any stable identifier that maps to that one attempt.

  2. Persist the merchantReference before calling Scan to Pay. Write it to your order record. If your process crashes after this point, you can recover by querying the platform with the saved reference.

  3. Call POST /code/create with the persisted reference.

  4. If the call succeeds — store the returned code and proceed.

  5. If the call fails with a network error or you don't receive a response — do not generate a new merchantReference. Retry the call with the same value.

  6. If the call fails because the reference already exists — query the platform with the existing reference (POST /code/queryRef or getTransactionState) to recover the in-flight transaction. Don't create a new one.

async function createPaymentCode(order) {
  // 1. Generate once when the customer initiates
  if (!order.scanToPayRef) {
    order.scanToPayRef = crypto.randomUUID();
    await orders.save(order); // PERSIST BEFORE CALLING
  }

  // 2. Call create. Retries with the same ref are safe.
  try {
    const response = await scanToPay.post('/code/create', {
      merchantReference: order.scanToPayRef,
      amount: order.total,
      useOnce: true,
    });
    return response.code;
  } catch (err) {
    if (err.code === 'INVALID_REQUEST' && err.message.includes('duplicate')) {
      // We already created this — fetch it
      const existing = await scanToPay.post('/code/queryRef', {
        merchantReference: order.scanToPayRef,
        code: order.scanToPayCode, // saved from a previous attempt, if any
      });
      return existing.code;
    }
    throw err;
  }
}

Reference rules

RuleDetails
ScopeUniqueness is per merchant (the UNIQUE index is on (merchantReference, merchantId)). Two different merchants can independently use the same reference.
RequiredA merchantReference is required on POST /code/create. The platform rejects the request if it's missing or empty.
LengthUp to 45 characters.
Allowed charactersUTF-8 alphanumeric, hyphens, and underscores. Avoid spaces and special characters to keep URLs clean and logs readable.
No expiry on uniquenessA reference is unique forever for that merchant. Even after a transaction completes, the reference can't be reused. Pick a scheme that never collides over the merchant's lifetime.

Idempotent operations across the API

OperationIdempotency mechanismNotes
POST /code/createmerchantReferencePrimary write — must be idempotent
GET /code/{code}Naturally idempotentPure read
PUT /code/{code}Naturally idempotent at the API levelEach update overwrites; safe to retry the same payload
DELETE /code/{code}Naturally idempotentDeleting an already-deleted code returns the same state
POST /code/queryRefNaturally idempotentPure read keyed by (code, merchantReference)
POST /purchase/refund/{transactionId}Idempotent at transaction levelA second refund attempt against an already-refunded transaction returns END_REFUNDED rather than processing twice
DELETE /purchase/{transactionRef} (reverse)Idempotent at transaction levelOnly one reversal per transaction is allowed

Choosing a good reference

A few patterns that work well:

order-{customer-id}-{order-id}-{attempt-id}
inv-{invoice-number}
{ksuid}
{uuidv4}
{your-system-prefix}-{snowflake-id}

Things to avoid:

  • Pure timestamps (1747235400000) — collisions if two requests happen in the same millisecond
  • Customer-readable sequence numbers without a prefix (12345) — easy to clash across systems or test/prod
  • Anything containing card data, MSISDN, or other PII — the reference shows up in webhook payloads and logs
  • Re-deriving the reference from request data at retry time — if any of that data changes (even insignificantly) you'll produce a new reference and break idempotency

What about retries on /purchase, refunds, reversals?

These are operations on existing transactions identified by transactionId rather than merchantReference. They're naturally bounded — the platform tracks transaction state and rejects double-refunds, double-reversals, etc.

The safest retry pattern for these:

  1. Attempt the operation.
  2. On error or no response, query the transaction state (getTransactionState).
  3. Branch based on the current state — don't blindly retry. If the state is already END_REFUNDED or END_REVERSED, the operation succeeded on the previous attempt.

Things the platform does not support

Be aware these general-purpose idempotency features are not part of Scan to Pay today:

  • No Idempotency-Key header. Scan to Pay uses the body field merchantReference exclusively.
  • No "first response replay." If you re-send a request with a duplicate merchantReference, you get an error — not a replay of the first response. Use queryRef to recover.
  • No client-controlled retention window. Reference uniqueness is permanent; you can't expire a reference and reuse it after some period.
  • No idempotency on read endpoints. Reads are naturally idempotent without needing keys.

If your security or platform review requires any of these features (especially "first response replay"), raise it with [email protected] so it can be factored into the roadmap.


What's next

  • Reference validation errorsErrors (codes 440 VALIDATION_ERROR, 461 INVALID_REQUEST)
  • Retrying after a failed callWebhooks recovery section
  • Sandbox testing of duplicate-reference behaviourSandbox and test cards
  • Rate limits when retrying aggressivelyRate limits