IDS Express Public API

A server-to-server REST API for booking FedEx shipments through IDS Express. Built for marketplaces and e-commerce platforms that want to offer IDS shipping to their sellers.

Base URL
https://ids.express/api/v1
Auth
API key: Authorization: Bearer ids_… or X-API-Key
Model
Prepaid balance · synchronous booking · sandbox and production keys

Authentication & environments

Send the key on every request as Authorization: Bearer … or X-API-Key. The Bearer prefix is case-sensitive; if both headers are sent, the Bearer token is used. The token format is ids_{live|test}_{prefix}.{secret}.

Authorization: Bearer ids_test_AbCdEf12.<secret>
# or
X-API-Key: ids_test_AbCdEf12.<secret>
KeyEnvironmentEffect
ids_test_…sandboxFedEx test system: no courier is dispatched and labels cannot be shipped. Your real balance is still debited.
ids_live_…productionReal labels, real pickups, real charges.

The environment is bound to the key and cannot be changed per request. A sandbox key cannot read, track or cancel a production shipment (404 NOT_FOUND), and a quoteId from one environment cannot be booked with a key of the other. Each key carries scopes; a call without its scope returns 403 FORBIDDEN_SCOPE.

ScopeGrants
quotes:writePOST /quotes, GET /metadata
shipments:writePOST /shipments
shipments:readGET /shipments, /shipments/{id}, /labels, /documents, document download
shipments:cancelPOST /shipments/{id}/cancel
tracking:readGET /track/{trackingNumber}, POST /shipments/{id}/refresh-tracking
balance:readGET /balance
pickups:cancelReserved: no endpoint uses it yet.

Request format

  • Request bodies are JSON only: send Content-Type: application/json. Any other content type, and a malformed or empty JSON body, returns 400 VALIDATION_FAILED.
  • For POST endpoints without a meaningful body (cancel, refresh-tracking) send {} with Content-Type: application/json, or send no body and no Content-Type header.
  • Unknown fields are rejected. Every object in a quote or booking body is strict (the cancel and refresh-tracking endpoints read no body at all): a field the API does not define (for example quantity, environment, hazmat or retailNetPrice) returns 400 VALIDATION_FAILED, and error.details names the unrecognized keys.
  • Ids are UUIDs, timestamps are ISO 8601, and money amounts are numbers in your balance currency.
  • Every response, errors included, carries an X-Request-Id header. Quote it to support.

The flow: quote → book

  1. POST /quotes returns the services the carrier offers for the route, with indicative prices in your balance currency, and a signed quoteId. It is valid for 30 minutes by default; after that, booking it returns 410 QUOTE_EXPIRED. Optional include: addressValidation, pickupOptions.
  2. POST /shipments books from the quoteId plus selectedServiceType (one of the quoted services[].serviceType), or from a full inline body with service.type. It requires an Idempotency-Key header. On 201 your balance is charged and the labels are returned inline.
  • Customs are required for every cross-border route, intra-EU included (for example RO → DE): send customs with documentsOnly: true or a non-empty commodities[] with a real customsValue. Only same-country routes skip customs. customs can be sent together with a quoteId on any route.
  • Pickup is optional and folded into the booking. pickup.date must be between today and today + 10 days (UTC); readyTime and closeTime are HH:mm and default to 09:00 and 18:00. It is best-effort: a failed pickup never fails a booked, paid shipment (pickup.status is failed); set pickup.required: true to also get a PICKUP_FAILED warning.
  • One packages[] element per box, up to 10.
  • Parcel limits of one box: up to 68 kg / 150 lb, longest side up to 274 cm / 108 in, length + girth (longest side + 2 × width + 2 × height) up to 330 cm / 130 in. Over them it is freight, which IDS does not sell online: 422 PACKAGE_EXCEEDS_PARCEL_LIMITS before the carrier is asked.
  • A booking asks only for services IDS sells: a freight service is refused with every key, and a production key books only the serviceTypes of GET /metadata (400 VALIDATION_FAILED, details.serviceType).

1 · Domestic quote (RO → RO)

curl -s https://ids.express/api/v1/quotes \
  -H "Authorization: Bearer $IDS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "shipper":   { "contact": { "name": "Acme Store", "phone": "+40700000000" },
                   "address": { "line1": "Calea Victoriei 1", "city": "Bucharest",
                                "postalCode": "010061", "countryCode": "RO" } },
    "recipient": { "contact": { "name": "Ana Popescu", "phone": "+40740000000" },
                   "address": { "line1": "Strada Memorandumului 28", "city": "Cluj-Napoca",
                                "postalCode": "400114", "countryCode": "RO" } },
    "packages":  [ { "weight": 2, "weightUnit": "KG" } ]
  }'

