Skip to main content
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.
POST /merchant/users/user_42/fund
api-key: sk_live_...
Idempotency-Key: 01h0bf2c-e9d8-4d3a-9c70-...
content-type: application/json

{ "amount": "25.00" }
// First call
{ "transactionId": "01h0...", "idempotent": false }

// Replay with same key
{ "transactionId": "01h0...", "idempotent": true }
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.

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:
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

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

CodeKeyMeaning
1511IDEMPOTENCY_KEY_REQUIREDHeader missing on /fund.
1510IDEMPOTENCY_CONFLICTSame 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.
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.
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:
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:
// 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:
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 modeAction
Network timeoutRetry with the same idempotency key (or externalId).
5xx from SkinSharkRetry with the same idempotency key (or externalId).
4xx other than 429Don’t retry. Fix the input.
429 Rate limitedHonour 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.