Error handling & retries
Every v2 error arrives in one stable envelope with a machine-readable code, so your integration can branch on codes instead of parsing prose. This page is the operational playbook: what each code means, what to do about it, and how to retry shipment creation safely without double-billing. The full envelope specification lives in the Errors guide.
Step 1 — Parse the envelope
Successful responses wrap their payload in { "data": ... }. Every error (any endpoint under api/v2/*) uses:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid",
"status": 422,
"details": [
{ "field": "ship_data.contact_phone", "issues": ["The contact phone field is required."] }
]
}
}code— stable machine string. Branch on this.status— mirrors the HTTP status code.details— present only when there is field-level information; omitted entirely when empty. Forvalidation_failedit is a list of{ "field": "...", "issues": ["..."] }objects, one per invalid field.
Unhandled server failures render as code: "server_error" with status: 500 and a generic message ("An unexpected error occurred." in production — details are hidden unless debug mode is on). Other non-500 HTTP exceptions that don't map to a specific code fall back to code: "error".
Step 2 — The per-code playbook
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 401 | unauthenticated | Missing/invalid X-Client-Id / X-Client-Secret. | Fix credentials. Do not retry unchanged. |
| 403 | forbidden | Authenticated, but the target license isn't yours, is inactive, is expired — or your account has no active license. | Fix the license_key (see Licenses guide) or the license itself. Do not retry unchanged. |
| 403 | package_limit_reached | Your subscription's shipment quota is exhausted. | Upgrade/renew the package. Retrying won't help until the quota changes. |
| 404 | not_found | The resource doesn't exist or belongs to someone else (foreign UUIDs 404, they don't 403). | Check the identifier. Do not retry unchanged. |
| 409 | duplicate_request | An identical create is in flight right now — a concurrent request holds the creation lock. Transient. | Wait a moment and retry the same request; you'll get the completed shipment. |
| 409 | idempotency_key_conflict | You reused an Idempotency-Key with a different request body. Permanent client error — the key is bound to the body of its first use. | Fix your key generation: new logical shipment ⇒ new key. Never retry unchanged. |
| 422 | validation_failed | The request body failed validation; per-field problems are in details. | Fix the fields listed in details. Never retry unchanged. |
| 424 | carrier_error | ShipOS accepted your request, but the carrier rejected or failed it (their message is in message). | Carrier-side. Often transient — retryable with the same Idempotency-Key after a delay. If it persists, the carrier is rejecting the data itself (bad address, unsupported service); fix and use a new key. |
| 5xx | server_error | Unexpected failure on our side. | Retry with backoff, reusing the same Idempotency-Key. |
duplicate_request vs idempotency_key_conflict
Both are 409, but they are opposites operationally. duplicate_request means "the same create is happening right now — wait and retry, you'll get the original result." idempotency_key_conflict means "you sent a different body under an old key — this will never succeed until you fix your client." Retry the first; never retry the second.
Step 3 — Safe retries on POST /shipments
Creating a shipment is a billable, irreversible carrier call. The create path is built to make retries safe (see the Idempotency guide):
- Always send an
Idempotency-Keyheader — a fresh UUID per logical shipment. - On timeout or 5xx, retry the same key + same body. The carrier result is recorded server-side before the shipment row is persisted, so even if the process crashed mid-request after the carrier call succeeded, your retry returns the recorded carrier result — the carrier is not called or billed again.
- On 409
duplicate_request, wait briefly and retry the same key + body — the first request's result will be returned. - On 424
carrier_error, retry the same key + body after a delay (carrier hiccups are common). Persistent 424s mean the data itself is being rejected — fix it and use a new key. - Never retry a
422,403, or409 idempotency_key_conflictunchanged — they will fail identically forever.
const crypto = require('node:crypto');
const RETRYABLE_CODES = new Set(['duplicate_request', 'carrier_error', 'server_error']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function createShipmentWithRetries(body, maxAttempts = 4) {
const idempotencyKey = crypto.randomUUID(); // one key per logical shipment
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch('https://app.shipos.co.il/api/v2/shipments', {
method: 'POST',
headers: {
'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
'Accept': 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
});
} catch (err) {
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if (attempt >= maxAttempts) throw err;
await sleep(2 ** attempt * 1000);
continue;
}
if (res.ok) {
const { data } = await res.json(); // 201 (or the original shipment on a retried key)
return data;
}
const { error } = await res.json();
const retryable = res.status >= 500 || RETRYABLE_CODES.has(error.code);
if (!retryable || attempt >= maxAttempts) {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
throw new Error(`Shipment create failed [${error.code}]: ${error.message}`);
}
await sleep(2 ** attempt * 1000); // exponential backoff, SAME key + body
}
}<?php
// composer require guzzlehttp/guzzle ramsey/uuid
use GuzzleHttp\Exception\ConnectException;
const RETRYABLE_CODES = ['duplicate_request', 'carrier_error', 'server_error'];
function createShipmentWithRetries(array $body, int $maxAttempts = 4): array
{
$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.shipos.co.il/api/v2/',
'timeout' => 60,
'http_errors' => false,
]);
$idempotencyKey = \Ramsey\Uuid\Uuid::uuid4()->toString(); // one key per logical shipment
for ($attempt = 1; ; $attempt++) {
try {
$response = $client->post('shipments', [
'headers' => [
'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
'Accept' => 'application/json',
'Idempotency-Key' => $idempotencyKey, // SAME key on every retry
],
'json' => $body,
]);
} catch (ConnectException $e) {
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if ($attempt >= $maxAttempts) {
throw $e;
}
sleep(2 ** $attempt);
continue;
}
$payload = json_decode($response->getBody()->getContents(), true);
if ($response->getStatusCode() < 300) {
return $payload['data']; // 201 (or the original shipment on a retried key)
}
$code = $payload['error']['code'] ?? 'error';
$retryable = $response->getStatusCode() >= 500 || in_array($code, RETRYABLE_CODES, true);
if (! $retryable || $attempt >= $maxAttempts) {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
throw new \RuntimeException("Shipment create failed [{$code}]: ".($payload['error']['message'] ?? ''));
}
sleep(2 ** $attempt); // exponential backoff, SAME key + body
}
}<?php
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
function createShipmentWithRetries(array $body, int $maxAttempts = 4): array
{
$idempotencyKey = (string) Str::uuid(); // one key per logical shipment
for ($attempt = 1; ; $attempt++) {
try {
$response = Http::withHeaders([
'X-Client-Id' => config('services.shipos.client_id'),
'X-Client-Secret' => config('services.shipos.client_secret'),
'Accept' => 'application/json',
'Idempotency-Key' => $idempotencyKey,
])->timeout(60)->post('https://app.shipos.co.il/api/v2/shipments', $body);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if ($attempt >= $maxAttempts) {
throw $e;
}
sleep(2 ** $attempt);
continue;
}
if ($response->successful()) {
return $response->json('data'); // 201 (or the original shipment on a retried key)
}
$code = $response->json('error.code');
$retryable = $response->serverError() // 5xx server_error
|| $code === 'duplicate_request' // concurrent create in flight
|| $code === 'carrier_error'; // carrier hiccup — same key, after a delay
if (! $retryable || $attempt >= $maxAttempts) {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
throw new \RuntimeException("Shipment create failed [{$code}]: ".$response->json('error.message'));
}
sleep(2 ** $attempt); // exponential backoff, SAME key + body
}
}# pip install httpx
import os
import time
import uuid
import httpx
RETRYABLE_CODES = {"duplicate_request", "carrier_error", "server_error"}
def create_shipment_with_retries(body: dict, max_attempts: int = 4) -> dict:
idempotency_key = str(uuid.uuid4()) # one key per logical shipment
headers = {
"X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
"X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
"Accept": "application/json",
"Idempotency-Key": idempotency_key, # SAME key on every retry
}
for attempt in range(1, max_attempts + 1):
try:
response = httpx.post(
"https://app.shipos.co.il/api/v2/shipments",
headers=headers,
json=body,
timeout=60,
)
except httpx.TransportError:
# Timeout / transport failure: outcome unknown — retry SAME key + body.
if attempt == max_attempts:
raise
time.sleep(2**attempt)
continue
if response.is_success:
return response.json()["data"] # 201 (or the original shipment on a retried key)
error = response.json().get("error", {})
retryable = response.status_code >= 500 or error.get("code") in RETRYABLE_CODES
if not retryable or attempt == max_attempts:
# 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
raise RuntimeError(f"Shipment create failed [{error.get('code')}]: {error.get('message')}")
time.sleep(2**attempt) # exponential backoff, SAME key + bodypackage main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/google/uuid"
)
var retryableCodes = map[string]bool{
"duplicate_request": true, "carrier_error": true, "server_error": true,
}
func createShipmentWithRetries(body map[string]any, maxAttempts int) (map[string]any, error) {
raw, _ := json.Marshal(body)
idempotencyKey := uuid.NewString() // one key per logical shipment
client := &http.Client{Timeout: 60 * time.Second}
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://app.shipos.co.il/api/v2/shipments", bytes.NewReader(raw))
req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey) // SAME key on every retry
res, err := client.Do(req)
if err != nil {
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if attempt >= maxAttempts {
return nil, err
}
time.Sleep(time.Duration(1<<attempt) * time.Second)
continue
}
var payload struct {
Data map[string]any `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(res.Body).Decode(&payload)
res.Body.Close()
if res.StatusCode < 300 {
return payload.Data, nil // 201 (or the original shipment on a retried key)
}
retryable := res.StatusCode >= 500 || retryableCodes[payload.Error.Code]
if !retryable || attempt >= maxAttempts {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
return nil, fmt.Errorf("shipment create failed [%s]: %s", payload.Error.Code, payload.Error.Message)
}
time.Sleep(time.Duration(1<<attempt) * time.Second) // exponential backoff, SAME key + body
}
}// Java 17+ — java.net.http + Jackson for the error code
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
public class SafeShipmentCreate {
static final Set<String> RETRYABLE_CODES =
Set.of("duplicate_request", "carrier_error", "server_error");
static String create(String jsonBody, int maxAttempts) throws Exception {
String idempotencyKey = UUID.randomUUID().toString(); // one key per logical shipment
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://app.shipos.co.il/api/v2/shipments"))
.header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
.header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey) // SAME key on every retry
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpClient client = HttpClient.newHttpClient();
for (int attempt = 1; ; attempt++) {
HttpResponse<String> response;
try {
response = client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if (attempt >= maxAttempts) {
throw e;
}
Thread.sleep(1000L << attempt);
continue;
}
if (response.statusCode() < 300) {
return response.body(); // {"data":{...}} — the original shipment on a retried key
}
String code = new ObjectMapper().readTree(response.body())
.path("error").path("code").asText();
boolean retryable = response.statusCode() >= 500 || RETRYABLE_CODES.contains(code);
if (!retryable || attempt >= maxAttempts) {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
throw new RuntimeException("Shipment create failed [" + code + "]: " + response.body());
}
Thread.sleep(1000L << attempt); // exponential backoff, SAME key + body
}
}
}// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;
var retryableCodes = new HashSet<string>
{
"duplicate_request", "carrier_error", "server_error",
};
async Task<JsonElement> CreateShipmentWithRetries(object body, int maxAttempts = 4)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.Add("X-Client-Id",
Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));
// One key per logical shipment — the SAME key is sent on every retry.
http.DefaultRequestHeaders.Add("Idempotency-Key", Guid.NewGuid().ToString());
for (var attempt = 1; ; attempt++)
{
HttpResponseMessage response;
try
{
response = await http.PostAsJsonAsync(
"https://app.shipos.co.il/api/v2/shipments", body);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
// Timeout / transport failure: outcome unknown — retry SAME key + body.
if (attempt >= maxAttempts)
{
throw;
}
await Task.Delay(TimeSpan.FromSeconds(1 << attempt));
continue;
}
var payload = (await response.Content.ReadFromJsonAsync<JsonDocument>())!.RootElement;
if (response.IsSuccessStatusCode)
{
return payload.GetProperty("data"); // 201, or the original shipment on a retried key
}
var error = payload.GetProperty("error");
var code = error.GetProperty("code").GetString()!;
var retryable = (int) response.StatusCode >= 500 || retryableCodes.Contains(code);
if (!retryable || attempt >= maxAttempts)
{
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
throw new InvalidOperationException(
$"Shipment create failed [{code}]: {error.GetProperty("message").GetString()}");
}
await Task.Delay(TimeSpan.FromSeconds(1 << attempt)); // backoff, SAME key + body
}
}require "json"
require "net/http"
require "securerandom"
RETRYABLE_CODES = %w[duplicate_request carrier_error server_error].freeze
def create_shipment_with_retries(body, max_attempts: 4)
uri = URI("https://app.shipos.co.il/api/v2/shipments")
request = Net::HTTP::Post.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = SecureRandom.uuid # one key per logical shipment, reused on retries
request.body = JSON.generate(body)
attempt = 0
loop do
attempt += 1
begin
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60) do |http|
http.request(request)
end
rescue IOError, SystemCallError, Timeout::Error => e
# Timeout / transport failure: outcome unknown — retry SAME key + body.
raise e if attempt >= max_attempts
sleep(2**attempt)
next
end
payload = JSON.parse(response.body)
# 201 (or the original shipment on a retried key)
return payload.fetch("data") if response.is_a?(Net::HTTPSuccess)
code = payload.dig("error", "code")
retryable = response.code.to_i >= 500 || RETRYABLE_CODES.include?(code)
# 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
if !retryable || attempt >= max_attempts
raise "Shipment create failed [#{code}]: #{payload.dig("error", "message")}"
end
sleep(2**attempt) # exponential backoff, SAME key + body
end
end// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
// uuid = { version = "1", features = ["v4"] }
use serde_json::Value;
use std::time::Duration;
const RETRYABLE_CODES: [&str; 3] = ["duplicate_request", "carrier_error", "server_error"];
async fn create_shipment_with_retries(
body: &Value,
max_attempts: u32,
) -> Result<Value, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let idempotency_key = uuid::Uuid::new_v4().to_string(); // one key per logical shipment
for attempt in 1..=max_attempts {
let sent = client
.post("https://app.shipos.co.il/api/v2/shipments")
.header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
.header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
.header("Accept", "application/json")
.header("Idempotency-Key", &idempotency_key) // SAME key on every retry
.timeout(Duration::from_secs(60))
.json(body)
.send()
.await;
let response = match sent {
Ok(response) => response,
// Timeout / transport failure: outcome unknown — retry SAME key + body.
Err(err) => {
if attempt == max_attempts {
return Err(err.into());
}
tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
continue;
}
};
let status = response.status();
let payload: Value = response.json().await?;
if status.is_success() {
return Ok(payload["data"].clone()); // 201, or the original shipment on a retried key
}
let code = payload["error"]["code"].as_str().unwrap_or("error");
let retryable = status.is_server_error() || RETRYABLE_CODES.contains(&code);
if !retryable || attempt == max_attempts {
// 422 validation_failed, 403, idempotency_key_conflict, … — fix, don't retry.
return Err(format!(
"Shipment create failed [{code}]: {}",
payload["error"]["message"]
)
.into());
}
tokio::time::sleep(Duration::from_secs(1 << attempt)).await; // backoff, SAME key + body
}
unreachable!()
}Same key means same body
The Idempotency-Key is bound to the exact request body of its first use. Retry with the identical body only. If you need to change anything — address, packages, service type — that is a new logical shipment: generate a new key. Reusing the old key with a changed body returns 409 idempotency_key_conflict every time.
See also
- Errors guide — the full envelope specification.
- Idempotency guide — key scoping, the no-key body fingerprint, and crash-recovery semantics.
- Shipments — the
POST /shipmentsreference.