> ## Documentation Index
> Fetch the complete documentation index at: https://skinshark.gg/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> How to retry money-moving operations safely. The Idempotency-Key header on /fund, externalId on trades and sub-users.

Anything that moves money is safe to retry **only** if the server can
recognise the retry as a duplicate. SkinShark gives you two mechanisms,
chosen per endpoint:

* **`Idempotency-Key` header** on `/fund` — transient retry safety with a
  short TTL.
* **`externalId` body field** on sub-user creation, `/market/buy`, and
  `/market/buy/quick` — your own permanent correlation id, doubles as the
  natural retry key.

## Idempotency-Key header (on funding)

`POST /merchant/users/{id}/fund` requires an `Idempotency-Key` header.
Replays with the same key — for the same merchant — return the original
transaction with `idempotent: true`.

```http theme={null}
POST /merchant/users/user_42/fund
api-key: sk_live_...
Idempotency-Key: 01h0bf2c-e9d8-4d3a-9c70-...
content-type: application/json

{ "amount": "25.00" }
```

```json theme={null}
// First call
{ "transactionId": "01h0...", "idempotent": false }

// Replay with same key
{ "transactionId": "01h0...", "idempotent": true }
```

<Tip>
  Generate the key once per logical operation, not per HTTP attempt. If your
  checkout retries on network errors, all retries should send the same key.
</Tip>

### With `@skinshark/sdk`

The SDK auto-generates an `Idempotency-Key` (UUIDv4) for every `fund` call
when you don't pass one. To pin retries to a key derived from your own
checkout id, pass it explicitly:

```ts theme={null}
import { Skinshark } from "@skinshark/sdk";

const sdk = new Skinshark({ apiKey: process.env.SKINSHARK_API_KEY! });

await sdk.users.fund(
  "user_42",
  { amount: "25.00" },
  { idempotencyKey: `checkout:${checkoutId}` },   // your stable key
);
```

### With raw fetch

```ts theme={null}
async function fundForCheckout(checkoutId: string, subUserId: string, amount: string) {
  const idempotencyKey = `checkout:${checkoutId}`;

  return api<{ transactionId: string; idempotent: boolean }>(
    `/merchant/users/${subUserId}/fund`,
    {
      method: "POST",
      headers: { "idempotency-key": idempotencyKey },
      body: JSON.stringify({ amount }),
    },
  );
}
```

### Scope

Idempotency keys are scoped per merchant: `merchant:<merchantId>:<key>`.
Two different merchants can use the same key without colliding, and a
sub-user's externalId never affects scope.

### Errors

| Code | Key                        | Meaning                                                                         |
| ---- | -------------------------- | ------------------------------------------------------------------------------- |
| 1511 | `IDEMPOTENCY_KEY_REQUIRED` | Header missing on `/fund`.                                                      |
| 1510 | `IDEMPOTENCY_CONFLICT`     | Same key, different body. The server returns the conflict instead of replaying. |

## externalId (on sub-user creation, buys, quick-buys)

`POST /merchant/users`, `POST /market/buy`, and `POST /market/buy/quick`
accept an `externalId` field. The server treats it as the natural retry
key.

### Sub-user creation

`createSubUser` replays on **matching** `externalId`:

* Same `externalId`, same email/steamId → returns the existing user with
  `idempotent: true` (no duplicate created).
* Same `externalId`, different email/steamId → 409 `EXTERNAL_ID_TAKEN`.

```ts theme={null}
const created = await sdk.users.create({
  email: "alice@example.com",
  externalId: "customer_42",
});

if (created.idempotent) {
  // Already had a sub-user with externalId customer_42, returned the existing one.
}
```

### Buys

`market.buy` and `market.quickBuy` replay on matching `externalId` —
calling either with the same `externalId` for the same sub-user returns
the **existing** Trade rather than creating a new one. This makes the
buy + retry pattern idempotent without any extra header.

```ts theme={null}
import { Skinshark } from "@skinshark/sdk";

const sdk = new Skinshark({ apiKey: process.env.SKINSHARK_API_KEY! });
const user = await sdk.as("customer_42");

// Same externalId in both calls = same Trade returned
const trade1 = await user.market.buy(items, "order-7421");
const trade2 = await user.market.buy(items, "order-7421");
// trade1.id === trade2.id
```

Or with raw fetch:

```ts theme={null}
async function buy(input: {
  externalId: string;
  subUserId: string;
  items: BuyItem[];
}): Promise<Trade> {
  return await api<Trade>("/market/buy", {
    method: "POST",
    headers: { "on-behalf-of": input.subUserId },
    body: JSON.stringify({ items: input.items, externalId: input.externalId }),
  });
}
```

Use `externalId` as your join key everywhere — when you reconcile
SkinShark trades back into your own order table, you look them up by your
own checkout ID, not the SkinShark UUID.

### Picking values

Pick `externalId` values that uniquely identify a *logical* operation —
don't recycle them across different intents. A retried checkout reuses
the same ID; a fresh checkout gets a fresh ID.

## Operations without idempotency

Some endpoints don't have idempotency support — duplicates from network
failures need different handling:

* **Catalog reads** (search, listings, item detail) — GETs, free to retry.
* **Deposit `quote` endpoints** — quotes are short-lived (TTL embedded in
  `expiresIn`); if you retry within the TTL you'll get a different quote.
  Don't retry on network failure; create a fresh quote.
* **Deposit `create` / `session` endpoints** — retrying after a network
  failure can produce two pending deposits. Pattern: walk recent deposits
  for the sub-user and **resume** the existing one rather than creating
  fresh:

```ts theme={null}
// Resume an in-progress deposit instead of creating a new one
await sdk.deposits.resume(fundingId);
// Returns the current whitelabelUrl / on-chain address.
```

If your quote expires mid-flow:

```ts theme={null}
try {
  return await sdk.deposits.gate.create({ quoteToken, chain }, { onBehalfOf: subUserId });
} catch (err) {
  if (isError(err, "INVALID_QUOTE")) {
    const quote = await sdk.deposits.gate.quote(requote, { onBehalfOf: subUserId });
    return sdk.deposits.gate.create({ quoteToken: quote.quoteToken, chain }, { onBehalfOf: subUserId });
  }
  throw err;
}
```

## When to retry vs reconcile

| Failure mode                                    | Action                                                                                |
| ----------------------------------------------- | ------------------------------------------------------------------------------------- |
| Network timeout                                 | Retry with the same idempotency key (or `externalId`).                                |
| `5xx` from SkinShark                            | Retry with the same idempotency key (or `externalId`).                                |
| `4xx` other than `429`                          | Don't retry. Fix the input.                                                           |
| `429 Rate limited`                              | Honour `Retry-After`, then retry.                                                     |
| Truly unknown state (e.g. you crashed mid-call) | Reconcile: query `/merchant/trades?externalId=<your-id>` to see if the trade made it. |

The `@skinshark/sdk` HTTP layer auto-retries 408/429/5xx with exponential
backoff and `Retry-After`-honouring delays. POST/PATCH retries are gated
on the presence of `idempotencyKey` to prevent double-charges.
