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
referenceis 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 -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,stocktakeIdand 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 /itemsanswers409 SkuConflict, never a second item. On a retry, treat that as done and read the item. - Idempotent by content
- A
PUTthat sets a value, such asPUT /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}/placeturns 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 -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
- Make the key once, when you decide to do the work. Save it with your own record before you send.
- Send. If there is no answer, the call times out, or it answers
5xx, wait and send the identical request: same key, same body. - A
2xxmeans done, whether this was the first try or a retry. The answer is the record as it stands. - A
401means the token was not accepted. Get a new token and send again: same key, same body. - Any other
4xxmeans 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 a4xx; they are listed below, and marked in each operation's Errors.
// 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:
| Call | Refusal | Check |
|---|---|---|
POST /itemsInventory Manager API | 409 SkuConflict | GET the SKU: if it is the one you meant to create, your earlier call landed. |
POST /locationsInventory Manager API | 409 LocationNameConflict | List the locations: if one has that name and is the one you meant, your earlier call landed. |
POST /productsInventory Manager API | 409 VariantSkuConflict | GET the product: if the variant is there with that SKU, your earlier call landed. |
POST /products/{productId}/variantsInventory Manager API | 409 VariantSkuConflict | GET the product: if the variant is there with that SKU, your earlier call landed. |
POST /items/{sku}/assetsInventory Manager API | 404 AssetNotFound | The file your first upload stored was removed since. Upload it again with a new reference. |
POST /products/{productId}/assetsInventory Manager API | 404 AssetNotFound | The file your first upload stored was removed since. Upload it again with a new reference. |
POST /catalogsInventory Manager API | 409 CatalogNameConflict | List the catalogs: if one has that name and is the one you meant, your earlier call landed. |
POST /accountsInventory Manager API | 409 AccountCodeConflict | List the accounts: if one has that code and is the one you meant, your earlier call landed. |
POST /pos/{poId}/invoicePurchasing Manager API | 409 PoNotAuthorised | GET the purchase order: if its status is invoiced with your costs, your earlier call landed. |
POST /orders/{orderId}/shipments/{shipmentId}/pickFulfillment Manager API | 409 ShipmentNotInState | GET 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 API | 409 ShipmentNotInState | GET 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 API | 409 NothingToInvoice | GET 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 API | 409 PaymentCollectionAnswered | If this answers a retry, your first call probably landed. Read the record before doing anything else. |
DELETE /settings/domainNotifications API | 404 SendingDomainNotFound | Nothing to check: the domain is gone, which is what you asked for. |
DELETE /settings/smsNotifications API | 404 SmsSenderNotFound | If this answers a retry, your first call probably landed. Read the record before doing anything else. |
DELETE /settings/pages-domainNotifications API | 404 PagesDomainNotFound | If this answers a retry, your first call probably landed. Read the record before doing anything else. |
DELETE /suppressions/{suppressionId}Notifications API | 404 SuppressionNotFound | If this answers a retry, your first call probably landed. Read the record before doing anything else. |
DELETE /me/push/devices/{deviceId}Notifications API | 404 PushDeviceNotFound | If this answers a retry, your first call probably landed. Read the record before doing anything else. |
POST /imports/{jobId}/commitImport / Export API | 409 ImportNotReady | GET 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-1anddock-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: apaymentIdis 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
PUTthat 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