Idempotency and retries

Send the same write twice and Manifold applies it once. For most writes, the key you send is what makes that true.

The rule

Every write is safe to send twice. Most do it with a key you send; the rest are safe by what they do. Each operation's Retries section says which, and how far its key reaches.

Manifold checks the key before it looks at the state of the record. If it has already recorded work under that key, it does not do it again: it answers with the record as it stands now, and records nothing.

  • Where a write takes a key, the key is required. Manifold never makes one up for you, because a key you did not send cannot tell your retry from new work.
  • A key is remembered for good. It does not expire.
  • A reference is unique across your organization for that kind of write. An id you make is unique for its record.

Why

A network can drop the answer to a call that succeeded. A queue can deliver a message twice. A person can click twice. In each case you cannot tell whether the write landed. With a key you do not need to: send it again, and it lands once.

Receive twice, stock once

Receiving stock takes the delivery note as its key, reference:

curl
curl -X POST 'https://inventory.getmanifold.io/items/WIDGET-BLUE/receive' \
  -H "Authorization: Bearer $MANIFOLD_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
  "quantity": 10,
  "reference": "dock-note-8841"
}'

The first call adds ten units of WIDGET-BLUE. Send exactly the same call again and it answers with the item, adds nothing and records no fact. You have ten, not twenty.

A retry answers with the same status as the first call. You need not tell them apart: either way, the work is done once.

Run it end to end: What happens when you get it wrong, or send it twice.

What the key is

Each operation's page says what makes it safe to retry, under Retries. It is one of these.

A reference from your system
reference: the document the work came from, such as a delivery note, your sales order number or the buyer's PO number. Receiving stock, creating an order and raising a purchase order take one.
An id you make for the record
paymentId, refundId, receiptId, writeOffId, stocktakeId and others: the new record's id, made by you with the right prefix. See Ids you make.
The record's own key
Creating a SKU is keyed by the SKU. A retried POST /items answers 409 SkuConflict, never a second item. On a retry, treat that as done and read the item.
Idempotent by content
A PUT that sets a value, such as PUT /orders/{orderId}/payment-terms. Asking for the value the record already has changes nothing and records nothing.
Idempotent by state
A step a record takes once. POST /orders/{orderId}/place turns a quote into an order; sent again, it answers the order as it stands.
No idempotency key
A few writes take no key, and their page says why. A retried CSV upload, for example, stages a second import job. Nothing is applied until you commit a job, so commit only the one you want.

A key you make: payments

curl
curl -X POST 'https://fulfillment.getmanifold.io/orders/ord_0192f3a4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/payments' \
  -H "Authorization: Bearer $MANIFOLD_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
  "paymentId": "pay_0192f3a4-7b1c-7d2e-8f30-4a5b6c7d8e9f",
  "amountCents": 7200,
  "method": "bank-transfer",
  "reference": "BANK-TXN-55102"
}'

The key here is paymentId. reference is the bank's own id for the transfer, kept for your records; it is not the key. The key is checked against that order's payments only, so never reuse one for another order: there it is a new payment.

The key is checked before the order's state, so that state cannot refuse your retry. If this payment settled the order, a retry is still answered with the payment already recorded, not with 409 OverPayment, and the money is counted once.

How to retry

  1. Make the key once, when you decide to do the work. Save it with your own record before you send.
  2. Send. If there is no answer, the call times out, or it answers 5xx, wait and send the identical request: same key, same body.
  3. A 2xx means done, whether this was the first try or a retry. The answer is the record as it stands.
  4. A 401 means the token was not accepted. Get a new token and send again: same key, same body.
  5. Any other 4xx means refused. Nothing was recorded, so do not loop. Fix what the body names, then send the corrected request. The key was not used, so you may keep it. A few operations answer a retry with a 4xx; they are listed below, and marked in each operation's Errors.
JavaScript
// makeRequest builds the request afresh on every try: the same url, key and
// body each time, with a token that is valid now (a single-use app token is
// minted again here).
async function send(makeRequest, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    const { url, init } = await makeRequest();
    let res;
    try {
      res = await fetch(url, init);
    } catch {
      res = undefined; // no answer: it may or may not have been applied
    }
    // 2xx: done. Any other 4xx but 401: refused, nothing recorded.
    if (res !== undefined && res.status < 500 && res.status !== 401) return res;
    if (attempt === attempts) throw new Error("gave up; send it again later with the same key");
    await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
  }
}

