Skip to content

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:

json
{
  "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. For validation_failed it 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

HTTPcodeMeaningWhat to do
401unauthenticatedMissing/invalid X-Client-Id / X-Client-Secret.Fix credentials. Do not retry unchanged.
403forbiddenAuthenticated, 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.
403package_limit_reachedYour subscription's shipment quota is exhausted.Upgrade/renew the package. Retrying won't help until the quota changes.
404not_foundThe resource doesn't exist or belongs to someone else (foreign UUIDs 404, they don't 403).Check the identifier. Do not retry unchanged.
409duplicate_requestAn 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.
409idempotency_key_conflictYou 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.
422validation_failedThe request body failed validation; per-field problems are in details.Fix the fields listed in details. Never retry unchanged.
424carrier_errorShipOS 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.
5xxserver_errorUnexpected 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):

  1. Always send an Idempotency-Key header — a fresh UUID per logical shipment.
  2. 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.
  3. On 409 duplicate_request, wait briefly and retry the same key + body — the first request's result will be returned.
  4. 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.
  5. Never retry a 422, 403, or 409 idempotency_key_conflict unchanged — they will fail identically forever.
js
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
<?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
<?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
    }
}
python
# 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 + body
go
package 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
// 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
        }
    }
}
csharp
// .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
    }
}
ruby
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
rust
// [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