2 · Book from the quote (no pickup)

ORDER_KEY=$(uuidgen)   # once per booking; reuse it on every retry

curl -s https://ids.express/api/v1/shipments \
  -H "Authorization: Bearer $IDS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $ORDER_KEY" \
  -d '{
    "quoteId": "<quoteId from /quotes>",
    "selectedServiceType": "<one of the quoted services[].serviceType>"
  }'

3 · Cross-border booking with customs and a pickup (RO → DE)

curl -s https://ids.express/api/v1/shipments \
  -H "Authorization: Bearer $IDS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $ORDER_KEY" \
  -d '{
    "shipper":   { "contact": { "name": "Acme Store", "phone": "+40700000000" },
                   "address": { "line1": "Calea Victoriei 1", "city": "Bucharest",
                                "postalCode": "010061", "countryCode": "RO" } },
    "recipient": { "contact": { "name": "Hans Müller", "phone": "+4915112345678" },
                   "address": { "line1": "Unter den Linden 1", "city": "Berlin",
                                "postalCode": "10117", "countryCode": "DE" } },
    "packages":  [ { "weight": 2, "weightUnit": "KG",
                     "length": 30, "width": 20, "height": 10, "dimUnit": "CM" } ],
    "service":   { "type": "FEDEX_REGIONAL_ECONOMY" },
    "customs":   { "shipmentPurpose": "SOLD", "incoterms": "DAP", "dutiesPayment": "SENDER",
                   "commodities": [ { "description": "Cotton t-shirts", "quantity": 3,
                                      "weight": 0.6, "customsValue": 45,
                                      "customsValueCurrency": "EUR", "harmonizedCode": "610910",
                                      "countryOfManufacture": "RO" } ] },
    "pickup":    { "date": "<YYYY-MM-DD, today..+10 days>",
                   "readyTime": "09:00", "closeTime": "18:00" }
  }'

A 201 contains id, status (confirmed), masterTrackingNumber, environment, service, shipDate, packages[] with the base64 label, pickup, charge, documents, alerts and, when relevant, warnings. The read endpoints return a different shape: no labels (use /labels) and no alerts, plus updatedAt, cancelledAt and carrier tracking fields.

Endpoints

All under https://ids.express/api/v1. Request schemas are in the OpenAPI spec.

MethodPathScopeDescription
GET/pingpublicHealth probe.
GET/openapi.jsonpublicOpenAPI 3.0.3 machine-readable spec.
GET/metadataquotes:writeEnum reference (countries, currencies, service types, label formats and sizes, units).
POST/quotesquotes:writeServices offered for a route with indicative prices (+ optional address validation and pickup windows) and a signed quoteId.
POST/shipmentsshipments:writeBook from a quoteId or a full inline body. Charges your balance and returns the labels inline.Idempotency-Key
GET/shipmentsshipments:readYour shipments, newest first. Query: limit (default 25, max 100), cursor (a cursor that is not a UUID is ignored and you get the first page), updatedSince (ignored if unparsable), status (one of the 8 documented values, otherwise 400).
GET/shipments/{id}shipments:readOne shipment by its UUID or master tracking number.
GET/shipments/{id}/labelsshipments:readBase64 labels per package, in the format chosen at booking.
GET/shipments/{id}/documentsshipments:readCustoms and commercial invoice document metadata.
GET/shipments/{id}/documents/{docId}/downloadshipments:readDownload one document as an attachment.
POST/shipments/{id}/refresh-trackingtracking:readLive carrier tracking sync; returns the cached status if tracked in the last ~20 minutes.
GET/track/{trackingNumber}tracking:readCached tracking status by master or package tracking number (no live carrier call).
POST/shipments/{id}/cancelshipments:cancelCancel the label and its pickup, refund per policy.Idempotency-Key
GET/balancebalance:readPrepaid account balance.

Idempotency

POST /shipments and POST /shipments/{id}/cancel require an Idempotency-Key header of 1 to 255 characters; without it they return 400 VALIDATION_FAILED. Generate one key per logical operation (for example per order) and reuse it on every retry of that operation.

SituationResult
Same key, same request, first one finishedThe stored response is replayed: no second label, no second charge.
Same key, first request still running409 IDEMPOTENCY_IN_PROGRESS. Retry later with the same key.
Same key, different body, endpoint or environment409 IDEMPOTENCY_CONFLICT.
Booking answered 402 PAYMENT_REQUIREDNo label is left and the key is released. Top up and retry with the same key. Only a label that could not be voided closes the key (retryable:false).
Booking outcome could not be confirmed500 INTERNAL_ERROR, replayed for the same key. Check GET /shipments before retrying with a NEW key.

