The error shape
| Field | Description |
|---|---|
status | HTTP status code (for example 409). |
code | A stable, machine-readable string (the contract - branch on this, not on message). |
message | A human-readable description. May change; never branch on it. |
retryAfterMs | Present on rate-limit errors, parsed from Retry-After. |
requestId | The X-Request-Id of the failed call. Quote it to support. |
Branch on
code, never on message. The code value is part of the API
contract and is stable across versions; message is for humans and can change.Status to error kind
Each SDK maps the HTTP status to an idiomatic typed error (a subclass in TypeScript/Swift, a sealed type in Kotlin) so you can catch exactly what you mean.| Status | Kind | TypeScript | Common codes |
|---|---|---|---|
| 401 | unauthorized | UnauthorizedError | UNAUTHORIZED |
| 403 | forbidden | ForbiddenError | FORBIDDEN |
| 404 | notFound | NotFoundError | NOT_FOUND |
| 409 | conflict | ConflictError | REDEMPTION_ALREADY_EXISTS, IDEMPOTENCY_KEY_REUSED |
| 422 | validation | ValidationError | INSUFFICIENT_POINTS, OFFER_UNAVAILABLE, VALIDATION |
| 429 | rateLimited | RateLimitError | RATE_LIMITED |
| 5xx | server | ApiError | INTERNAL |
network, timeout, decoding, and notConfigured.
Handling errors
import {
ApiError,
ConflictError,
initialize,
NetworkError,
RateLimitError,
TimeoutError,
UnauthorizedError,
ValidationError,
} from "@expys/sdk";
const token = process.env.EXPYS_MEMBER_TOKEN;
if (!token) {
throw new Error(
"Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)",
);
}
const expys = initialize({
baseUrl: process.env.EXPYS_BASE_URL,
environment: "sandbox",
// A short ceiling so the timeout branch is reachable on a slow network.
timeoutMs: 10_000,
token,
});
async function main(): Promise<void> {
try {
await expys.createRedemption({
offer: process.env.EXPYS_OFFER_ID ?? "off_1",
});
console.log("redemption created");
} catch (error) {
// Prefer instanceof on the specific subclass, then refine with the code.
if (
error instanceof ConflictError &&
error.code === "REDEMPTION_ALREADY_EXISTS"
) {
console.log("already redeemed by this member");
} else if (error instanceof ValidationError) {
console.log(`validation failed: ${error.code}`);
} else if (error instanceof UnauthorizedError) {
console.log(
"token rejected; re-exchange a fresh member token on your backend",
);
} else if (error instanceof RateLimitError) {
console.log(`rate limited; retry after ${error.retryAfterMs ?? "?"}ms`);
} else if (error instanceof TimeoutError) {
console.log("request timed out");
} else if (error instanceof NetworkError) {
console.log("network failure; no response received");
} else if (error instanceof ApiError) {
// Unknown code: handle as the generic class for its status. Quote requestId.
console.log(
`api error ${error.status} (${error.code}) requestId=${error.requestId ?? "n/a"}`,
);
} else {
throw error;
}
}
}
import ExpysSDK
import Foundation
@main
struct ErrorHandlingExample {
static func main() async throws {
let environment = ProcessInfo.processInfo.environment
guard let token = environment["EXPYS_MEMBER_TOKEN"] else {
fatalError("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")
}
let baseURL =
environment["EXPYS_BASE_URL"].flatMap(URL.init(string:))
?? ExpysConfiguration.defaultBaseURL
let offer = environment["EXPYS_OFFER_ID"] ?? "off_1"
let client = ExpysClient(
configuration: ExpysConfiguration(
token: token,
environment: .sandbox,
baseURL: baseURL,
// A short ceiling so the timeout branch is reachable on a slow network.
timeout: 10
)
)
do {
_ = try await client.createRedemption(.init(offer: offer))
print("redemption created")
} catch ExpysError.api(let error) {
// Switch on the coarse `kind`, then refine with the stable `code`.
switch error.kind {
case .conflict where error.code == "REDEMPTION_ALREADY_EXISTS":
print("already redeemed by this member")
case .validation:
print("validation failed: \(error.code)")
case .unauthorized:
print("token rejected; re-exchange a fresh member token on your backend")
case .rateLimited:
print("rate limited; retry after \(error.retryAfterMs.map(String.init) ?? "?")ms")
default:
// Unknown code: handle as the generic class for its kind. Quote requestId.
print("api error \(error.status) (\(error.code)) requestId=\(error.requestId ?? "n/a")")
}
} catch ExpysError.timeout {
print("request timed out")
} catch ExpysError.network(let message) {
print("network failure; no response received: \(message)")
}
}
}
package com.expys.sdk.examples.errorhandling
import com.expys.sdk.ApiErrorKind
import com.expys.sdk.ExpysClient
import com.expys.sdk.ExpysConfiguration
import com.expys.sdk.ExpysException
import com.expys.sdk.models.CreateRedemptionRequest
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val token = System.getenv("EXPYS_MEMBER_TOKEN")
?: error("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")
val client = ExpysClient.create(
ExpysConfiguration(
token = token,
baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
),
)
try {
client.createRedemption(CreateRedemptionRequest(offer = "off_does_not_exist"))
} catch (error: ExpysException.Api) {
when (error.error.kind) {
ApiErrorKind.NOT_FOUND -> println("offer not found (${error.error.code})")
ApiErrorKind.CONFLICT -> println("already redeemed (${error.error.code})")
ApiErrorKind.RATE_LIMITED -> println("rate limited; retry after ${error.error.retryAfterMs} ms")
else -> println("api error ${error.error.status} ${error.error.code}: ${error.error.message}")
}
error.error.requestId?.let { println("request id: $it") }
} catch (error: ExpysException.Timeout) {
println("request timed out")
} catch (error: ExpysException.Network) {
println("network error: ${error.detail}")
}
}
Stable codes worth handling
Thesecode values are stable and worth branching on explicitly:
| Code | Status | Meaning |
|---|---|---|
REDEMPTION_ALREADY_EXISTS | 409 | The member already redeemed this offer. |
IDEMPOTENCY_KEY_REUSED | 409 | An idempotency key was reused with a different request body. |
INSUFFICIENT_POINTS | 422 | The member’s balance is below the offer’s pointsPrice. |
OFFER_UNAVAILABLE | 422 | The offer is expired or not redeemable. |
RATE_LIMITED | 429 | Too many requests; honor retryAfterMs. |
WEBHOOK_ENDPOINT_LIMIT | 422 | The org’s webhook-endpoint limit was reached. |
WEBHOOK_EVENT_UNKNOWN | 422 | A subscribed event name is not in the catalog. |
WEBHOOK_URL_NOT_ALLOWED | 422 | The webhook URL failed validation (for example, not HTTPS). |
Using the request id
When something goes wrong in production, logrequestId. It is the
X-Request-Id header on the response and lets support trace the exact call in
the server logs. Every response carries one, success or failure.
Retryable failures (
429 and 5xx) are retried automatically by the SDK with
full-jitter backoff. See Retries and idempotency.