GoatLabelsGoatLabels

Blog

Idempotent Label Purchases From an ERP Integration

Why retried label requests buy the same shipment twice, and how to build and store an idempotency key in your ERP so every fulfillment is paid for once.

A label purchase is a payment, so an integration that retries a failed request without an idempotency key can pay for the same shipment twice. The fix is to send a unique key with each purchase, derived from the ERP record being shipped, and to reuse that exact key on every retry. The API then has a way to recognize the second attempt as a repeat instead of a new order. The rest of this post covers where duplicates come from, how to build the key, and what your ERP needs to store.

Why do retries buy the same label twice?

Retries double-buy because a timeout tells the caller nothing about whether the server finished the work. Your script sends a purchase request, the network stalls, and the HTTP client gives up after its timeout. From the ERP side, three different things could have happened:

  • The request never arrived, and no label exists.
  • The request arrived and failed, and no label exists.
  • The request arrived, the label was bought and paid for, and only the response was lost.

The script cannot tell these apart. If it retries blindly, the third case produces a second label and a second charge. If it never retries, the first two cases leave an order unshipped until a person notices.

Timeouts are not the only source. The same duplicate appears whenever one fulfillment can reach the purchase step more than once:

  • A scheduled job overlaps with its previous run because the earlier run was slow.
  • A worker crashes after buying the label but before saving the tracking number, then restarts and picks up the same record.
  • A queue delivers the same message twice, which at-least-once queues are allowed to do.
  • A user clicks "Ship" twice.

None of these are exotic in an integration that runs every day.

What does an idempotency key actually do?

An idempotency key gives the server a name for the operation, so it can treat a repeated request as the same purchase and not a new one. The client generates a unique string, sends it in an Idempotency-Key header, and sends the identical string again on any retry of that operation. A request with a key the server has not seen is a new purchase. A request with a key it has already processed is a repeat.

Two rules follow from that:

  1. One key means one intended purchase. Never reuse a key for a different shipment.
  2. A retry must carry the same key and the same request body. Changing the weight or the address and resending under the old key is a different purchase wearing an old name, and you should not rely on any API to do what you meant.

How long a key is remembered, and exactly what comes back on a repeat, varies by API. Read the provider's documentation and do not assume.

How should an ERP build the key?

Build the key from the ERP record that represents one physical parcel, not from the time of the request or a random value created per attempt. A random UUID generated inside the retry loop is an easy mistake to make: every attempt gets a fresh key, and the protection is gone.

A key needs to be stable across retries and unique across parcels. The table shows how common choices hold up.

Key source Stable on retry? Unique per parcel? Verdict
Random UUID generated per HTTP attempt No Yes Does not prevent duplicates
Timestamp of the request No Usually Does not prevent duplicates
Sales order number alone Yes No, if the order ships in several cartons or partial shipments Blocks legitimate labels
Fulfillment or shipment record ID plus carton number Yes Yes Good default
Random UUID generated once and saved on the fulfillment line before the first call Yes Yes Good, and supports intentional re-buys

The last two rows are the workable ones. A deterministic key such as FUL-88213-C2 (fulfillment 88213, carton 2) is easy to debug because anyone can read it. A stored UUID is better when you need to buy a replacement label on purpose, because you can issue a new key deliberately without changing the record ID.

Whichever you choose, add a short prefix for the environment or the integration (prod-, erp1-) so a test run and a production run can never collide.

What does a safe purchase request look like?

A safe request carries the key in a header and comes from code that saves the key before it sends anything. The request below is an illustration only. The header name is the standard one, but the body fields are placeholders, so take the exact field names and response shape from the API reference at /docs.

POST <label purchase endpoint, see /docs>
Authorization: <your API key, sent as described in /docs>
Idempotency-Key: prod-FUL-88213-C2
Content-Type: application/json

{ ...shipment details as described in /docs... }

The order of operations around that request matters more than the request itself:

  1. Write the key to the fulfillment line and mark the line "purchase pending". Commit that change.
  2. Send the purchase request with the key.
  3. On success, save the tracking number, label, and cost to the line and mark it "purchased".
  4. On a timeout or a 5xx response, leave the line "pending" and retry later with the same key and the same body.
  5. On a 4xx validation error, stop retrying. Mark the line "failed" with the error text and route it to a person. A bad address will not fix itself on the fifth attempt.
  6. Retry with increasing delays and a cap on attempts, then alert.

Step 1 is the one teams skip. If the key exists only in memory and the worker crashes, the restarted worker has no idea a purchase was in flight.

A worked example: one timeout, two outcomes

This example uses hypothetical numbers to show the difference. Suppose a distributor's nightly job ships 400 cartons, and one request for a carton with a quoted label cost of $14.20 times out after the label was bought.

Without a key: the job retries, a second label is purchased, and the account is charged $28.40 for one carton. The ERP stores the second tracking number. The first label is never used, and someone in finance has to find it and request a void.

With a key: the job retries with prod-FUL-88213-C2. The key lets the API treat the retry as a repeat of the same purchase and not a new one, so the carton costs $14.20 (see /docs for exactly what comes back on a repeat). The ERP stores the one tracking number that matches the label on the box.

What should the ERP store, and how do you test it?

The ERP should store enough on each fulfillment line to answer "was this bought, and with which key?" without calling anyone. A minimal set of fields:

  • Idempotency key
  • Purchase status (pending, purchased, failed)
  • Attempt count and time of last attempt
  • Tracking number, carrier, and service
  • Label cost as quoted
  • Last error text

Then test the failure paths on purpose, using test keys and not live ones:

  1. Send the same request twice with the same key and confirm only one shipment exists.
  2. Set the client timeout very low so the response is lost, let the retry run, and confirm one shipment.
  3. Kill the worker between the purchase and the save, restart it, and confirm it resumes with the stored key.
  4. Run two copies of the job at once against the same records.
  5. Ship a three-carton order and confirm three labels, each with its own key.

If your order data is not ready for an API build yet, a file-based flow has the same risk in a different place: importing the same export twice. The guard there is a "label requested" flag on the ERP side so a row is exported once. See bulk shipping labels from CSV for that route, and the ERP shipping integration overview for how the two approaches compare.

Where GoatLabels fits

GoatLabels has a REST API that accepts an Idempotency-Key header, with live and test keys, signed replayable webhooks, an OpenAPI 3.1 description, and rate-limit headers. API calls are unlimited on both the Free plan ($0 per month, 50 shipments per month) and the Pro plan (a flat $40 per month with unlimited shipments). Each label debits a prepaid wallet as a dated ledger entry that carries your reference, so putting the fulfillment ID in the reference gives finance a second way to spot anything unexpected. The shipping API for developers page lists what is included.

The limits are worth knowing before you plan the build. There are no native ERP connectors and no SDKs, so the integration is your own code against the REST API, or a CSV export, whether you run NetSuite, Oracle, or something else (the NetSuite shipping software page covers that case). GoatLabels does not write tracking numbers back into an ERP automatically. Your code does that, from the API response or a webhook. There are no SLAs, and support is by email and the in-app assistant, not phone. Labels are bought on GoatLabels rates, not on your own carrier accounts. And the idempotency key protects the purchase call only: the stored key, the status field, and the retry rules described above still have to live in your ERP.