Keys are unique per account across all endpoints and both environments, so never reuse a booking key for its cancel. Stored responses are kept for 7 days. A 201 can carry the warning FINALIZATION_PENDING: the shipment is booked and charged, but finalization failed, so labels may be unavailable. Contact support with the shipment id.

Rate limits

  • Per API key: 120 requests per minute by default, over a sliding 60-second window. IDS can configure a different limit for a key.
  • Per client IP: a safety ceiling of 600 requests per minute across the whole v1 API (fixed 1-minute window). It applies before authentication, so it also counts /ping, /openapi.json and failed authentications.
  • Both answer 429 RATE_LIMITED (retryable) with a Retry-After header in seconds; the error body also carries retryAfterSec and limit. There are no X-RateLimit-* headers.

Balance & payment

  • A booking is paid from your prepaid real balance, in your balance currency. API bookings never use bonus balance.
  • If the real balance does not cover the price, or there is any pending debt, the booking returns 402 PAYMENT_REQUIRED (retryable) and nothing is charged. In production the balance is checked against a fresh quote before the label is booked, so no label is created. A booking goes on to the carrier and is checked again against the booked price when the balance covers the quote, or falls short of it by only a small margin, or the quote cannot price it; if the booked price is not covered, the label is voided. In the sandbox the label is always created and then voided. error.paymentRequired holds shortfall, availableBalance, availableBonus, pendingDebt, totalRequired and currency; shortfall already includes pendingDebt. A 402 given before booking computes them from the quote; the final price is set at booking, so topping up exactly the shortfall may not be enough. The Idempotency-Key is released, so top up and retry with the same key.
  • GET /balance returns balance, bonusBalance, pendingDebt, currency, totalAvailable, canCreateShipment and credit. totalAvailable includes bonus, which API bookings cannot use, and subtracts any deferred-payment debt. canCreateShipment is false while pending debt exceeds the balance or while the account is blocked for overdue deferred-payment debt. A prepaid API booking needs a balance that covers the price and a pending debt of 0.
  • Deferred payment (company accounts, switched on by IDS): a booking past the balance goes into a minus, held as one debt per booking with its own due date (the booking day in Romania plus the deferral days of the account), up to the credit limit. Past the limit the booking answers 402 CREDIT_LIMIT_EXCEEDED with error.creditLimit. A debt is overdue from the day after its due date; for an account IDS switched overdue blocking on for (it is off unless agreed), once it is overdue past the block period, every booking the account pays answers 402 CREDIT_OVERDUE_BLOCKED with error.creditOverdue (amount, oldestDueDate, blockStartsOn, currency) before the carrier is called. The block lifts by itself as soon as the overdue amount is paid. Both answers release the Idempotency-Key. Money that arrives for the account (a card top-up, a bank transfer IDS records, a refund) pays the open debts first, earliest due date first; late payment penalties, if the account has them, only after every other amount owed. GET /balance returns credit: null without deferred payment, otherwise enabled, limit, debt, available, total (a minus rounded away from zero), overdue (its blockStartsOn is null without blocking), blockEnabled, blocked and currency.

Cancel & refunds

  • POST /shipments/{id}/cancel cancels the carrier label and, best-effort, its pickup, then applies the refund policy. It returns id, cancelled, status, pickup.cancelled and refund (refunded, amount, currency, reason, status).
  • The amount paid is refunded in full when you cancel before the automatic refund is blocked. refund.status is refunded, not_eligible (for example the pickup is already completed), blocked_pickup_window (from 00:00 UTC on the pickup date, whatever the ready time; IDS reviews those manually), pending_review (cancelled, but the refund could not be booked — cancel again with a new Idempotency-Key to complete it) or not_applicable (nothing is held on the shipment: nothing was paid, or it was already refunded).
  • A booking that went into the minus (deferred payment): the refund first closes its own unpaid debt (no money moves for that part); the part already paid and the part paid from the balance come back as money, which pays the other open debts of the account first (earliest due date first) and only then reaches the balance. refund.amount is the whole refunded amount (debt closed, money and any bonus part). When the refund is blocked_pickup_window, the debt of that booking is frozen until IDS decides: it still counts against the limit but never becomes overdue.
  • A shipment already in transit can still be cancelled; talk to IDS about the refund. A delivered shipment cannot be cancelled (400 VALIDATION_FAILED). Cancelling an already cancelled shipment returns 200 with alreadyCancelled: true and checks the refund again: an unfinished refund is completed, a finished one is never paid twice.
  • If the carrier cancel fails you get 422 CARRIER_ERROR and the shipment is unchanged; read error.retryable.

Errors

