Error Handling
Handle API errors, conflicts and retries safely
SDK methods resolve to their declared response type and throw on failure; there is no result wrapper.
import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";
const arky = initialize(import.meta.env.PUBLIC_ARKY_PUBLISHABLE_KEY, {
locale: "en",
createVisitorIdentity: () => ({
customer_id: crypto.randomUUID(),
session_id: crypto.randomUUID(),
}),
createSessionId: () => crypto.randomUUID(),
createId: () => crypto.randomUUID(),
createOrderToken,
});
Request Errors
import { ArkyApiError } from "arky-sdk/storefront";
try {
const product = await arky.eshop.product.get({ id: "26daa1d7-617e-497f-a37d-6b92768c3fd8", catalog_id: catalogId });
console.log(product.key);
} catch (error) {
if (error instanceof ArkyApiError) {
console.error(error.statusCode, error.code, error.message, error.validationErrors, error.requestId);
} else {
throw error;
}
}
A refused request throws an ArkyApiError with statusCode, code, message,
validationErrors, method, url and an optional requestId. The class is exported from
arky-sdk, arky-sdk/storefront and arky-sdk/admin, and instanceof works across all three;
isArkyApiError(value) checks a value the same way. A 401 the SDK raises itself, such as a session
it can’t refresh, carries GENERAL.UNAUTHENTICATED, and a checkout refused because the quote
changed throws a CartPresentationChangedError, an ArkyApiError that carries the new quote.
Network failures, aborted requests and unreadable answers throw an ArkyNetworkError (named
NetworkError, AbortError or ParseError) whose code is network, aborted or
unreadable_answer. Every error the SDK throws at runtime is an ArkyError with a code; map the
code to your own text. Server errors (5xx) carry no internal detail; use the request id to find them
in Arky’s logs.
Don’t show raw provider or internal errors to customers, and never log credentials, tokens, signing secrets or full customer payloads.
Conflicts
409 means the request can’t apply to the record as it is now. Its code says why:
| Code | Cause | What to do |
|---|---|---|
GENERAL.CONFLICT | a stale expected_updated_at, or another request under an id that is already used | someone changed the record, or the same action already applied: reload and decide again; a different action needs a new id |
REFERENCE.STILL_REFERENCED | a delete refused because something points at the record | the message names who points: remove the pointer first, or archive instead |
REFERENCE.DELETING | a record that is being deleted | it can’t change or be pointed at any more |
STORE.DELETING | the store is being closed | nothing in it can change |
a code ending in _TAKEN, such as PRODUCT.KEY_TAKEN | another record already holds a value that must be unique, such as a key or a slug | choose another value, or work with the record that holds it |
a named rule, such as PRICE.OVERLAP or CART.OFFER_EXPIRED | the request breaks a business rule | the code and message say which; reloading doesn’t change the answer |
Only GENERAL.CONFLICT means “changed since you read it”. A named code is a definite answer about
the request itself, so show it to the person and let them change the request.
Retrying Safely
Every create carries an id, and every action carries the updated_at it read, so a retry is safe
by construction:
- Keep the id with the request until the answer confirms it. A retry with the same id and the same request returns the existing record: one order, one refund, one email.
- Never make a fresh id in a retry loop. A new id is a new business action and can duplicate a charge, an email or a booking.
- A repeated action finds the record changed and is refused with
409; read the record to see whether your first try applied.
Storefront Flows
The storefront flows that create something keep their own id: a form submission
(forms.submit), a sign-in code request (customer.requestCode), a support
conversation and its messages (support.conversation.start, sendMessage), a checkout without
order_id and order_token, and a free group sign-up (customerGroup.joinFree,
newsletter.join). Every id you leave out comes from your createId, and an order token from your
createOrderToken. The SDK keeps the pending ids with a fingerprint of the request in
localStorage, under a lock shared by the tabs:
- the same request sent again reuses the id, so a lost answer never makes a second record;
- a changed request is a new request and gets a new id;
- a success or a definite refusal clears it.
isDefiniteRefusal(error)(fromarky-sdk/utils) is true for every 4xx except 408 and 429; a network failure or a 5xx keeps the id for the retry.
Only the fingerprint is kept, never the answers, an email or the chat text. A checkout and a free
sign-up also refuse a different request while one is pending, because the pending one may already
have placed an order: recover it first. Without localStorage and Web Locks, during SSR or in
Node, these flows refuse to start with a DurableRequestError whose code is storage; pass your
own id there.
import type { FormValues } from "arky-sdk";
import { isDefiniteRefusal } from "arky-sdk/utils";
const form = await arky.forms.getByKey({ key: "contact-form" });
async function send(values: FormValues): Promise<"sent" | "refused" | "unknown"> {
try {
await arky.forms.submit({ form, language: "en", values });
return "sent";
} catch (error) {
return isDefiniteRefusal(error) ? "refused" : "unknown";
}
}
On "unknown", keep the answers on screen and offer a retry: the same values sent again reuse the
kept id, so the message arrives once. On "refused", show the error and let the person change the
request.
Admin Creates
An Admin create has no kept id: keep the request yourself until Arky confirms it. The SDK’s
durable request helpers keep it in localStorage across reloads and lock it across tabs. They fail
closed when storage or locks are unavailable:
import { createAdmin } from "arky-sdk/admin";
import type { CreatePaymentRefundParams } from "arky-sdk";
import {
clearDurableRequest,
durableRequestPayload,
getOrCreateDurableRequest,
readDurableRequest,
withDurableRequestLock,
} from "arky-sdk/utils";
const sdk = createAdmin({ apiUrl: "https://api.arky.io" });
const storageKey = `arky:payment-refund:${paymentId}`;
const payment = await withDurableRequestLock(storageKey, "refund", async () => {
const saved = readDurableRequest(storageKey, "refund");
const request: CreatePaymentRefundParams = saved
? (durableRequestPayload(saved) as CreatePaymentRefundParams)
: {
store_id: storeId,
payment_id: paymentId,
id: crypto.randomUUID(),
money: { amount: 1999, currency: "eur" },
reason: "customer_request",
application: {
type: "commercial_credit",
allocations: [{ order_credit_id: creditId, order_credit_allocation_id: allocationId, amount: 1999 }],
},
notify_customer: true,
};
const durable = saved ?? getOrCreateDurableRequest(storageKey, request, "refund");
const result = await sdk.eshop.payment.createRefund(request);
clearDurableRequest(durable, "refund");
return result;
});
Clear the saved request only after Arky confirmed it. A browser Admin client signs in with the account code flow; never give a browser a secret key.
Unknown Outcomes
When a provider’s answer is lost, Arky never sends again on its own: an email becomes
maybe_sent, a payment or refund call unknown. Arky settles it from the provider’s own records
or signed events. Don’t create a second payment, refund or email because one is unclear.
Read-Only Retries
Plain retries suit reads only, such as get, find and status reads. Use bounded attempts and a
growing delay:
import { isArkyApiError } from "arky-sdk/storefront";
async function getWithRetry<T>(request: () => Promise<T>, attempts = 3): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await request();
} catch (error) {
lastError = error;
if (isArkyApiError(error) && error.statusCode < 500) throw error;
if (attempt + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
}
}
throw lastError;
}Never wrap a create or an action in this helper with a new id per try. A timeout doesn’t prove that nothing happened.
Application Handling
Map errors at the edge of your app and show a stable message:
import { ArkyApiError } from "arky-sdk/storefront";
async function loadProduct(id: string) {
try {
return await arky.eshop.product.get({ id, catalog_id: catalogId });
} catch (error) {
if (error instanceof ArkyApiError && error.statusCode === 404) return null;
throw error;
}
}