# Idempotency

> Prevent duplicate financial operations with idempotent API requests.

Qualy supports idempotent requests for financial mutation endpoints, allowing you to safely retry requests without the risk of performing the same operation twice. This is especially important for payment operations where network issues or timeouts may leave you uncertain whether a request was processed.

---

## How it works

Financial endpoints such as transactions, payment intents, payment splits, and orders support idempotency. Qualy uses two mechanisms to prevent duplicates:

1. **Idempotency key** (recommended) -- You provide an explicit key via the `Idempotency-Key` header.
2. **Automatic fingerprinting** -- If no key is provided, Qualy automatically generates a fingerprint based on the request body to detect identical requests.

When an idempotency key is provided, it takes precedence over automatic fingerprinting.

---

## Using the Idempotency-Key header

Include the `Idempotency-Key` header in your request with a unique value (e.g. a UUID) that identifies the intended operation:

```javascript
try {
  const response = await fetch('https://api.qualyhq.com/v1/payment-intents/create', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'ApiKey your-api-key-here',
      'X-TENANT-ID': 'your-tenant-id-here',
      'Idempotency-Key': '550e8400-e29b-41d4-a716-446655440000',
    },
    body: JSON.stringify({
      "intentType": "ad-hoc",
      "contact": "656b9ef1a3258074a705433b",
      "dueAt": "2025-05-10T14:26:07.369Z",
      "currency": "AUD",
      "items": [
        {
          "name": "Tuition fee",
          "description": "",
          "category": "Course",
          "amount": 100000
        }
      ],
    }),
  });

  if (response.ok) {
    const data = await response.json();

    console.log(data);
  } else {
    throw new Error(`Request failed with status: ${response.status}`);
  }
} catch (error) {
  console.error(error);
}
```

---

## Behavior

| Scenario | Result |
| --- | --- |
| First request with a given key | Request is processed normally and the response is cached. |
| Retry with the same key and the same body | The cached response from the original request is returned. The operation is not executed again. |
| Same key but different request body | HTTP `422 Unprocessable Entity` is returned. This prevents replay attacks where a previously used key is reused with a modified amount or other parameters. |
| No `Idempotency-Key` header provided | Qualy automatically detects duplicate requests by fingerprinting the request body. Identical requests within the deduplication window are treated as retries. |
| Retry while the first request is still in flight | HTTP `409 Conflict` (`IDM-1003`) is returned while the original request is still processing. Wait a moment and retry with the same key — once the original completes, you'll get the cached response. |
| Key longer than 256 characters (or empty) | HTTP `400 Bad Request` (`IDM-1001`). Keys must be 1–256 characters. |

> **Note — Detecting a replayed response**
>
> When Qualy returns a cached response instead of executing the operation again, it sets the `Idempotent-Replayed: true` response header. Use it to tell a fresh execution apart from a replay in your logs.

> **Warning — Key reuse with different body**
>
> If you send a request with the same `Idempotency-Key` but a different request body, Qualy rejects it with HTTP `422` (`IDM-1002`). Always generate a new key for each unique operation.

---

## A safe-retry recipe

The point of idempotency is that you can retry a request after a timeout or network error **without** risking a double charge. The rule: generate one key per operation, then reuse that **same** key on every retry. Back off between attempts, and treat `409` (still processing) as retryable.

**JavaScript**

```javascript
import { randomUUID } from 'node:crypto';

async function createPaymentIntent(body) {
  const idempotencyKey = randomUUID(); // one key for this operation…

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch('https://api.qualyhq.com/v1/payment-intents/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'ApiKey your-api-key-here',
        'X-TENANT-ID': 'your-tenant-id-here',
        'Idempotency-Key': idempotencyKey, // …reused on every retry
      },
      body: JSON.stringify(body),
    });

    // 409 = the first attempt is still processing; 5xx = transient. Retry both.
    if (res.status === 409 || res.status >= 500) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 250)); // backoff
      continue;
    }

    const { data } = await res.json();
    if (!res.ok) throw new Error(`Failed: ${JSON.stringify(data ?? {})}`);
    return data;
  }
  throw new Error('Exhausted retries');
}
```

**PHP (Laravel)**

```php
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;

function createPaymentIntent(array $body): array
{
    $idempotencyKey = (string) Str::uuid(); // one key for this operation…

    for ($attempt = 0; $attempt < 5; $attempt++) {
        $res = Http::withHeaders([
            'Authorization' => 'ApiKey your-api-key-here',
            'X-TENANT-ID' => 'your-tenant-id-here',
            'Idempotency-Key' => $idempotencyKey, // …reused on every retry
        ])->post('https://api.qualyhq.com/v1/payment-intents/create', $body);

        // 409 = first attempt still processing; 5xx = transient. Retry both.
        if ($res->status() === 409 || $res->serverError()) {
            usleep((2 ** $attempt) * 250_000); // backoff
            continue;
        }

        $res->throw();
        return $res->json('data');
    }

    throw new \RuntimeException('Exhausted retries');
}
```

---

## Smart references

Some idempotent endpoints accept [smart references](/docs/smart-references.md), such as a contact email instead of a contact ObjectId. Idempotency uses the raw request body before those references are resolved.

That means a retry with the same key and the exact same body returns the cached response, even if the underlying record was renamed, deleted, or would now resolve to a different ObjectId. To force Qualy to resolve references again, send a new `Idempotency-Key`.

---

## Supported endpoints

Idempotency is enforced on financial mutation endpoints, including:

* **Payment intents** -- `POST /v1/payment-intents/create`
* **Transactions** -- `POST /v1/transactions/create`
* **Payment splits** -- `POST /v1/payment-splits/create`
* **Orders** -- `POST /v1/orders/create`
* **Refunds** -- `POST /v1/refund-intents/create`
* **Bank accounts** -- `POST /v1/bank-accounts/create`
* **Authorizations** and **payment gateway charges**

Endpoints marked as `header` strategy require you to send an `Idempotency-Key`; those marked `both` also fall back to automatic body fingerprinting when no key is sent.

---

## Best practices

* **Generate a unique key per operation** -- Use a UUID or another unique identifier for each distinct payment or transaction. Do not reuse keys across different operations.
* **Store keys alongside your records** -- Save the idempotency key with the corresponding record in your system so you can reliably retry with the same key if needed.
* **Retry safely on network errors** -- If a request times out or you receive a network error, retry with the same `Idempotency-Key` to safely determine whether the original request was processed.
* **Use a new key when changing reference inputs** -- If you switch from an email/name/code to an ObjectId, or want a natural identifier to be resolved again, create a new key for that request.
* **Keys expire after 24 hours** -- Idempotency keys are valid for 24 hours after the first request. After that, the same key can be reused for a new operation.