Every error uses one envelope: code, message, retryable, optional details, code-specific fields (paymentRequired; retryAfterSec and limit) and requestId. Always read retryable from the response: true means the same request can succeed later, after a wait, a top-up, or once the first request with the same Idempotency-Key has finished.

{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "Insufficient balance for shipment",
    "retryable": true,
    "paymentRequired": {
      "shortfall": 42.5, "availableBalance": 20, "availableBonus": 0,
      "pendingDebt": 0, "totalRequired": 62.5, "currency": "RON"
    },
    "requestId": "8ba0c9d0-ee75-42de-99b5-f1f4157cdf7d"
  }
}
CodeHTTPRetryableMeaning
VALIDATION_FAILED400noInvalid or unknown fields, non-JSON or malformed body, missing Idempotency-Key, invalid quoteId, missing customs, pickup date outside today to +10 days, cancel of a delivered shipment. See details.
AUTHENTICATION_FAILED401noMissing, malformed, invalid, revoked or expired key, or a key environment mismatch.
PAYMENT_REQUIRED402yesBalance too low or pending debt. Nothing is charged. In production the balance is checked against a quote before the label is booked, so usually no label is created; in the sandbox, or when the shortfall only shows at booking, the label is created and voided. Top up, then retry with the same Idempotency-Key. If a label could not be voided the answer says so and is retryable:false — that key is closed, use a new one.
CREDIT_LIMIT_EXCEEDED402yesOnly for accounts with deferred payment: this booking would take the account past its credit limit. Nothing is charged and the key is released; error.creditLimit says what the booking needs, the credit left, the limit and the current debt. Pay the debt down (or ask IDS to raise the limit), then retry with the same Idempotency-Key.
CREDIT_OVERDUE_BLOCKED402yesDeferred payment: debt is overdue past the block period of an account with overdue blocking switched on, so every booking it pays is refused before the carrier is called, whatever the balance. Nothing is charged and the key is released; error.creditOverdue holds the overdue amount, the oldest due date and the first day of the block. Pay the overdue amount (the block lifts by itself), then retry with the same Idempotency-Key.
PAYMENT_FAILED402noCharging failed after the carrier booked; the label is cancelled or held for IDS review. Quote the requestId to support.
API_ACCESS_DISABLED403noAPI access is switched off for this account.
FORBIDDEN_SCOPE403noThe key lacks the scope this endpoint requires.
API_OWNER_NOT_BILLABLE403noThe account cannot be billed through the API (no pricing tier, or an admin account). Contact IDS.
NOT_FOUND404noUnknown path, or the resource is not visible to this key (another account or the other environment).
IDEMPOTENCY_CONFLICT409noThe Idempotency-Key was already used for a different request (body, endpoint or environment).
IDEMPOTENCY_IN_PROGRESS409yesThe first request with this key is still running. Retry later with the same key.
QUOTE_EXPIRED410noThe quoteId is past its TTL. Request a new quote.
PRICING_UNAVAILABLE422noQuote: no sellable service for the route (details.reason). Booking: no price could be derived, so the label was cancelled.
PACKAGE_EXCEEDS_PARCEL_LIMITS422noQuote or booking: a box is over the parcel limits (68 kg / 150 lb, longest side 274 cm / 108 in, length + girth 330 cm / 130 in). That is freight, which IDS does not sell online; details name the reason, the package and the limit. Nothing was booked.
CARRIER_ERROR422variesThe carrier rejected or failed the request. Read error.retryable: booking and cancel failures are usually retryable, quote rejections are not.
RATE_LIMITED429yesPer-key or per-IP limit exceeded. Wait Retry-After seconds.
INTERNAL_ERROR500noUnexpected error. After a booking, check GET /shipments before retrying with a new Idempotency-Key. Quote the requestId to support.
API_DISABLED503noThe public API is switched off by IDS.
CURRENCY_RATE_MISSING503noNo exchange rate for your balance currency. Contact IDS.
CARRIER_NOT_CONFIGURED503noCarrier credentials for this environment are not configured. Contact IDS.

Successful responses can carry warnings[]: ADDRESS_VALIDATION_UNAVAILABLE and PICKUP_OPTIONS_UNAVAILABLE on quotes, PICKUP_FAILED and FINALIZATION_PENDING on bookings.

Known limitations

  • Franchise retail prices and retail earnings cannot be set through the API; only shipments created in the IDS dashboard carry them. Retail fields such as retailNetPrice are rejected as unknown.
  • Not supported: hazmat and dangerous goods, return labels, hold-at-location, broker select, carrier notifications, and per-package quantity (send one packages[] element per box).
  • Future work: webhooks, an interactive playground, standalone pickup re-scheduling and document upload. Until webhooks exist, poll GET /shipments?updatedSince=… or GET /track/{trackingNumber}.