> ## 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.

# Error handling

> Retry policy, when to reconcile vs replay, and patterns for safely recovering from any failure mode.

The API returns errors in the [response envelope](/docs/guides/envelope), so
your client throws a typed `SkinsharkError` for any non-2xx response.
This page covers what to *do* with that error.

## Using `@skinshark/sdk`

If you're using the SDK, you get the typed error class plus narrowing
guards out of the box:

```ts theme={null}
import { Skinshark, SkinsharkError, isError, isAuthError, isRateLimited } from "@skinshark/sdk";

try {
  await sdk.market.buy(items, "order-1", { onBehalfOf: "u-1" });
} catch (e) {
  if (!(e instanceof SkinsharkError)) throw e;

  if (isError(e, "INSUFFICIENT_BALANCE")) /* refill */;
  if (isError(e, "PRICE_MISMATCH"))       /* refresh listing */;
  if (isAuthError(e))                     /* rotate key */;
  if (isRateLimited(e))                   await sleep(e.retryAfterMs ?? 1000);

  console.error({ key: e.key, code: e.code, requestId: e.requestId });
}
```

The SDK also auto-retries 408/429/5xx with exponential backoff and
`Retry-After` honoring. POST/PATCH retries are gated on the presence of
`idempotencyKey` to prevent double-charges. The decision tree below
applies whether you're using the SDK or raw fetch — the SDK handles the
network/timeout/5xx layer for you, and you handle the business-rule
errors at the call site.

## The retry decision tree

```text theme={null}
Got an error.
├─ Network timeout / DNS / connection refused
│  └─ Retry with same Idempotency-Key (if applicable). Bounded backoff.
│
├─ 500 INTERNAL
│  └─ Retry with same Idempotency-Key. Bounded backoff (3-5 attempts).
│
├─ 503 MARKET_UNAVAILABLE / FX_RATE_UNAVAILABLE / MAINTENANCE_MODE
│  └─ Retry once after 30s. If still failing, surface to user.
│
├─ 504 MARKET_TIMEOUT / PURCHASE_RETRY_TIMEOUT
│  └─ Don't blind-retry. Reconcile by externalId — the operation may have completed.
│
├─ 429 RATE_LIMITED
│  └─ Honor Retry-After header, then retry.
│
├─ 422 VALIDATION_FAILED / INVALID_AMOUNT / INSUFFICIENT_BALANCE / PRICE_BELOW_MINIMUM
│  └─ Don't retry. Fix the input.
│
├─ 401 / 403 (auth / forbidden)
│  └─ Don't retry. Investigate keys, IP allowlist, role, suspended account.
│
├─ 404 USER_NOT_FOUND / ORDER_NOT_FOUND / LISTING_NOT_FOUND
│  └─ Don't retry. The thing doesn't exist.
│
├─ 409 PRICE_MISMATCH / IDEMPOTENCY_CONFLICT / EMAIL_TAKEN / TRADE_NOT_CANCELLABLE
│  └─ Reconcile, don't retry. Re-quote, look up by externalId, etc.
│
└─ Truly unknown state (your process crashed mid-call)
   └─ Reconcile by externalId before retrying anything.
```

## Reconcile vs replay

**Replay** = send the same request again with the same `Idempotency-Key`.
The server deduplicates and returns the original result. Only `POST
/merchant/users/{id}/fund` supports this today.

**Reconcile** = query the API to discover whether the previous attempt
succeeded, regardless of whether you ever saw the response.

When in doubt, reconcile. Replay is only safe on idempotent endpoints.

```ts theme={null}
async function reconcileTrade(checkoutId: string): Promise<Trade | null> {
  // Look up by your own externalId
  const result = await api<{
    items: Trade[];
    nextCursor: string | null;
  }>(`/merchant/trades?externalId=${encodeURIComponent(checkoutId)}&limit=1`);

  return result.items[0] ?? null;
}

async function buyOrReconcile(input: BuyInput): Promise<Trade> {
  try {
    return await api("/market/buy", { /* … */ });
  } catch (err) {
    // Trade externalId is unique per (subUser, externalId). A duplicate
    // surfaces as a generic CONFLICT — reconcile to find the original.
    if (err instanceof SkinsharkError && err.key === "CONFLICT") {
      const existing = await reconcileTrade(input.checkoutId);
      if (existing) return existing;
    }
    throw err;
  }
}
```

