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- OpenAPI spec
- https://ids.express/api/v1/openapi.json
- Auth
- API key:
Authorization: Bearer ids_…orX-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>| Key | Environment | Effect |
|---|---|---|
| ids_test_… | sandbox | FedEx test system: no courier is dispatched and labels cannot be shipped. Your real balance is still debited. |
| ids_live_… | production | Real 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.
| Scope | Grants |
|---|---|
| quotes:write | POST /quotes, GET /metadata |
| shipments:write | POST /shipments |
| shipments:read | GET /shipments, /shipments/{id}, /labels, /documents, document download |
| shipments:cancel | POST /shipments/{id}/cancel |
| tracking:read | GET /track/{trackingNumber}, POST /shipments/{id}/refresh-tracking |
| balance:read | GET /balance |
| pickups:cancel | Reserved: 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, returns400 VALIDATION_FAILED. - For POST endpoints without a meaningful body (cancel, refresh-tracking) send
{}withContent-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,hazmatorretailNetPrice) returns400 VALIDATION_FAILED, anderror.detailsnames 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-Idheader. Quote it to support.
The flow: quote → book
- 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 returns410 QUOTE_EXPIRED. Optionalinclude:addressValidation,pickupOptions. - POST /shipments books from the
quoteIdplusselectedServiceType(one of the quotedservices[].serviceType), or from a full inline body withservice.type. It requires anIdempotency-Keyheader. On201your 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
customswithdocumentsOnly: trueor a non-emptycommodities[]with a realcustomsValue. Only same-country routes skip customs.customscan be sent together with aquoteIdon any route. - Pickup is optional and folded into the booking.
pickup.datemust be between today and today + 10 days (UTC);readyTimeandcloseTimeareHH:mmand default to 09:00 and 18:00. It is best-effort: a failed pickup never fails a booked, paid shipment (pickup.statusisfailed); setpickup.required: trueto also get aPICKUP_FAILEDwarning. - 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_LIMITSbefore 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
serviceTypesofGET /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.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /ping | public | Health probe. |
| GET | /openapi.json | public | OpenAPI 3.0.3 machine-readable spec. |
| GET | /metadata | quotes:write | Enum reference (countries, currencies, service types, label formats and sizes, units). |
| POST | /quotes | quotes:write | Services offered for a route with indicative prices (+ optional address validation and pickup windows) and a signed quoteId. |
| POST | /shipments | shipments:write | Book from a quoteId or a full inline body. Charges your balance and returns the labels inline.Idempotency-Key |
| GET | /shipments | shipments:read | Your 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:read | One shipment by its UUID or master tracking number. |
| GET | /shipments/{id}/labels | shipments:read | Base64 labels per package, in the format chosen at booking. |
| GET | /shipments/{id}/documents | shipments:read | Customs and commercial invoice document metadata. |
| GET | /shipments/{id}/documents/{docId}/download | shipments:read | Download one document as an attachment. |
| POST | /shipments/{id}/refresh-tracking | tracking:read | Live carrier tracking sync; returns the cached status if tracked in the last ~20 minutes. |
| GET | /track/{trackingNumber} | tracking:read | Cached tracking status by master or package tracking number (no live carrier call). |
| POST | /shipments/{id}/cancel | shipments:cancel | Cancel the label and its pickup, refund per policy.Idempotency-Key |
| GET | /balance | balance:read | Prepaid 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.
| Situation | Result |
|---|---|
| Same key, same request, first one finished | The stored response is replayed: no second label, no second charge. |
| Same key, first request still running | 409 IDEMPOTENCY_IN_PROGRESS. Retry later with the same key. |
| Same key, different body, endpoint or environment | 409 IDEMPOTENCY_CONFLICT. |
| Booking answered 402 PAYMENT_REQUIRED | No 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 confirmed | 500 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.jsonand failed authentications. - Both answer
429 RATE_LIMITED(retryable) with aRetry-Afterheader in seconds; the error body also carriesretryAfterSecandlimit. There are noX-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.paymentRequiredholdsshortfall,availableBalance,availableBonus,pendingDebt,totalRequiredandcurrency;shortfallalready includespendingDebt. 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 /balancereturnsbalance,bonusBalance,pendingDebt,currency,totalAvailable,canCreateShipmentandcredit.totalAvailableincludes bonus, which API bookings cannot use, and subtracts any deferred-payment debt.canCreateShipmentis 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_EXCEEDEDwitherror.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 answers402 CREDIT_OVERDUE_BLOCKEDwitherror.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 /balancereturnscredit:nullwithout deferred payment, otherwiseenabled,limit,debt,available,total(a minus rounded away from zero),overdue(itsblockStartsOnisnullwithout blocking),blockEnabled,blockedandcurrency.
Cancel & refunds
POST /shipments/{id}/cancelcancels the carrier label and, best-effort, its pickup, then applies the refund policy. It returnsid,cancelled,status,pickup.cancelledandrefund(refunded,amount,currency,reason,status).- The amount paid is refunded in full when you cancel before the automatic refund is blocked.
refund.statusisrefunded,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 newIdempotency-Keyto complete it) ornot_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.amountis the whole refunded amount (debt closed, money and any bonus part). When the refund isblocked_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 returns200withalreadyCancelled: trueand 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_ERRORand the shipment is unchanged; readerror.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"
}
}| Code | HTTP | Retryable | Meaning |
|---|---|---|---|
| VALIDATION_FAILED | 400 | no | Invalid 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_FAILED | 401 | no | Missing, malformed, invalid, revoked or expired key, or a key environment mismatch. |
| PAYMENT_REQUIRED | 402 | yes | Balance 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_EXCEEDED | 402 | yes | Only 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_BLOCKED | 402 | yes | Deferred 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_FAILED | 402 | no | Charging failed after the carrier booked; the label is cancelled or held for IDS review. Quote the requestId to support. |
| API_ACCESS_DISABLED | 403 | no | API access is switched off for this account. |
| FORBIDDEN_SCOPE | 403 | no | The key lacks the scope this endpoint requires. |
| API_OWNER_NOT_BILLABLE | 403 | no | The account cannot be billed through the API (no pricing tier, or an admin account). Contact IDS. |
| NOT_FOUND | 404 | no | Unknown path, or the resource is not visible to this key (another account or the other environment). |
| IDEMPOTENCY_CONFLICT | 409 | no | The Idempotency-Key was already used for a different request (body, endpoint or environment). |
| IDEMPOTENCY_IN_PROGRESS | 409 | yes | The first request with this key is still running. Retry later with the same key. |
| QUOTE_EXPIRED | 410 | no | The quoteId is past its TTL. Request a new quote. |
| PRICING_UNAVAILABLE | 422 | no | Quote: no sellable service for the route (details.reason). Booking: no price could be derived, so the label was cancelled. |
| PACKAGE_EXCEEDS_PARCEL_LIMITS | 422 | no | Quote 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_ERROR | 422 | varies | The carrier rejected or failed the request. Read error.retryable: booking and cancel failures are usually retryable, quote rejections are not. |
| RATE_LIMITED | 429 | yes | Per-key or per-IP limit exceeded. Wait Retry-After seconds. |
| INTERNAL_ERROR | 500 | no | Unexpected error. After a booking, check GET /shipments before retrying with a new Idempotency-Key. Quote the requestId to support. |
| API_DISABLED | 503 | no | The public API is switched off by IDS. |
| CURRENCY_RATE_MISSING | 503 | no | No exchange rate for your balance currency. Contact IDS. |
| CARRIER_NOT_CONFIGURED | 503 | no | Carrier 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
retailNetPriceare rejected as unknown. - Not supported: hazmat and dangerous goods, return labels, hold-at-location, broker select, carrier notifications, and per-package
quantity(send onepackages[]element per box). - Future work: webhooks, an interactive playground, standalone pickup re-scheduling and document upload. Until webhooks exist, poll
GET /shipments?updatedSince=…orGET /track/{trackingNumber}.