# Buying labels safely

Buying a label spends money from your wallet. The API splits the work in two calls so you can look before you spend.

1. `POST /shipments/quote` rates the package and saves it as a shipment. It costs nothing.
2. `POST /shipments/{id}/buy` buys one of the returned rates.

## Quote

A quote needs the ids of a saved ship-from address (`GET /ship-from`), a destination, and a parcel. See the [API reference](/docs/api) for every field. The response includes:

| Field | Meaning |
| --- | --- |
| `shipment_id` | The saved shipment. You pass it to the buy call. |
| `rates` | The prices your organization pays. Each has `id`, `carrier`, `service`, `price_cents`, `delivery_days`, `guaranteed` and `badges`. |
| `suggested_rate_id` and `suggested_by_rule` | Set when one of your [shipping rules](/docs/shipping-rules) chose a rate. |
| `applied_rules` | Which rules ran and what they did. |
| `partial` | `true` when some carriers did not answer. |
| `expires_in_seconds` | How long the rates can be bought. |

Rules that remove rates act on the quote itself, so a rate a rule removed is not in the list and cannot be bought.

**Note:** `rate.id` is an opaque string. It is only valid for the shipment it came from and only until `expires_in_seconds` runs out. Do not store it long term and do not try to build one yourself.

If the quote request has `"verify_address": true` (the default) and your account is set to confirm address changes, the response is `200` with `"status": "needs_address_confirmation"`, the address you entered and a `suggestion`. Show the suggestion to a person, then quote again with the address you want. Pass `"verify_address": false` to skip the check.

### Expired rates and partial results

If you buy after the rates expired, the call fails with `409` and the code `conflict`, with `details.expired` set to `true`. Quote again and use the new rate.

When `partial` is `true` the cheapest carrier may be missing. For an unattended system it is usually right to quote again after a short wait. For a person, show the rates you have and say that the list may be incomplete.

## Buy with an idempotency key

The buy call requires an `Idempotency-Key` header of 8 to 200 characters. Without it the call fails with `422 validation`.

```bash
curl https://shipjay.com/api/v1/shipments/$SHIPMENT_ID/buy \
  -H "Authorization: Bearer $SHIPJAY_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001-label-1" \
  -d '{ "rate_id": "r_5f3a91" }'
```
```js
const res = await fetch(`https://shipjay.com/api/v1/shipments/${shipmentId}/buy`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHIPJAY_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order-${orderId}-label-1`,
  },
  body: JSON.stringify({ rate_id: rateId }),
});
```
```python
res = requests.post(
    f"https://shipjay.com/api/v1/shipments/{shipment_id}/buy",
    headers={
        "Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}",
        "Idempotency-Key": f"order-{order_id}-label-1",
    },
    json={"rate_id": rate_id},
    timeout=30,
)
```
How the key behaves:

- The same key with the same request returns the first result and adds the header `Idempotent-Replayed: true`. Nothing is bought a second time.
- The same key with a different request fails with `422 validation`.
- The same key while the first request is still running fails with `409 conflict`. Wait and send it again.
- A server error (`5xx`) forgets the key, so you can retry with it.
- Keys are scoped to the API key that sent them.

Derive the key from something stable in your system, such as an order id plus a counter, not from a random value generated on each retry. A random key per attempt defeats the protection. Buying a shipment that is already purchased also fails with `409`, and the response body has `"duplicate": true` when it repeats a purchase made with the same key.

If a buy times out and you do not know the outcome, repeat the call with the same key. If you lost the key, read the shipment (`GET /shipments/{id}`) and check its `status` before you do anything else.

## Spending limits

Three limits can stop a purchase, all with `402`:

- The key's **per label** limit (`spend_limit`).
- The key's **per day** limit (`spend_limit`).
- Your account's daily limit (`spend_limit`) and your wallet balance (`insufficient_funds`).

Set a per label and per day limit on every key that can buy. Then a bug or a leaked key can lose at most one day of the limit.

## After the purchase

- `GET /shipments/{id}/label` downloads the label file.
- `GET /shipments/{id}/tracking` returns the status and scan history.
- Subscribe to `shipment.purchased` and `tracking.updated` to avoid polling. See [Webhooks](/docs/webhooks).

## Refunds

`POST /shipments/{id}/refund` asks the carrier to void an unused label. It needs the `refunds:write` permission. The response has a `refund_status`:

| Status | Meaning |
| --- | --- |
| `submitted` | The carrier has the request. The wallet is credited when it approves, which can take days. |
| `refunded` | Approved and credited already. |
| `rejected` | The carrier refused. |
| `not_applicable` | The label cannot be refunded. |

Only purchased labels can be refunded, and asking twice fails with `409`. You get `shipment.refund_requested`, then either `shipment.refunded` or `shipment.refund_rejected` events.