## Bounded retry helper

A reusable retry-with-backoff for safe operations:

```ts theme={null}
interface RetryOptions {
  attempts?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
  shouldRetry?: (err: unknown) => boolean;
}

export async function withRetry<T>(
  fn: () => Promise<T>,
  opts: RetryOptions = {},
): Promise<T> {
  const attempts = opts.attempts ?? 3;
  const base = opts.baseDelayMs ?? 500;
  const max = opts.maxDelayMs ?? 8_000;
  const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;

  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      const last = i === attempts - 1;
      if (last || !shouldRetry(err)) throw err;
      const delay = Math.min(max, base * 2 ** i) + Math.random() * 200;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw new Error("unreachable");
}

const RETRYABLE_KEYS = new Set([
  "INTERNAL",            // 500
  "MARKET_UNAVAILABLE",  // 503
  "FX_RATE_UNAVAILABLE", // 503
  "MAINTENANCE_MODE",    // 503
  "RATE_LIMITED",        // 429 — pair with Retry-After
]);

function defaultShouldRetry(err: unknown): boolean {
  if (!(err instanceof SkinsharkError)) return true; // network/parse errors
  return RETRYABLE_KEYS.has(err.key);
}
```

```ts theme={null}
// Safe: idempotency key bound to checkoutId, retry on transient server errors
await withRetry(() =>
  api(`/merchant/users/${subUserId}/fund`, {
    method: "POST",
    headers: { "idempotency-key": `checkout:${checkoutId}` },
    body: JSON.stringify({ amount }),
  }),
);
```

## Common error keys, by category

### Auth (don't retry — fix the credential)

| Key                 | Cause                                                         | Fix                                    |
| ------------------- | ------------------------------------------------------------- | -------------------------------------- |
| `MISSING_API_KEY`   | No `api-key` header.                                          | Add it.                                |
| `INVALID_API_KEY`   | Key not found / wrong env.                                    | Check value and env.                   |
| `API_KEY_REVOKED`   | Key was revoked in dashboard.                                 | Rotate.                                |
| `API_KEY_IP_DENIED` | Source IP not on allowlist.                                   | Update allowlist or pin to NAT IP.     |
| `FORBIDDEN`         | Calling a JWT-only route, or `On-Behalf-Of` on `/merchant/*`. | Drop the header / use the right route. |
| `USER_NOT_FOUND`    | `On-Behalf-Of` ID not owned by your merchant.                 | Check the value.                       |
| `ACCOUNT_SUSPENDED` | Merchant or sub-user suspended.                               | Reactivate before retrying.            |

### Money (don't retry — surface to user)

| Key                          | Cause                                      | Fix                             |
| ---------------------------- | ------------------------------------------ | ------------------------------- |
| `INSUFFICIENT_BALANCE`       | Wallet balance \< required.                | Top up or reject the operation. |
| `TRANSFER_CURRENCY_MISMATCH` | Merchant ≠ sub-user wallet currency.       | Use deposit flow.               |
| `INVALID_AMOUNT`             | Amount ≤ 0 or wrong format.                | Validate input.                 |
| `MERCHANT_MUST_BE_USD`       | Tried to set merchant currency to non-USD. | Merchants are USD-only.         |

### Trade (mostly user-facing)

