# Retries and duplicates

> The public API has no general idempotency keys, so a retried create makes a second record. Which requests are safe to repeat, and how to retry the others without duplicates.

Web page: https://developer.yoprint.com/concepts/retries-and-duplicates/

> **Caution: No general idempotency keys**
>
> The public API ignores an `Idempotency-Key` header. If a create times out and you send it again, YoPrint makes a second record. Only a few operations have a key of their own, such as shipment creates, address saves and data exports: see [Built-in ways to avoid duplicates](#built-in-ways-to-avoid-duplicates). (AI apps work differently: their changes run at most once. See [How AI apps work](https://developer.yoprint.com/ai-apps/how-it-works.md).)

## Which requests are safe to repeat

| Request                                                                       | Safe to repeat?                                                                                                                                                                                 |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`, and `POST` filters and searches                                        | Yes. They only read.                                                                                                                                                                            |
| `PUT` or `PATCH` that sets fields to values                                   | Usually. Repeating it sets the same values again.                                                                                                                                               |
| `DELETE`                                                                      | Yes. A repeat either answers `404` or succeeds without changing anything, depending on the operation. Deleting an order line twice, for example, answers `200` with `action_performed: "NONE"`. |
| `POST` that creates a record                                                  | **No.** A repeat creates another one.                                                                                                                                                           |
| Anything that sends an email or text, records or moves money, or buys a label | **No.** A repeat sends, records or buys again. Repeated sends also count toward the [email limits](https://developer.yoprint.com/concepts/limits.md#email-sending).                             |

## Know whether a change happened

Every JSON answer has a `success` field as well as its HTTP status. Read them together:

**Diagram:** A few refusals answer 200, so the HTTP status alone isn't enough.

1. **2xx and "success": true**: It happened: don't retry. After publishing, also check `data.valid` → otherwise
2. **200 and "success": false**: Refused, and nothing changed, except Delete a job (below). Read `error` → otherwise
3. **4xx**: It didn't happen. Fix the request before you send it again. Retry a `429` after waiting → otherwise
4. **5xx, a timeout or no answer**: You don't know. Read the record back before you retry, and fix the cause of a known business `500` first

> **Danger: Delete a job deletes, even when it answers success: false**
>
> On some older orders, neither a draft nor published, Delete a job deletes the job anyway, then answers `200` with `"success": false`. Don't retry it: read the order's jobs back, and the job is gone.

The `200` refusals and the business `500`s are listed under [Known exceptions](https://developer.yoprint.com/concepts/errors.md#known-exceptions).

## Retry a create without a duplicate

1. Before the first attempt, decide how you'll find the record later. Use something only this record has and that a read can find, such as the customer's PO number on an order, which order search matches. For customers, see [Find a customer by email](https://developer.yoprint.com/guides/customers-and-contacts.md#find-a-customer-by-email).

2. Send the create.

3. If the answer is `5xx`, or you got no answer, look for the record. For an order, search for its PO number:

   `GET /v2/api/store/{team_slug}/search/orders` [Search sales orders and quotes](https://developer.yoprint.com/reference/search/v2-search-orders-get.md)

4. If it's there, use it. If it isn't, send the create again.

Keep a record of each create you send and its outcome, so a restart of your integration doesn't send the same create twice.

## Built-in ways to avoid duplicates

### Customers and contacts: upsert

Instead of creating a customer, upsert it. With `"perform_upsert": true`, YoPrint looks among your team's live customers for one with a contact whose email or phone matches the contact you send. If it finds one, it updates that customer. Otherwise it creates the customer and the contact.

`POST /v1/api/store/{team_slug}/customer/upsert` [Create or update a customer by contact](https://developer.yoprint.com/reference/customers/v1-customer-upsert.md)

```json
{
  "perform_upsert": true,
  "name": "Northwind Apparel",
  "contact": {
    "first_name": "Alex",
    "last_name": "Rivera",
    "email": "alex@example.com",
    "send_invoice": true,
    "is_primary": true
  }
}
```

Contacts work the same way inside one customer:

`POST /v1/api/store/{team_slug}/customer/{hash_id}/contact/upsert` [Create or update a customer contact by email or phone](https://developer.yoprint.com/reference/customer-contacts/v1-customer-contact-upsert.md)

> **Danger: Never upsert without an email or phone**
>
> Upsert matches on the contact's `email` **or** `phone`. Send neither, and it matches the first customer that has any contact, then overwrites that customer's name and settings with yours. Always send `contact.email`. To match by email only, leave out `contact.phone`.

**The dangerous request**

This doesn't create "Acme Corp": it renames the first customer it finds.

```json
{
  "perform_upsert": true,
  "name": "Acme Corp",
  "contact": {
    "first_name": "Jordan",
    "last_name": "Lee",
    "send_invoice": true,
    "is_primary": true
  }
}
```

Rules to know before using upsert:

- It matches on email **or** phone. A contact with the same phone number on another customer is treated as a match.
- When a customer matches, every top-level field you send (`name`, `website`, `tax_exempt`, `vat_number`, etc.) overwrites that customer's values. Fields you leave out keep their existing values.
- When a customer matches, the contact itself is not updated, and no new contact is added to the customer.
- When nothing matches (or with `perform_upsert: false`), a new customer is created with `contact` as its only contact. The contact's `is_primary` field is ignored on creation. Set it afterwards if needed (see [Create and sync customers](https://developer.yoprint.com/guides/customers-and-contacts.md)).

### Data exports: a request key

The data export operations take an `idempotency_key` in the body, up to 100 characters.

- Sending the same key again returns the same export, and starts nothing new, while it's `pending`, `generating` or `ready`, or after it failed while generating.
- If the export failed to queue, or its attempt expired, the same key queues it again. The usual limits then apply: `422` with `duplicate_report` and `429` with `export_capacity`.
- The same key for a different export is refused with `422`.

### Shipments: an operation key

Creating a shipment takes an optional `operation_key`, up to 100 characters. Sending the same key with the same body returns the shipment already made instead of a second one. The same key with a different body is refused.

`POST /v2/api/store/{team_slug}/v2/sales_order/{unique_key}/shipment` [Create a shipment for a sales order](https://developer.yoprint.com/reference/shipments/v2-sales-order-shipment-create.md)

### Customer addresses: an operation key and a revision

Saving a customer's address takes two optional fields:

- `base_revision_number`: the `revision_number` you last read. If someone changed the address since, YoPrint refuses the save with `409` (`stale_address`) instead of overwriting their change.
- `operation_key`, your own id for the save: retrying with the same key and the same values returns the address the first try made, instead of a `409`.

`PUT /v1/api/store/{team_slug}/customer/{hash_id}/address/{id}` [Update a customer address](https://developer.yoprint.com/reference/customer-addresses/v1-customer-address-update.md)

### Shipping labels

YoPrint's only guard is a 10-second lock per rate, counted from the first attempt's start. It's cleared at once when a purchase succeeds, kept for the rest of the 10 seconds after a failure, and it expires while a slow purchase is still running. After that, Shippo may sell a single-parcel rate again. Wait for each purchase's answer, and read the shipment's packages to see whether the label was bought before you retry. See [Ship an order](https://developer.yoprint.com/guides/shipping.md).

## Retry with backoff

For reads, and for changes you've confirmed didn't happen:

- Wait before the first retry, and wait longer each time, for example 1, 2, 4 and 8 seconds.
- Stop after a few attempts and report the failure.
- Don't retry a `4xx` without changing the request, except a `429`: it will fail the same way.
- Don't retry the business `500`s listed on [Errors](https://developer.yoprint.com/concepts/errors.md#known-exceptions): fix the cause.

> **Danger: Never retry emails in a loop**
>
> More than 60 calls in a minute that email customers (sending order details or reminders, creating or sending approvals) block your whole team's email, in the web app too, until YoPrint support lifts the block. A retry loop around a send can do that in seconds. Cap your own sends well under 60 a minute, and don't retry a send that may have gone out. See [Limits](https://developer.yoprint.com/concepts/limits.md#email-sending).
