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