| Key                      | Cause                                             |
| ------------------------ | ------------------------------------------------- |
| `LISTING_NOT_FOUND`      | Listing already sold or removed.                  |
| `PRICE_MISMATCH`         | Listing price moved above `maxPrice`. Re-quote.   |
| `PRICE_BELOW_MINIMUM`    | Quick-buy `maxPrice` below current minimum.       |
| `TRADEURL_REQUIRED`      | Sub-user has no primary Steam trade URL set.      |
| `TRADEURL_INVALID_TOKEN` | Trade URL invalid, expired, or inventory private. |
| `TRADEURL_ESCROW`        | Counterparty's account has trade hold enabled.    |
| `TRADE_NOT_CANCELLABLE`  | Trade not in a cancellable state.                 |
| `TRADE_CANCEL_TOO_SOON`  | Trade must be ≥ 30 minutes old to cancel.         |

### Idempotency

| Key                        | Cause                                                               |
| -------------------------- | ------------------------------------------------------------------- |
| `IDEMPOTENCY_KEY_REQUIRED` | Header missing on `/fund`.                                          |
| `IDEMPOTENCY_CONFLICT`     | Same key, different body — server returns the original transaction. |

The full table is in [Errors](/docs/reference/errors).

## Async trade outcomes

The keys above are *synchronous* — they come straight back from the `buy` /
`quick-buy` call. A trade that was accepted can still fail *asynchronously* while
the marketplace fulfills it. That outcome arrives via webhook (`trade.*`) and on
the trade's terminal `status`:

| Status      | Meaning                                                                           | Refund                  |
| ----------- | --------------------------------------------------------------------------------- | ----------------------- |
| `completed` | Items delivered, hold released.                                                   | —                       |
| `failed`    | Couldn't be placed/delivered. Each item's `error` carries a `TradeFailureCode`.   | Full                    |
| `canceled`  | Canceled before delivery (by the platform, or by the user while still `pending`). | Full                    |
| `declined`  | Buyer's fault after the Steam offer went out (declined / let it expire).          | Full minus a 2% penalty |
| `reverted`  | Delivered, then recalled by the supplier.                                         | Full                    |

On a `failed` item, `error` is one of:

| `TradeFailureCode`         | Meaning                                                                                                     | What to do                                                         |
| -------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `LISTING_UNAVAILABLE`      | The listing sold or was delisted.                                                                           | Retry with another listing.                                        |
| `NO_LISTING_AT_PRICE`      | Nothing on sale at or below your `maxPrice`.                                                                | Re-quote higher, or wait for restock.                              |
| `PRICE_CHANGED`            | Price moved above your `maxPrice`.                                                                          | Re-quote and retry.                                                |
| `TRADE_URL_INVALID`        | Buyer's Steam trade URL is invalid or expired.                                                              | Fix the user's trade URL.                                          |
| `STEAM_ACCOUNT_RESTRICTED` | Buyer's Steam account can't receive trades (VAC/ban/family view/private).                                   | User fixes their Steam account.                                    |
| `BUYER_TRADE_RESTRICTED`   | The marketplace suspended this buyer, e.g. for repeated trade-offer no-shows — usually until a stated time. | User accepts their pending offers; retrying sooner cannot succeed. |
| `MARKET_UNAVAILABLE`       | Temporary upstream / our-side issue.                                                                        | Retry later.                                                       |
| `PURCHASE_FAILED`          | Unexpected / unmapped failure.                                                                              | Reconcile, then retry or surface.                                  |

`canceled` and `declined` items carry no `error` code — the status *is* the
outcome. An optional `errorDetail` string carries the raw marketplace reason
(e.g. `"c5:ITEM_SOLD item already sold"`) for debugging only — treat it as
opaque and never branch on it.

## What to log

Every error has a `requestId`. Log it on every API call (success or fail)
so support can trace through our side. A useful client-side log line:

```ts theme={null}
catch (err) {
  if (err instanceof SkinsharkError) {
    console.error(
      {
        op: "fund",
        subUserId,
        amount,
        requestId: err.requestId,
        code: err.code,
        key: err.key,
      },
      err.message,
    );
  } else {
    console.error({ op: "fund", subUserId, amount }, err);
  }
}
```

When you file a support request, paste two or three `requestId`s — they
let us pull the full request/response and ledger trace from our side
without back-and-forth.
