Shipping API Rate Limits in Batch Integrations
How a nightly batch reads rate-limit headers, paces its calls, retries a 429 with backoff and an idempotency key, and when a CSV upload fits better.
A batch integration stays inside a shipping API's rate limit by pacing itself from the limit headers on every response, instead of sending as fast as it can and reacting to errors. When it does get an HTTP 429, it waits the number of seconds the server asks for, then resends the same request with the same idempotency key, so a retry never buys a second label. If the run is a one-off, or a person needs to review rates first, a CSV upload is often simpler.
How do shipping API rate limits work?
A rate limit caps how many requests one client can send in a fixed window, and the server rejects the excess with HTTP 429 until the window resets. A limit stated in requests counts HTTP calls, not labels or orders, so a call that carries 50 shipments uses one slot, the same as a call that validates one address. Confirm how your provider counts by watching the remaining count across one batch call.
On the v1 API at GoatLabels, the limit is 300 requests per minute per API key, on both the Free and Pro plans, with no monthly cap on calls. Every v1 response carries three headers:
| Header | What it tells you |
|---|---|
X-RateLimit-Limit |
Requests this key may send per window |
X-RateLimit-Remaining |
Requests left in the current window |
X-RateLimit-Reset |
When the window resets, in unix seconds |
A request over the limit gets HTTP 429 with error type rate_limit_error and a Retry-After header that gives the wait in seconds.
How do you read the headers before you hit a 429?
Read X-RateLimit-Remaining and X-RateLimit-Reset on every response and slow down before the remaining count reaches zero. Waiting for the 429 wastes a round trip on every rejected call.
- After each response, record the remaining count and the reset time.
- If the remaining count drops below a small floor, such as 5, pause until the reset time plus one second.
- Convert the reset value from seconds to milliseconds before comparing it with your clock. The extra second covers clock drift.
- Run all workers for one key through a single shared limiter, or they will all spend the last few slots at once.
Give the nightly batch its own API key. The limit is per key, so a job that shares a key with your storefront or support tools competes with them for the same 300 requests. A separate key is also easy to spot in the keys table, which shows each key's last-used date.
How should a nightly batch pace itself?
A nightly batch should send at a steady rate below the limit and use batch calls where they fit, so the run finishes without touching the ceiling. Order the work so failures are cheap: create every draft first, set aside the ones that came back with errors, then purchase. A duplicate draft costs clutter. A duplicate label costs postage.
On the v1 API, a batch-create call takes up to 50 draft shipments and a batch-purchase call buys up to 10 labels.
| Example: 1,200 orders in one run | Create requests | Purchase requests | Total requests | Floor at 300 per minute |
|---|---|---|---|---|
| One call per order for both steps | 1,200 | 1,200 | 2,400 | 8 minutes |
| Batch create, single purchases | 24 | 1,200 | 1,224 | About 4 minutes |
| Batch create, batch purchase | 24 | 120 | 144 | Under 1 minute |
The middle row is often the right trade. The single purchase-label call is where the v1 API accepts an Idempotency-Key, so a retried purchase replays instead of buying again, and four minutes is still a short run. Pace below the ceiling either way: about 4 requests per second (240 a minute) leaves margin for retries, address checks and rate quotes. Two notes for this example: Free covers 50 shipments a month, so 1,200 orders a night is a Pro workload, and every label debits the prepaid wallet, so fund it before the run.
What should a retry do after a 429 or a timeout?
After a 429, wait the Retry-After value, add a little random jitter, and resend the identical request with the identical idempotency key. A timeout or a 5xx gets exponential backoff with jitter and a cap on attempts. Most other 4xx responses will give the same answer every time.
// Example only. Take endpoint paths from /api/v1/openapi.json.
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const backoff = (n) => Math.min(30000, 1000 * 2 ** n) * (0.5 + Math.random() / 2);
async function send(path, body, idemKey) {
const headers = { Authorization: "Bearer " + process.env.GL_BATCH_KEY,
"Content-Type": "application/json" };
if (idemKey) headers["Idempotency-Key"] = idemKey; // create and purchase only
for (let attempt = 1; attempt <= 6; attempt++) {
let res = null;
try {
res = await fetch("https://goatlabels.io/api/v1" + path, { method: "POST",
headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
} catch {}
if (res && res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") || "1") * 1000 + Math.random() * 500);
continue;
}
if (!res || res.status >= 500) {
if (!idemKey) throw new Error("unprotected write: check state before resending");
await sleep(backoff(attempt));
continue;
}
if (Number(res.headers.get("X-RateLimit-Remaining")) < 5) {
const reset = Number(res.headers.get("X-RateLimit-Reset")) * 1000;
await sleep(Math.max(0, reset - Date.now()) + 1000);
}
return res; // the caller saves it or logs it
}
throw new Error("gave up after 6 attempts: " + idemKey);
}
A 429 is always safe to retry, because the server rejected the request before doing anything. Stop on the responses that point at your setup. On the v1 API, a 403 insufficient_scope means a read-only key tried a write, and a 403 test_mode_unsupported means a test key tried a purchase. Log these and alert a person.
How does an idempotency key stop a retry from buying twice?
An idempotency key lets the server recognize a resend of a request it already completed and return the first result instead of doing the work again. A timeout tells your job nothing about whether the label was bought, so without a key a retry can pay twice.
On the v1 API, the Idempotency-Key header takes up to 255 characters on create-shipment and purchase-label calls. A retry with the same key and body returns the original response with Idempotent-Replayed: true. The same key with a different body returns 409 idempotency_error, which means your key building has a bug. Keys are scoped to the API key, so retry on the same API key as the first attempt, and rotate only when no retries are pending, since a rotated key's old secret stops working immediately.
Build the key from the ERP record and the step, for example so-10482-box-1-purchase, and save it on the record before the first send. A replayed response means an earlier attempt succeeded and only its answer was lost, so log it. The walkthrough on idempotent label purchases from an ERP covers what to store. Treat any other write as unprotected: after a timeout, check whether the first attempt took effect before sending it again.
When should a batch switch from the API to a CSV upload?
Switch to a CSV upload when the volume is occasional, when a person should review rates before buying, or when the integration is not built yet. A spreadsheet suits a holiday backlog, a catch-up after an outage, or an ERP that can export a file but cannot make HTTP calls.
| Situation | Better fit |
|---|---|
| Unattended nightly run, tracking stored on the order by your code | API |
| One-off backlog or seasonal spike | CSV upload |
| Someone checks or overrides rates before buying | CSV upload |
| Integration still being built | CSV upload until the code is ready |
The importer for bulk shipping labels from a CSV file takes up to 500 rows per file, so the 1,200-order example becomes three files. Each row is checked separately with its errors named in plain words, and one bad row does not fail the batch. Each row is rate-shopped and the lowest usable rate is picked, which you can override before buying. A column named Order Number, Order ID, Order, Ref or Reference becomes the shipment reference.
The trade-off is the return trip. With the API, your code gets the tracking number in the response, and webhooks that send tracking back to your ERP replace polling. With a CSV upload, getting tracking numbers into your ERP is a step you have to plan. For the wider order-to-ship picture, see the B2B shipping software overview.
Where GoatLabels fits
GoatLabels offers a REST API with Bearer key auth and unlimited calls per month on Free and Pro, limited only by 300 requests per minute per API key, and Idempotency-Key on create-shipment and purchase-label calls. The OpenAPI 3.1 document at /api/v1/openapi.json needs no key, and the shipping API for developers page has the overview.
The limits matter here. There is no official SDK and no native ERP connector, so the pacing and retry logic is code you write and run. On the v1 API, test keys can quote rates, validate addresses and create drafts, but purchases return 403 test_mode_unsupported and there is no separate sandbox. Use test keys in CI for everything before purchase, then rehearse the purchase step on a few live labels and cancel the unused ones from the dashboard; the refund returns to the wallet once the carrier confirms the label was not used.