When a retry is answered with a refusal

A few operations answer a retry of a call that already worked with a 4xx, because the record has moved on: a picked parcel cannot be picked again. The refusal does not say whether your call or someone else's moved it, so check the record before you do anything else:

CallRefusalCheck
POST /itemsInventory Manager API409 SkuConflictGET the SKU: if it is the one you meant to create, your earlier call landed.
POST /locationsInventory Manager API409 LocationNameConflictList the locations: if one has that name and is the one you meant, your earlier call landed.
POST /productsInventory Manager API409 VariantSkuConflictGET the product: if the variant is there with that SKU, your earlier call landed.
POST /products/{productId}/variantsInventory Manager API409 VariantSkuConflictGET the product: if the variant is there with that SKU, your earlier call landed.
POST /items/{sku}/assetsInventory Manager API404 AssetNotFoundThe file your first upload stored was removed since. Upload it again with a new reference.
POST /products/{productId}/assetsInventory Manager API404 AssetNotFoundThe file your first upload stored was removed since. Upload it again with a new reference.
POST /catalogsInventory Manager API409 CatalogNameConflictList the catalogs: if one has that name and is the one you meant, your earlier call landed.
POST /accountsInventory Manager API409 AccountCodeConflictList the accounts: if one has that code and is the one you meant, your earlier call landed.
POST /pos/{poId}/invoicePurchasing Manager API409 PoNotAuthorisedGET the purchase order: if its status is invoiced with your costs, your earlier call landed.
POST /orders/{orderId}/shipments/{shipmentId}/pickFulfillment Manager API409 ShipmentNotInStateGET the order and look at that parcel in shipments[].status: picked after a pick, packed after a pack, means your earlier call landed.
POST /orders/{orderId}/shipments/{shipmentId}/packFulfillment Manager API409 ShipmentNotInStateGET the order and look at that parcel in shipments[].status: picked after a pick, packed after a pack, means your earlier call landed.
POST /orders/{orderId}/invoicesFulfillment Manager API409 NothingToInvoiceGET the order: if invoices already bills what shipped, your earlier call landed. Name the shipmentId next time, and a retry answers with the invoice.
POST /orders/{orderId}/collections/{collectionId}/answerFulfillment Manager API409 PaymentCollectionAnsweredIf this answers a retry, your first call probably landed. Read the record before doing anything else.
DELETE /settings/domainNotifications API404 SendingDomainNotFoundNothing to check: the domain is gone, which is what you asked for.
DELETE /settings/smsNotifications API404 SmsSenderNotFoundIf this answers a retry, your first call probably landed. Read the record before doing anything else.
DELETE /settings/pages-domainNotifications API404 PagesDomainNotFoundIf this answers a retry, your first call probably landed. Read the record before doing anything else.
DELETE /suppressions/{suppressionId}Notifications API404 SuppressionNotFoundIf this answers a retry, your first call probably landed. Read the record before doing anything else.
DELETE /me/push/devices/{deviceId}Notifications API404 PushDeviceNotFoundIf this answers a retry, your first call probably landed. Read the record before doing anything else.
POST /imports/{jobId}/commitImport / Export API409 ImportNotReadyGET the job: if its status is queued, running or completed, your earlier commit landed.

With a single-use app token, each try needs a fresh token; the key stays the same. See Authentication and app tokens.

Mistakes to avoid

  • Reusing a key for different work. It is answered as a retry of the first work, and nothing new is recorded. Two deliveries under one note number, even into different SKUs, need two keys, such as dock-note-8841-1 and dock-note-8841-2. A note number that comes round again next year needs a new key too.
  • Assuming every key counts across your organization. Some do, such as a receive's reference. Others count within one record: a paymentId is checked against that order's payments only.
  • A new key on every try. Each try is then new work, and a retry after a lost answer applies it twice.
  • Changing the body on a retry. Under a key, Manifold does not compare bodies: the first request under a key is the one that counts. For a PUT that is idempotent by content, the body is the key, so a different body is a new change.
  • Expecting the first answer back. A retry answers with the record as it is now, which may include changes made since.

Related: Facts and the journal · Errors · Quickstart: write, and retry safely