# DeboPay idempotency guide

> Step-by-step integration (PHP · Laravel · Node): [INTEGRATION-GUIDE.md](./INTEGRATION-GUIDE.md)

Money-moving **POST** endpoints use a **client idempotency key**, enforced by middleware `EnforceIdempotency` (alias `idempotency`) against `config/idempotency.php` paths. Persistence is the `idempotency_keys` table (SHA-256 request fingerprint only — never raw bodies). Add future money-moving routes to that path list so they inherit enforcement automatically.

## Two identifiers (do not merge them)

| Field | Who owns it | Purpose |
|-------|-------------|---------|
| **`reference`** | Merchant / business | Order ID in *your* books; must be unique per new payment intent |
| **`idempotency_key`** / **`Idempotency-Key`** | Client (browser, app, server) | Same HTTP operation retried safely |

- **Same merchant + same idempotency key + same fingerprint** → exact original **200/201** replay (`Idempotency-Replayed: true`). Payment logic does not run again.
- **Same key + different payload** → **409** `idempotency_conflict`.
- **Header and body key both sent and differ** → **409** `idempotency_key_mismatch` (header wins when they match).
- **Missing key** → **422** `idempotency_key_required`.
- **Invalid key** → **422** `invalid_idempotency_key`.
- **In-flight twin** → brief wait, else **202** `{ "status": "processing" }`.
- **Same `reference` + different idempotency key** → **409** `duplicate_reference`.
- **Auth / validation failures are never cached**; only successful 200/201 responses are replayable (24h TTL). Expired keys behave as new. Cleanup: `php artisan idempotency:purge` (hourly schedule).

`reference` alone does **not** stop double-submit: two parallel POSTs can share one reference before either completes. Idempotency stops duplicate *execution*; reference stops duplicate *business identity*.

### Key rules

- Length **16–128**, charset **`[A-Za-z0-9_-]`**
- **New business intent** → new key (and usually new `reference`)
- **Retry** (timeout, 5xx, user double-click) → **same** key and **same** body

---

## Web wallet (browser)

Users never type a key. The UI generates one per form submit:

| Flow | Where |
|------|--------|
| P2P / bank transfer | `wallet/show.blade.php` — `crypto.randomUUID()` hidden field |
| Top-up | `wallet/partials/topup-panel.blade.php` — new UUID when starting a new top-up |
| FX send / deposit | `wallet/forex.blade.php` — per-form UUID |

**Double-click:** first submit wins; a second submit should use a **new** idempotency key (Alpine resets key when opening a new top-up). If the user refreshes and pays again, that is a **new** intent → new key.

**CSRF + session** protect the web app; idempotency protects the **server** if the same POST is replayed (back button, flaky mobile network).

---

## Merchant API (REST)

Required on:

- `POST /v1/payments/initialize`
- `POST /v1/payments` / `POST /v1/charges`
- Wallet mobile/API: top-up, transfer, bills (see OpenAPI / mobile controllers)

Send either:

```http
Idempotency-Key: 7f3c9a2e1b4d6f8a0c2e4d6f8a0b2c4d
```

or JSON:

```json
"idempotency_key": "7f3c9a2e1b4d6f8a0c2e4d6f8a0b2c4d"
```

**Initialize** stores the key on `transactions.idempotency_key`; a retry returns the existing row.

**Charges** pass the key into `initialize` the same way.

Example (note key length ≥ 16):

```bash
curl -X POST https://api.debopay.com/v1/payments/initialize \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pay_716181_ab12cd34ef56gh78" \
  -d '{"amount":5000,"currency":"ETB","reference":"716181", ...}'
```

Server-side wallet transfers use `IdempotencyGuard` (cache + lock, 24h TTL) for P2P, business transfers, and some dashboard flows.

---

## SDKs (PHP, Node, Python, Go)

Official SDKs support idempotency across languages. PHP, Node, and Python **auto-generate** a valid 32-char hex idempotency key when the integrator omits one; Go examples should pass the key explicitly. This covers the common case, but for **retry safety**, store the key on your order/session and pass it explicitly so retries reuse the same key.

### Auto-idempotency (built in)

Every `initializePayment()` / `charge()` call checks for `idempotency_key` in the payload. If missing or shorter than 16 chars, the SDK generates one automatically.

- **PHP SDK** — `withIdempotency()` uses `bin2hex(random_bytes(16))`
- **Node SDK** — `_withIdempotency()` uses `crypto.randomBytes(16).toString('hex')`
- **Python SDK** — `_with_idempotency()` uses `secrets.token_hex(16)`
- **Go SDK** — `withIdempotency()` uses `crypto/rand` + `hex.EncodeToString`

### Recommended pattern (explicit key for retries)

1. At checkout start: generate a key and store it on the order row.
2. On `initializePayment` / `charge`: pass that stored key.
3. On retry after network error: **same** payload + **same** key.
4. On new order: new `reference` + new key.

SDKs retry **429 / 5xx** automatically (up to `max_retries`, default 2) without changing the idempotency key.

**PHP:**

```php
$order->debopay_idempotency_key ??= bin2hex(random_bytes(16));
$order->save();

$dp = new \DeboPay\DeboPay('sk_test_...');
$dp->initializePayment([
    'amount'          => 5000,
    'currency'        => 'ETB',
    'reference'       => $order->id,
    'idempotency_key' => $order->debopay_idempotency_key,
]);
```

**Node:**

```js
const { DeboPay } = require('@debopay/node');
const dp = new DeboPay('sk_test_...');

order.deboPayIdempotencyKey ??= crypto.randomUUID();
await dp.initializePayment({
    amount: 5000,
    currency: 'ETB',
    reference: order.id,
    idempotency_key: order.deboPayIdempotencyKey,
});
```

**Python:**

```python
from debopay import DeboPay
dp = DeboPay('sk_test_...')

order.debopay_idempotency_key = order.debopay_idempotency_key or secrets.token_hex(16)
dp.initialize_payment(
    amount=5000,
    currency='ETB',
    reference=order.id,
    idempotency_key=order.debopay_idempotency_key,
)
```

---

## FX settlement without enabling forex UI

Testers may hold USD in `wallet_forex_balances` while `FOREX_ENABLED=false`. Ops can move FX → ETB on the ledger **without** turning on the public forex hub:

```bash
cd api
php artisan wallet:settle-fx-to-etb --phone=0913830987          # dry-run
php artisan wallet:settle-fx-to-etb --phone=0913830987 --fix  # apply
php artisan wallet:settle-fx-to-etb --phone=0913830987 --fix --waive-fee
php artisan wallet:settle-fx-to-etb --phone=0913830987 --currency=USD --fix
```

This debits FX balances, credits ETB, writes `forex_conversions` + audit — same accounting as conversion, but skips public trade gates.

After settlement, users transfer in **ETB** from the normal **Send** tab.

---

## FAQ

**Can the API derive idempotency from `reference` only?**  
Not as a replacement. Reference is not guaranteed unique across retries of the *same* attempt vs a *new* attempt with a reused order number. Keep both fields.

**Is idempotency “extra security”?**  
It is **integrity / safety** (no duplicate debits), complementary to auth, webhooks, and unique references.

**INSA / audit**  
Short or missing keys on wallet top-up are rejected (`wallet.topup_idempotency_min_length`, default 16).
