Error Handling
Every ShipPulse API error uses a consistent JSON schema. This guide covers HTTP codes, the error object structure, and recommended retry strategies.
Error response schema
All non-2xx responses return a JSON body with the following structure:
{
"error": {
"code": "validation_failed",
"message": "Human-readable description of the error",
"details": [
{
"field": "title",
"message": "Title is required"
}
]
}
}| Field | Type | Description |
|---|---|---|
| error.code | string | Machine-readable error code (snake_case) |
| error.message | string | Human-readable message, safe to display to users |
| error.details | array | null | Field-level errors for validation failures (422) |
| error.details[].field | string | The field path that failed validation |
| error.details[].message | string | What went wrong with that field |
HTTP status codes
The request body is malformed or missing required fields. Check that all required parameters are present and correctly typed.
Missing or invalid API key. Verify your Authorization header starts with Bearer sp_ and the key has not been revoked.
Your plan does not include access to this resource. The REST API requires Pro or Agency plan. Upgrade in Settings → Billing.
The requested resource does not exist or has been deleted. Check the ID in your request path.
Input passed validation format but failed business-logic rules. The response body will include a details array with field-level errors.
Rate limit exceeded. Check the X-RateLimit-Reset header for when the window resets and retry after that time.
An unexpected error on ShipPulse's side. Safe to retry with exponential backoff. Check https://status.shippulse.dev if errors persist.
Retry strategy
Only 429 and 5xx errors are safe to retry. Use exponential backoff with jitter to avoid thundering herd problems.
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3,
): Promise<Response> {
let attempt = 0;
while (attempt <= maxRetries) {
const res = await fetch(url, options);
// Success
if (res.ok) return res;
// Don't retry client errors (4xx except 429)
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
const error = await res.json();
throw new Error(error.error?.message ?? "API error");
}
// Rate limit: honour Retry-After header
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
await sleep(retryAfter * 1000);
attempt++;
continue;
}
// 5xx: exponential backoff with jitter
const backoff = Math.min(1000 * 2 ** attempt + Math.random() * 500, 30_000);
await sleep(backoff);
attempt++;
}
throw new Error("Max retries exceeded");
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));Typed error handling
Create a typed wrapper so every API call returns structured errors rather than thrown exceptions:
interface ShipPulseError {
code: string;
message: string;
details?: Array<{ field: string; message: string }>;
}
interface ApiResult<T> {
data?: T;
error?: ShipPulseError;
}
async function apiCall<T>(url: string, options?: RequestInit): Promise<ApiResult<T>> {
try {
const res = await fetch(url, options);
const json = await res.json();
if (!res.ok) {
return { error: json.error as ShipPulseError };
}
return { data: json.data as T };
} catch (err) {
return {
error: { code: "network_error", message: "Failed to reach ShipPulse API" },
};
}
}
// Usage
const { data, error } = await apiCall<Testimonial[]>(
"https://shippulse.dev/api/v1/testimonials",
{ headers: { Authorization: "Bearer sp_..." } },
);
if (error) {
if (error.code === "validation_failed") {
// Show field errors
error.details?.forEach((d) => console.error(d.field, d.message));
} else {
console.error(error.message);
}
}Prefer the JavaScript SDK for a fully typed client with built-in error handling and retry logic.