Brief for the Allin Studio backend: connect to Additive Inn as a production partner
Paste this whole document into the AI that maintains the Tap To Connect backend. It is written for that AI. Version 1, 17/09/2026.
0. Context
Allin Studio sells 3D-printed NFC stands (Tap Bar, Tap Board, Tap Base, Tap Tile, Tap Snap). Today the backend generates the 3D files for each customer order and home-based makers ("Studio Nodes") pull those files from the backend, print, insert the NFC chips and magnets, apply the DTF sticker, programme the chips, and pack.
Additive Inn (www.additiveinn.com, Singapore) is being added as a second kind of maker. Unlike a Studio Node, Additive Inn is reached by API: the backend pushes an order and its files to Additive Inn, Additive Inn prints, UV-prints the 2D artwork directly onto the plastic (no sticker), inserts and programmes the NFC chips, assembles, QCs and packs, and reports progress back by webhook. Pricing is a fixed per-SKU price list agreed between the two businesses, not a per-order quote, so the backend never has to ask for a price: it posts the order and gets the priced breakdown back.
Nothing about the existing Studio Node flow changes. Additive Inn is one more value in a "maker" field.
Three things Additive Inn needs that a Studio Node never asked for, because Additive Inn stocks material to Allin's specification instead of substituting: the exact filament (brand, line, colour code) behind every colour on every part; a checksummed print file per line item; and a fulfilment mode per order, because either side can do the last mile. NFC chips and magnets are supplied by Additive Inn unless Allin prefers to supply them in bulk.
1. Scope of the work
Build, in the existing backend:
- A maker abstraction: each order line (or order) is assigned to a maker.
Existing behaviour is
studio-node. New value:additive-inn. - An Additive Inn adapter that turns an approved order into the API
payload below, posts it, and stores what comes back (
orderId, the pricing snapshot, the ETA). - An outbox with retries, so a network failure never loses or duplicates an order (the API is idempotent; use that).
- A webhook receiver that verifies signatures, deduplicates, and mirrors Additive Inn's status into the ERP order (plus the NFC tag UIDs and the courier tracking when they arrive).
- File export that follows the conventions in section 3 (one 3MF per line item with named, coloured objects and a print manifest; UV artwork as PNG).
- An admin surface: assign to Additive Inn, send, resend, see status, see the price we are paying next to the retail price, open the Additive Inn order page.
- Test mode against the test key, with the acceptance tests in section 7.
- A materials manifest: the filament behind each of the 17 palette
colours (and any specials), declared once with
PUT /materialsand kept current whenever the palette changes. Additive Inn stocks from this list. It is the first deliverable, ahead of any code (section 3.5).
Keep the customer-facing site untouched. Nothing here changes what a customer sees, except that orders fulfilled by Additive Inn can ship sooner.
2. Additive Inn Partner API v1
2.1 Base URL, environments, authentication
Production: https://www.additiveinn.com/api/partner/v1
Test: same base URL; the key decides the environment
Every request carries Authorization: Bearer <key>.
ai_live_...keys create real orders that Additive Inn will produce and invoice.ai_test_...keys create orders flaggedtest: true. They go through every validation, get priced, get files stored, appear on Additive Inn's staff board marked TEST ORDER, DO NOT PRODUCE, are never invoiced, and can be walked through every status by Additive Inn staff so your webhook handling can be exercised end to end.
Keys are issued by Additive Inn out of band (one live, one test). Each is
ai_live_ or ai_test_ followed by at least 32 random characters. Store them
as secrets (ADDITIVE_INN_API_KEY, ADDITIVE_INN_API_BASE,
ADDITIVE_INN_WEBHOOK_SECRET). Never put a key in a URL, a log line, or the
browser.
All request and response bodies are JSON, UTF-8. Timestamps are ISO 8601 with offset. Money is integer cents in SGD, ex-GST unless the field says otherwise.
Timeouts: POST /orders fetches every file before it answers, which is
usually a few seconds but can take longer for large print files. The public
host closes a request at 60 seconds and answers 504; the order keeps being
created behind it. Treat a 504, a dropped connection or 409 idempotency_in_progress the same way: wait the Retry-After (5 seconds if
none) and resend the identical request with the same Idempotency-Key until
you get 201 or 200. Set your HTTP client timeout to at least 120 seconds.
Malformed JSON is sometimes refused by the platform before it reaches the API,
as a 400 whose body is not JSON. Treat any non-JSON 400 as bad_json.
2.2 Idempotency
POST /orders requires an Idempotency-Key header. Use your own order
reference plus a revision, e.g. TAP-456-r1.
- Same key, same body:
200with the order as it stands now (sameorderId), and the headerIdempotent-Replayed: true. Safe to retry forever. - Same key while the first request is still running:
409 idempotency_in_progresswithRetry-After. A claim left behind by a request that died is released after 10 minutes. - A request that failed with a
4xxdid not use the key; fix and resend with the same key. - Same key, different body:
409 idempotency_conflict. Bump the revision. - Keys are remembered for 30 days.
2.3 Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /price-list |
The agreed SKU and add-on prices, versioned. Cache it; re-fetch daily and on price_list_stale. |
| PUT | /materials |
Declare the full list of filaments the palette maps to (section 3.5). Idempotent; replaces the previous list. Returns stock status per entry. |
| POST | /orders |
Create an order. Returns the priced order. |
| GET | /orders/{orderId} |
Current state of one order. |
| GET | /orders?since=<iso>&limit=<n> |
Orders updated since a time. Polling fallback if webhooks are down. |
| POST | /orders/{orderId}/cancel |
Cancel. 200 before printing starts, 409 already_in_production after. |
| POST | /orders/{orderId}/notes |
Add a note, optionally with replacement files, to an order that is not yet printing. Body { message, files?: [{ lineRef, id, role, filename, url, sha256, bytes, printAreaMm?, replaces? }] }; replaces names the file id being superseded and is required for a print file, which then becomes the line's print file and is checked against the line's manifest like the original. Same fetch and checksum rules as at creation. 409 already_in_production once printing has started. |
| GET | /account |
Credit limit, float balance, open exposure and what is available for the next order (section 2.9.2). |
| POST | /orders/{orderId}/dispute |
Raise a quality dispute within 5 working days of ready (section 2.9.6). |
| POST | /orders/{orderId}/redeliver |
Queue every event we gave up delivering for this order for a fresh round, or send { "eventIds": [...] } to resend exactly those events, delivered or not (section 2.7). |
| GET | /webhook |
Where events for this key's environment are sent, and how many are waiting. |
| PUT | /webhook |
Set that URL: { "url": "https://..." }, or { "url": null } to pause deliveries. |
| POST | /webhook/ping |
Send a signed ping event to that URL now and report what it answered. |
| POST | /price-appeals |
Ask for a different price: { "items": [{ "sku": "AS-TAPTILE", "askCents": 600 }, { "addOn": "BACKPRINT-UV", "max": true, "askCents": 850 }], "message": "why" }, ex-GST cents, up to 20 items. 201 with the appeal (appealId, status: "open", each item's currentCents). At most 5 open at once (409 too_many_open_appeals). Also on your partner page, under Prices. |
| GET | /price-appeals |
Your appeals for this key's environment, newest first: open, accepted (each item's grantedCents, which may differ from your ask), declined (with a note) or withdrawn. |
| POST | /price-appeals/{appealId}/withdraw |
Withdraw an appeal that is still open. |
GET /price-list response:
{
"version": 3,
"updatedAt": "2026-09-20T02:00:00+08:00",
"currency": "SGD",
"exGst": true,
"gstRate": 9,
"skus": [
{ "sku": "AS-TAPBAR-2", "name": "Tap Bar 2", "unitCents": 3400,
"includes": ["UV-LOGO"], "tileSlots": 2 }
],
"addOns": [
{ "code": "EXTRA-TILE", "unitCents": 700, "per": "unit" },
{ "code": "BACKPRINT-UV", "unitCents": 700, "per": "unit", "skus": ["AS-TAPBAR-2", "AS-TAPBAR-3", "AS-TAPBAR-4", "AS-TAPBAR-QR"] },
{ "code": "RUSH", "unitCents": 1500, "per": "order" }
],
"leadTime": { "standardWorkingDays": 3, "rushWorkingDays": 1, "cutoffLocal": "14:00", "timezone": "Asia/Singapore" },
"consumables": { "ntag215": "additive-inn", "magnets": "additive-inn" },
"materials": [
{ "filament": "Bambu Lab PLA Basic 10101", "name": "Black", "hex": "#1A1A1A", "status": "stocked" },
{ "filament": "Bambu Lab PLA Basic 10100", "name": "Jade White", "hex": "#FEFEFE", "status": "on_order", "expected": "2026-09-24" }
]
}
The numbers above are placeholders; the live list is the source of truth.
materials is your own list as declared with PUT /materials, with Additive
Inn's shelf status against each entry. consumables says who supplies the
chips and magnets (additive-inn or partner); the unit prices already
reflect it.
Store version and send it back as expectedPriceListVersion on each order
so a price change never surprises either side.
2.4 Order request (POST /orders)
{
"partnerOrderRef": "TAP-456",
"partnerOrderUrl": "https://erp.allin-studio.com/orders/TAP-456",
"expectedPriceListVersion": 3,
"placedAt": "2026-09-17T09:12:00+08:00",
"proof": {
"imageUrl": "https://xxxx.public.blob.vercel-storage.com/TAP-456/proof.jpg",
"approvedAt": "2026-09-17T10:40:00+08:00"
},
"customer": {
"businessName": "Petite Krumbs",
"contactName": "Sarah",
"phone": "+6591234567"
},
"fulfilment": {
"mode": "partner_courier_pickup",
"needBy": "2026-09-30",
"priority": "standard",
"shipTo": null
},
"items": [
{
"lineRef": "TAP-456-1",
"sku": "AS-TAPBAR-2",
"qty": 1,
"colours": {
"body": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" },
"plate": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" },
"tiles": { "name": "Sakura Pink", "hex": "#E898A0", "filament": "Bambu Lab PLA Matte 11201" },
"icons": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" },
"sleeves":{ "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" },
"text": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" }
},
"tiles": [
{ "slot": 1, "icon": "google", "iconMode": "print", "url": "https://g.page/r/abc/review" },
{ "slot": 2, "icon": "custom-qr", "iconMode": "uv", "url": "https://instagram.com/petite.krumbs", "artworkFileId": "f-qr2" }
],
"logo": { "mode": "uv", "fileId": "f-logo" },
"backprint": { "mode": "uv", "fileIds": ["f-bp1"], "placement": { "offsetXmm": 0, "offsetYmm": -4, "scale": 0.9 } },
"addOns": [
{ "code": "EXTRA-TILE", "qty": 1, "tiles": [ { "slot": 3, "icon": "whatsapp", "iconMode": "print", "url": "https://wa.me/6591234567" } ] },
{ "code": "KEYCHAIN-BASE", "qty": 1 }
],
"print": {
"fileId": "f-print",
"units": "mm",
"objects": [
{ "name": "body__Black__1A1A1A", "part": "body", "colour": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" } },
{ "name": "plate__JadeWhite__FEFEFE", "part": "plate", "colour": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" } },
{ "name": "tile1__SakuraPink__E898A0", "part": "tile", "colour": { "name": "Sakura Pink", "hex": "#E898A0", "filament": "Bambu Lab PLA Matte 11201" }, "slot": 1 },
{ "name": "tile1-icon__Black__1A1A1A", "part": "icon", "colour": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" }, "slot": 1 },
{ "name": "tile2__SakuraPink__E898A0", "part": "tile", "colour": { "name": "Sakura Pink", "hex": "#E898A0", "filament": "Bambu Lab PLA Matte 11201" }, "slot": 2 },
{ "name": "tile3__SakuraPink__E898A0", "part": "tile", "colour": { "name": "Sakura Pink", "hex": "#E898A0", "filament": "Bambu Lab PLA Matte 11201" }, "slot": 3 },
{ "name": "tile3-icon__Black__1A1A1A", "part": "icon", "colour": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" }, "slot": 3 },
{ "name": "sleeve1__JadeWhite__FEFEFE", "part": "sleeve", "colour": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" }, "slot": 1 },
{ "name": "sleeve2__JadeWhite__FEFEFE", "part": "sleeve", "colour": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" }, "slot": 2 },
{ "name": "text__JadeWhite__FEFEFE", "part": "text", "colour": { "name": "Jade White", "hex": "#FEFEFE", "filament": "Bambu Lab PLA Basic 10100" } },
{ "name": "keychain__Black__1A1A1A", "part": "keychain","colour": { "name": "Black", "hex": "#1A1A1A", "filament": "Bambu Lab PLA Basic 10101" } }
],
"inserts": [
{ "object": "tile1__SakuraPink__E898A0", "type": "ntag215-coin-25", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "tile1__SakuraPink__E898A0", "type": "magnet-d6x2", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "tile2__SakuraPink__E898A0", "type": "ntag215-coin-25", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "tile2__SakuraPink__E898A0", "type": "magnet-d6x2", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "tile3__SakuraPink__E898A0", "type": "ntag215-coin-25", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "tile3__SakuraPink__E898A0", "type": "magnet-d6x2", "qty": 1, "pauseAtZmm": 2.4 },
{ "object": "body__Black__1A1A1A", "type": "magnet-d6x2", "qty": 2, "pauseAtZmm": 38.0 }
],
"notes": "Plate is a press fit, print face down."
},
"files": [
{ "id": "f-print", "role": "print", "filename": "TAP-456-1.3mf", "url": "https://xxxx.public.blob.vercel-storage.com/TAP-456/TAP-456-1.3mf", "sha256": "9f86d0…", "bytes": 4183220, "contentType": "model/3mf" },
{ "id": "f-logo", "role": "uv-logo", "filename": "logo.png", "url": "https://…/logo.png", "sha256": "…", "bytes": 210331, "contentType": "image/png", "printAreaMm": [60, 30] },
{ "id": "f-bp1", "role": "uv-backprint","filename": "back.png", "url": "https://…/back.png", "sha256": "…", "bytes": 512000, "contentType": "image/png", "printAreaMm": [80, 100] },
{ "id": "f-qr2", "role": "uv-tile", "filename": "tile2-qr.png", "url": "https://…/tile2.png", "sha256": "…", "bytes": 90112, "contentType": "image/png", "printAreaMm": [30, 30] }
],
"notes": "Customer wants the logo slightly higher than the proof if possible."
}
],
"notes": "Repeat customer, second stand for a new outlet."
}
Field rules:
| Field | Required | Notes |
|---|---|---|
partnerOrderRef |
yes | Your reference. Unique per order. Shown on the Additive Inn job card and on the invoice. |
partnerOrderUrl |
no | Deep link for Additive Inn staff. |
expectedPriceListVersion |
no | If sent and it does not match the live list: 409 price_list_stale with the new version in the body. |
supersedes |
no | An earlier live order id this one replaces (section 2.9.6). |
proof |
yes on live keys | The image the customer approved and when. Additive Inn QC compares the finished piece to it. Optional on test keys, and waived on the one firstArticle: true order (section 2.9.6). |
customer.businessName |
yes | Goes on the box label. Send the minimum you need; no emails or addresses unless fulfilment.mode needs them. |
fulfilment.mode |
yes | Either side can do the last mile; choose per order. partner_courier_pickup (your courier collects from Additive Inn), partner_collect (you collect), additive_inn_ship (Additive Inn books the courier to shipTo; the courier cost plus S$2 handling comes back as an order add-on SHIPPING in the pricing, and the tracking number arrives in the order.shipped webhook). |
fulfilment.needBy |
no | Date. Additive Inn will reject with 422 lead_time if it cannot be met at the chosen priority. |
fulfilment.priority |
yes | standard or rush. rush adds the RUSH add-on once per order. |
items[].lineRef |
yes | Unique within the order. |
items[].sku |
yes | From section 4. |
items[].qty |
yes | 1 to 50. Above 50, split orders. |
items[].colours |
yes | One entry per part the SKU has. Keys: body, plate, tiles, icons, sleeves, text, qrcode, card, frame. Each entry carries name, hex and filament (the exact spool, matching an entry in your PUT /materials list). A filament on your list is never charged extra, stocked or not: if its status is on_order the order goes to needs_info with the expected date and you decide whether to wait or swap. A filament that is NOT on your list is a one-off for one end customer and is priced as CUSTOM-COLOUR. |
items[].tiles |
yes for tile SKUs | One per slot the SKU has. icon is one of the built-in icon keys (section 4) or custom-qr, payment-qr, custom-icon. iconMode is print (icon is 3D-printed in the icon colour, geometry must be in the 3MF) or uv (icon is UV-printed from artworkFileId). payment-qr is always UV, black on white. url is what gets written to the NTAG215; omit for payment-qr. |
items[].logo |
SKUs with a plate | mode: uv (included), emboss (adds EMBOSS-LOGO; emboss geometry must be in the 3MF and embossColour given), none. |
items[].qrPanel |
AS-TAPBAR-QR only |
{ fileId } pointing at the uv-qrpanel artwork for the static QR panel (included in the SKU price). |
items[].backprint |
Bar SKUs only | mode: uv, emboss, none. placement mirrors your configurator's offset/scale. |
items[].addOns |
no | Only things not inferable from the item: EXTRA-TILE (with its tiles), KEYCHAIN-BASE, TAPSNAP-UV-SIDE. Everything else (emboss, backprint, UV tiles, custom colours, rush) is derived and priced from the item itself. |
items[].print |
yes | The manifest for the 3MF. See section 3. |
items[].files |
yes | Every file the item needs. See section 3. |
2.5 Order response and statuses
201 Created (or 200 on an idempotent replay):
{
"orderId": "AIP-1042",
"partnerOrderRef": "TAP-456",
"test": false,
"status": "received",
"createdAt": "2026-09-17T10:41:07+08:00",
"pricing": {
"currency": "SGD", "exGst": true, "priceListVersion": 3,
"lines": [
{ "lineRef": "TAP-456-1", "sku": "AS-TAPBAR-2", "qty": 1, "unitCents": 3400,
"addOns": [
{ "code": "BACKPRINT-UV", "qty": 1, "cents": 700 },
{ "code": "TILE-UV", "qty": 1, "cents": 300, "note": "slot 2 custom QR" },
{ "code": "EXTRA-TILE", "qty": 1, "cents": 700 },
{ "code": "KEYCHAIN-BASE","qty": 1, "cents": 200 }
],
"lineCents": 5300 }
],
"orderAddOns": [],
"subtotalCents": 5300,
"gstCents": 477,
"totalCents": 5777
},
"eta": { "readyBy": "2026-09-22", "basis": "3 working days from acceptance, cut-off 14:00 SGT" },
"files": [
{ "id": "f-print", "status": "stored", "sha256Verified": true },
{ "id": "f-logo", "status": "stored", "sha256Verified": true, "dpiAtSize": 320 }
],
"warnings": [
{ "code": "uv_low_dpi", "fileId": "f-bp1", "message": "180 dpi at 80 × 100 mm; 300 recommended" }
],
"url": "https://www.additiveinn.com/api/partner/v1/orders/AIP-1042"
}
Statuses, in order:
| Status | Meaning |
|---|---|
received |
Stored, priced, files copied. |
accepted |
Staff opened the files and they slice. The ETA clock starts here. |
needs_info |
Blocked. message says what. Reply with POST /orders/{id}/notes. |
printing |
On a printer. Cancellation now returns 409. |
uv_printing |
2D artwork going on. |
assembling |
Inserts in, tags being programmed, parts fitted. |
qc |
Being checked against the proof. |
ready |
Packed, waiting for collection (or for your courier). |
shipped |
Handed to a courier. tracking filled in when we have it. |
completed |
Collected or delivered. Terminal. |
cancelled |
By you or by Additive Inn (with message). Terminal. |
GET /orders/{orderId} returns the same object plus status, statusHistory,
tiles[] (with tagUid once programmed), tracking, photos[] (QC photos)
and notes[].
2.6 Errors
{ "error": { "code": "validation_failed", "message": "2 problems", "details": [
{ "path": "items[0].files[0].sha256", "code": "file_checksum_mismatch", "message": "computed 3a7f…, declared 9f86…" },
{ "path": "items[0].tiles[1].url", "code": "required", "message": "a url is required unless icon is payment-qr" }
] } }
| HTTP | code |
When |
|---|---|---|
| 400 | bad_json |
Body is not valid JSON. |
| 400 | idempotency_key_required |
POST /orders without an Idempotency-Key header. |
| 401 | unauthorized |
Missing or unknown key. |
| 403 | key_env_mismatch |
Test key used where a live action is required (firstArticle). |
| 404 | not_found |
Unknown orderId, not yours, or from the other environment. |
| 409 | idempotency_conflict |
Same key, different body. |
| 409 | idempotency_in_progress |
Same key, first request still running. Honour Retry-After. |
| 409 | price_list_stale |
expectedPriceListVersion is behind. Body carries currentVersion. |
| 409 | already_in_production |
Cancel, note or supersede after printing started. |
| 409 | already_cancelled |
A note on a cancelled order. |
| 409 | already_completed |
A cancel on a completed order. |
| 413 | file_too_large |
Print file over 200 MB or artwork over 10 MB, declared in bytes or found while downloading. details names the file. |
| 422 | validation_failed |
Field-level problems in details, each { path, code, message }. Codes include required, invalid, duplicate, too_many, unknown_sku, lead_time, unsupported_destination, missing_object (the manifest lacks a part the SKU needs, or the 3MF lacks an object the manifest names), unreferenced_file, file_unreachable, file_unreadable, file_checksum_mismatch, file_size_mismatch, uv_low_dpi (below 150 dpi at printAreaMm), file_too_large (when mixed with other problems). |
| 429 | rate_limited |
Honour Retry-After. |
| 5xx | internal |
Retry with backoff; the idempotency key makes it safe. |
Warnings never refuse an order. They come back in warnings[] on the order,
each { code, message, lineRef?, fileId? }: uv_low_dpi (150 to 299 dpi),
colour_count (more than 4 filaments in one item; Additive Inn splits plates
and says so in needs_info if it cannot), unlisted_objects (objects in the
3MF the manifest does not name; they are ignored), manifest_unchecked (a
print file over 64 MB is not opened, the manifest is trusted),
shipping_estimate and proof_unavailable (the proof image could not be
fetched; QC will ask for it).
GET /orders?since=&limit= returns { "orders": [ { orderId, partnerOrderRef, status, test, createdAt, updatedAt, subtotalCents, hold, url } ], "hasMore": true, "nextSince": "<updatedAt of the last row>" }. Page by
passing nextSince back as since until hasMore is false. limit is 1 to
200, default 50.
2.7 Webhooks (Additive Inn to you)
Register your endpoint yourself: PUT /webhook { "url": "https://..." }. The
test key sets where test events go, the live key where live events go, so a
staging backend and production can each have their own. Only public https
URLs on a hostname are accepted, and redirects are not followed. Until a URL
is registered, events wait (they are not counted as failed attempts) and go
out the moment one is set.
Additive Inn sends you the signing secret (whsec_...) separately. The same
secret signs test and live events.
Check the wiring with POST /webhook/ping: it sends an event of type ping
(data has partner, env and message), signed exactly like the rest, and
answers { eventId, url, delivered, httpStatus, error, ms }. GET /webhook
answers { env, url, updatedAt, signing, pendingEvents, ordersWithFailedEvents }; signing is ready once your secret is in place
on our side. A ping before a URL is set is 409 no_webhook_url.
Each event is one POST with JSON:
{
"id": "evt_01J8…",
"type": "order.status_changed",
"createdAt": "2026-09-19T14:05:11+08:00",
"test": false,
"data": {
"orderId": "AIP-1042",
"partnerOrderRef": "TAP-456",
"status": "assembling",
"previousStatus": "uv_printing",
"message": null,
"eta": { "readyBy": "2026-09-22" },
"tiles": [
{ "lineRef": "TAP-456-1", "slot": 1, "tagUid": "04:A3:2F:1A:9B:5C:80", "programmedUrl": "https://g.page/r/abc/review" }
],
"tracking": null,
"photos": []
}
}
Every event's data carries orderId, partnerOrderRef, status, eta,
tiles (all of them, with tagUid once programmed), tracking, photos
and the order's billing block (without the account part; read that from
GET /account), plus the fields particular to the event.
Event types: order.received, order.accepted, order.needs_info,
order.status_changed (every transition, including the ones with their own
type, partner cancellations, supersessions and a lapsed needs_info),
order.shipped (has tracking), order.completed, order.cancelled,
order.file_rejected (staff found a file unusable after it was stored: has
fileId, lineRef, role and message; the order moves to needs_info,
answer with a replacement file on /notes), order.tiles_programmed (tags
written; tiles has the UIDs). The billing events
(order.billing_hold, order.billing_released, order.billable,
order.rework, order.invoiced, order.paid, order.dispute_resolved) are
defined in 2.9.5 and signed and delivered exactly the same way.
One event is about the account, not an order: price_list.updated, sent the
moment Additive Inn changes your price list (a price, a product or add-on
name, a product or add-on turned on or off, the lead time or the order
limits). It goes to each webhook you have registered, live and test, with
test set to match, and carries data.version (the new version),
data.previousVersion, data.updatedAt and data.changes, a list of
{ kind, code?, field, from, to } (prices in ex-GST cents). On receipt,
fetch GET /price-list and price new orders from it. It is delivered once
and not retried; if you miss it, your next order that sends the old
expectedPriceListVersion gets 409 price_list_stale with the new version,
which is the same cue.
When Additive Inn answers one of your price appeals, price_appeal.decided
goes to the webhook of the appeal's environment. data is the appeal as
GET /price-appeals shows it: status (accepted or declined), note,
each item's askCents and grantedCents, applied (true when the price
list changed) and priceListVersion. An accepted live appeal also sends
price_list.updated first, so price new orders from the list as usual. A
sandbox appeal is answered the same way but never changes a price.
Headers:
X-AI-Event-Id: evt_01J8…
X-AI-Signature: t=1758261911,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the webhook secret>
Content-Type: application/json
Verification (do this before parsing):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, nowSec = Date.now() / 1000) {
const m = /t=(\d+),v1=([0-9a-f]{64})/.exec(header || "");
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(nowSec - Number(t)) > 300) return false; // 5 minute replay window
const expect = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expect.length === v1.length && timingSafeEqual(Buffer.from(expect), Buffer.from(v1));
}
Rules: reply 2xx within 10 seconds, do the work afterwards. A 3xx counts
as a failure. Deduplicate on id: a retried event keeps its id. Additive Inn
sends each event as soon as it happens, and retries on any non-2xx or
timeout no sooner than 1 min, 5 min, 30 min, 2 h and 12 h after each failed
attempt (retries run every five minutes, so allow up to five minutes more).
After the sixth failed attempt it gives up on that event and the order shows
webhookFailed: true on GET /orders/{id}; once your endpoint is healthy,
POST /orders/{id}/redeliver gives those events a fresh round. To resend
particular events even though they were delivered (say, to a webhook URL you
have since replaced), send { "eventIds": ["evt_…"] }: exactly those go out
again with their original ids, so a receiver that already has one ignores it.
Either way it answers { orderId, requeued, events, sent, delivered, failed },
where events lists each resent event's deliveredAt, httpStatus and error. Events are sent oldest
first but can still arrive out of order; trust the order object's status
from a GET over any single event if in doubt.
2.8 Rate limits
60 requests per minute per key. GET /price-list is cheap; do not call it
per order.
2.9 Billing
Money never moves per order. Every order is priced from the list at
received and that pricing block is what will appear on the invoice, with
two exceptions that always arrive as billing.billedCents and
billing.adjustmentNote: a SHIPPING line (an estimate until the courier is
booked; the invoice carries the actual fare plus S$2) and a RUSH charge
waived because a reprint of ours missed the promised date. Orders become billable at ready, are rolled into one Additive Inn
tax invoice per period (weekly to begin with), and the invoice is paid by
PayNow or bank transfer. Your account has a limit, enforced by the API: an
order that would push open exposure past it is stored and priced but waits at
received with a billing.hold until money arrives. Your customer has
already paid you, so the order queues, it never bounces.
What is live today: pricing, holds, the account, cancellation fees, the test
sandbox and every event marked for v1 below. The invoice run and payment
detection (invoice numbers on orders, pay links, automatic release when a
payment lands, order.invoiced, order.paid, dispute resolutions) go live
before your first live order; until then Additive Inn releases a live hold
by hand once your payment is confirmed.
All amounts are integer cents, SGD, ex-GST, unless the field name ends in
InclGst or is invoiceTotalCents. GST is 9% and appears on the invoice.
2.9.1 The billing block
Present on the POST /orders response, on GET /orders/{id}, and inside
data of every webhook.
"billing": {
"mode": "credit",
"terms": "net7",
"billable": true,
"billableAt": "ready",
"disputed": false,
"invoiceId": "2446071000009123456",
"invoiceNumber": "INV-000412",
"invoiceUrl": "https://books.zoho.com/portal/additiveinn/…",
"invoicedAt": "2026-10-04T23:59:59+08:00",
"dueDate": "2026-10-11",
"invoiceTotalCents": 47522,
"paid": false,
"paidAt": null,
"balanceCents": 47522,
"billedCents": 5300,
"adjustmentNote": null,
"hold": null,
"account": {
"creditLimitCents": 150000,
"effectiveLimitCents": 150000,
"retainerBalanceCents": 0,
"openExposureCents": 86000,
"availableCents": 64000,
"inProductionCents": 42000,
"needsInfoCents": 0,
"unpaidInvoicedCents": 44000,
"disputedCents": 0,
"unpaidInvoices": [ { "number": "INV-000412", "dueDate": "2026-10-11", "balanceCents": 43598, "payUrl": "https://…/pay/INV-000412" } ],
"overdueInvoices": [],
"suspended": false,
"mode": "credit",
"topUpUrl": "https://books.zoho.com/…/retainer/…",
"payUrl": null
}
}
| Field | Meaning |
|---|---|
mode |
prepay while the unsecured limit is 0 (month 1, and any time an invoice is more than 7 days overdue); otherwise credit. Informational; the hold rule below is the same in both. |
billable |
false when Additive Inn waives the order (goodwill, our error). A waived order still gets order.invoiced with billable: false so your ERP does not wait for a line. |
billedCents |
What this order contributes to the invoice. Equals pricing.subtotalCents unless a cancellation fee or a RUSH waiver applied. |
invoiceTotalCents |
The whole invoice including GST, so finance can match the bank debit. Only meaningful after invoicedAt. |
balanceCents |
Remaining balance of the invoice this order sits on; a short payment shows here. |
hold |
null, or the object in 2.9.3. Non-null means the order will not be accepted until it is null. |
account |
Partner level, identical on every order. availableCents = effectiveLimitCents + retainerBalanceCents - openExposureCents. |
2.9.2 GET /account
Returns the account block alone, with partner, currency and
priceListVersion. Counts toward the 60/min limit. Call it before a send if
you want to know whether the next order will hold; availableCents is the
number to compare with the order's expected subtotal. topUpUrl is a
retainer invoice (float) with a PayNow link: paying it raises
retainerBalanceCents by the ex-GST amount within 15 minutes and clears
holds oldest first. The float is charged 9% GST on receipt and is applied to
your invoices before any remainder falls due.
2.9.3 Holds
Rule, evaluated at POST /orders and again whenever money or status changes:
openExposureCents = inProductionCents + unpaidInvoicedCents (needs_info older than 5 working days and open disputes are excluded)
hold if openExposureCents + order.pricing.subtotalCents > effectiveLimitCents + retainerBalanceCents
"hold": {
"reason": "credit_limit",
"message": "Top up S$84.00 or pay INV-000409 (S$400.00 ex-GST).",
"releaseWhenCents": 8400,
"unpaidInvoices": ["INV-000409"],
"payUrl": "https://…/pay/INV-000409",
"topUpUrl": "https://…/retainer/…",
"etaIfReleasedNow": "2026-10-08"
}
reason |
When | What clears it |
|---|---|---|
prepay_required |
effectiveLimitCents is 0 and the float does not cover the order |
Open the hold's own payUrl (PayNow or card, for exactly what the float lacks), or top up on the partner page, or let auto top-up charge the saved card; for the first-article, pay the invoice named in message. Release is automatic within a minute of payment |
credit_limit |
Exposure would exceed the limit plus float | Pay payUrl (the oldest unpaid invoice) or topUpUrl; release is automatic within 15 minutes of a Stripe PayNow, next working day for a bank transfer |
overdue |
Any invoice is more than 7 days past due; can also appear at ready, in which case finished goods are not released for collection until paid |
Pay the invoices in unpaidInvoices |
Reserved for later, not sent in v1: capacity (a full production queue).
Paying without anyone pressing a button (20/10/2026). Two ways, and you can use both:
- Pay as you go. Every
prepay_requiredhold carries apayUrlof its own (https://www.additiveinn.com/api/partner/v1/pay/allin-studio/AIP-…?t=…). It needs no login: whoever opens it pays what the float lacks for the held orders, by PayNow or card, and they start. Show it on your admin screen next to the held order. Opened after the order is paid for, it goes to the partner page instead of charging again. - Auto top-up. On the partner page (
/partners/portal) save a card and set a floor and an amount (say: below S$100, add S$500). We charge the card when an order arrives that the float cannot cover, and on our five-minute clock whenever the balance drops below the floor; the order is answered already released, or you receiveorder.billing_releaseda moment later. Card only: PayNow cannot be charged automatically. A declined card stops automatic charges for a day (or until the card is replaced), and the held orders wait on theirpayUrl.
Rules you can rely on: a hold never changes pricing; the ETA clock starts
at acceptance and a hold pauses it, eta.readyBy is recomputed on release;
a hold is cleared by your payment, not by Additive Inn staff, including on a
Friday evening; test keys clear any hold automatically once 60 seconds have passed since
order.billing_hold (on your next read of that order or your account, or on
Additive Inn's five-minute tick, whichever comes first), so you can test both
events without anyone at Additive Inn.
2.9.4 What happens to money at each status
| Status | Money |
|---|---|
received |
Priced; billing computed; hold decided. order.billing_hold if held. |
accepted |
Only reachable with hold: null. Nothing billed. |
needs_info |
Nothing billed. After 5 working days the order stops counting toward exposure; after 15 working days it is cancelled with message: "stale_needs_info" and billedCents: 0. |
printing |
Point of no return. printedAt is written once, on first entry. Cancellation now costs 60% of the SKU unit price, no add-ons (see 2.9.6). |
uv_printing |
Cancellation now costs 100%. |
assembling, qc |
Nothing. qc → printing is the only backward transition, used for a QC reprint at Additive Inn's cost (order.rework). |
ready |
billable: true. order.billable fires with the pricing snapshot: treat it as your payout accrual, the same moment you pay a Studio Node. If the account is overdue the order also carries hold.reason: "overdue" and is not released for collection. |
shipped, completed |
No change. |
cancelled |
billedCents says what, if anything, goes on the invoice (0 before printing). The fee follows the furthest stage the order reached, so a cancel during a QC reprint costs what the finished work cost. Courier and rush charges are never billed on a cancelled order. |
| Invoice run | Every billable order not yet invoiced gets invoiceNumber, dueDate, invoiceTotalCents; order.invoiced fires per order. |
| Payment | When the invoice balance reaches 0, paid: true, paidAt set, order.paid fires per order; holds clear oldest first. A partial payment updates balanceCents only. |
2.9.5 Events
Two you must handle in v1: order.billing_hold and order.billing_released
(both carry the full billing block, data.status stays received). Show the
hold message and releaseWhenCents on your admin screen and do not resend
the order; it is stored and will proceed. Recommended in v1: order.billable
(your payout accrual), and order.rework with rework: true,
reworkReason: "qc_reprint" on the accompanying order.status_changed, which
means suppress the customer notification and allow the one backward move.
Finance-only, optional until v1.1: order.invoiced, order.paid,
order.dispute_resolved. All are signed and delivered exactly like the events
in 2.7.
2.9.6 Request changes and new endpoints
| Change | Detail |
|---|---|
POST /orders body: firstArticle: true |
Live key only, waives proof.approvedAt, one order (one line per SKU; use partnerOrderRef TAP-FA-1). Holds with prepay_required until the first-article invoice named in message is paid; once released it is paid work and never counts toward exposure. A second one is 409 first_article_done. |
POST /orders body: supersedes: "AIP-1042" |
Replaces a live order that has not reached printing: the old one is cancelled and the new one created, in that order. Pointing at an order that is already cancelled is allowed and simply creates the new one. Without it, a partnerOrderRef that matches a live, non-cancelled order returns 409 duplicate_partner_ref with existingOrderId. Use this for revisions instead of cancel + resend, so a missed cancel can never leave two live orders. |
POST /orders/{id}/cancel after printing |
409 already_in_production with cancelFeeCents and stage (printing = 60% of SKU unit price, no add-ons; uv_printing or later = 100%). Resend with { "confirm": true } to accept the fee; order.cancelled then carries billedCents. |
POST /orders/{id}/dispute |
Allowed on ready, shipped, completed within 5 working days of ready. Body: { "reason": "colour" | "damage" | "nfc" | "wrong_item" | "other", "message": "...", "photoUrls": [...] }. Sets billing.disputed: true, removes the order from exposure while open (up to 25% of the limit), and resolves with order.dispute_resolved carrying resolution: "credit_note" | "reprint" | "rejected", creditNoteNumber, creditCents. A colour that matches the manifest filament code is not a defect. The undisputed balance of the invoice stays due on time. |
GET /price-list |
Each SKU and add-on gains unitCentsInclGst for your margin display when the buyer is not GST-registered. |
fulfilment.shipTo.country |
Must be SG on additive_inn_ship in v1; anything else is a 422 validation_failed whose details carry unsupported_destination at fulfilment.shipTo.country. Every order is a supply to ALLIN CREATIVE in Singapore at 9% GST whatever the end customer's country. |
New errors:
| HTTP | code |
When |
|---|---|---|
| 402 | account_suspended |
An invoice is more than 21 days past due. Body carries unpaidInvoices and payUrl. Nothing is stored; resend after payment. This is the only money condition that refuses an order. |
| 409 | duplicate_partner_ref |
See supersedes above. |
| 409 | first_article_done |
A second firstArticle order. |
| 409 | already_in_production |
Now carries cancelFeeCents and stage. |
| 409 | not_disputable / dispute_window_closed / dispute_open |
A dispute on an order that is not finished, more than 5 working days after ready, or already under dispute. |
2.9.7 What to build
- Store the
billingblock onadditiveInn(addhold,billable,invoiceNumber,dueDate,paid,billedCents,disputed). - Handlers for
order.billing_holdandorder.billing_released; a held order shows the hold message,releaseWhenCents, the pay and top-up links, andetaIfReleasedNowin the admin UI, with no automatic resend. - Map
order.billableto your existing pay-the-maker step. - Suppress the customer notification when
rework: true, and let your state machine acceptqc→printingonce. - Optional but cheap: when
availableCentsonGET /accountis below one day of orders, surfacetopUpUrlto the admin (or pay it automatically if you hold a float). - Revisions use
supersedes; the cancel button afterprintingshowscancelFeeCentsand asks before resending withconfirm: true. - A dispute button on
ready/shipped/completedorders calling/disputewith photos.
2.9.8 Acceptance tests (test key), continuing section 7
GET /accountreturnscreditLimitCents: 50000andretainerBalanceCents: 0. Post an order whose subtotal exceedsavailableCents. Expect201,status: "received",billing.hold.reason: "credit_limit",releaseWhenCentsequal to the shortfall, thenorder.billing_holdand, one to five minutes later,order.billing_releasedwithhold: null(aGETon the order after 60 seconds releases it at once). Your admin screen shows the hold and then clears it without any resend.- Post the same
partnerOrderRefwith a newIdempotency-Keyand nosupersedes:409 duplicate_partner_refwithexistingOrderId. Repeat withsupersedesset to that id:201, andGETon the old id showscancelled. - Ask Additive Inn to move a test order to
printing, then call/cancel:409withcancelFeeCentsequal to 60% of the SKU unit price. Resend withconfirm: true:200, thenorder.cancelledwith the samebilledCents.
3. File conventions
3.1 Print files
One .3mf per line item, containing every 3D-printed part of that item
(including extra tiles, emboss pieces and the keychain base if ordered).
- Millimetres, Z up, each part already lying in its print orientation (the face that goes on the bed at Z = 0). Additive Inn slices with its own profiles; do not bake in supports, slicer settings or G-code. A plain 3MF from your generator is ideal. A Bambu Studio project 3MF is accepted; only the geometry and object names are used.
- One object per colour per part. An icon inset into a tile is two
objects,
tile1__…andtile1-icon__…, sharing the same world position so they drop into place when grouped. Same for embossed logo text on a plate. - Object names carry the colour:
<part>[<slot>][-<sub>]__<ColourName>__<HEX>with spaces removed from the colour name and the hex without#. Examples:body__Black__1A1A1A,tile2-icon__JadeWhite__FEFEFE,plate-emboss__Maroon__901820,backprint-emboss__Red__C82020. - The
print.objectsmanifest in the order lists every object by that exact name, withpartfrom:body,plate,tile,icon,sleeve,text,qrpanel,plate-emboss,backprint-emboss,keychain,card,frame. Additive Inn rejects an order whose manifest and 3MF disagree (missing_object), because that is how a wrong colour gets shipped. - Keep each item to 4 filaments where you can: that is what prints together
on one plate (one AMS). More is accepted with a
colour_countwarning; Additive Inn splits the parts across plates by colour, and if the design will not split it says so inneeds_infobefore printing. - Up to 200 MB per file. Larger:
413.
3.2 Inserts (NFC chips and magnets)
print.inserts tells the operator where to pause and what goes in:
type |
What | Notes |
|---|---|---|
ntag215-coin-25 |
25 mm NTAG215 coin tag | default for tiles and Tap Snap corners |
ntag215-sticker |
NTAG215 sticker | if your cavity is designed for a sticker |
magnet-d6x2 |
6 × 2 mm neodymium disc | tiles, stand slots, keychain |
magnet-ring-magsafe |
MagSafe ring | Tap Snap |
Additive Inn supplies the chips and magnets and the price list includes them
(consumables on GET /price-list). If Allin already buys them in bulk and
would rather supply, say so before the first-article run: the price list
switches that item to "partner" and the unit prices drop by the agreed
amount. Whoever supplies carries the stock-out risk.
pauseAtZmm is the height at which the cavity is fully formed and the print
must pause for the insert to go in, in the object's print orientation. If
polarity matters (magnet-in-tile must attract magnet-in-slot), say so in
print.notes; Additive Inn's default is north face up in tiles, south face
up in slots.
tiles[].url is written to the chip as an NDEF URL record. The tag UID is
reported back per slot when programmed. payment-qr tiles get no URL.
3.3 UV artwork
Full-colour 2D artwork is UV-printed straight onto the plastic. One file per printed area:
role |
Where | Typical area |
|---|---|---|
uv-logo |
face plate | 60 × 30 mm |
uv-backprint |
back of a Tap Bar | up to 80 × 100 mm (Max: 100 × 150) |
uv-tile |
face of one tile (custom QR, payment QR, UV icon) | 30 × 30 mm |
uv-qrpanel |
the Tap Bar QR panel | 50 × 50 mm |
uv-card-front, uv-card-back |
Tap Snap sides | 105 × 70 mm |
- PNG with transparent background preferred; SVG or single-page PDF accepted.
printAreaMm: [w, h]is required and is the physical size the file must be printed at. The pixel size should give 300 dpi at that size. For PNG files the dpi is checked: below 300 you get auv_low_dpiwarning, below 150 the order is refused. SVG and PDF are vector and not checked.- sRGB. No bleed needed; the plastic edge is the crop.
- White underbase: Additive Inn decides from the part's colour (dark part gets
a white underbase under the artwork so colours stay true). Override with
"whiteUnderbase": "always" | "never"on the file if you need to. - Payment QR tiles are always printed as black on a white underbase, the way your site promises, whatever colours the rest of the order has.
placementonbackprintmirrors your configurator's offset and scale so the print lands where the customer saw it in the proof.- Up to 10 MB per artwork file.
3.4 Hosting, integrity, retention
- Files are fetched by URL at
POST /orderstime and copied into Additive Inn's own storage. Your public Vercel Blob URLs are fine. The URL must be reachable without cookies or auth and must stay valid for at least 30 days. sha256is mandatory and checked on download.bytesis checked too.- Additive Inn keeps the files for about 13 months from upload, then deletes them. The proof image is kept for the same time.
3.5 Materials manifest (PUT /materials)
Additive Inn does not substitute filament. Whatever Allin's palette is made of, Additive Inn buys and keeps on the shelf, so the backend has to say exactly what that is: brand, product line and manufacturer colour code, for every colour a customer can pick, and separately for any part that uses something different (a matte, a silk, a translucent, a PETG for the tiles).
Send it once before the first order, and again whenever the palette changes:
PUT /materials
{
"materials": [
{ "filament": "Bambu Lab PLA Basic 10101", "brand": "Bambu Lab", "line": "PLA Basic", "code": "10101", "name": "Black", "hex": "#1A1A1A", "usedFor": ["body", "icons", "keychain"] },
{ "filament": "Bambu Lab PLA Basic 10100", "brand": "Bambu Lab", "line": "PLA Basic", "code": "10100", "name": "Jade White", "hex": "#FEFEFE", "usedFor": ["plate", "sleeves", "text"] },
{ "filament": "Bambu Lab PETG HF 33102", "brand": "Bambu Lab", "line": "PETG HF", "code": "33102", "name": "Black", "hex": "#1A1A1A", "usedFor": ["tiles"], "note": "tiles are PETG for the magnet press fit" }
]
}
filament is the key every order refers to; make it <brand> <line> <code>
so a PLA and a PETG of the same colour name can never be confused. The codes
in the examples throughout this document are illustrative (Jade White 10100
and Black 10101 are real Bambu PLA Basic codes; check the rest against the
spool label); the manifest you send is the truth. The response echoes the list with a status per entry: stocked, on_order with
an expected date, or unavailable with a suggested substitute for Allin to
approve. Allin's site says the products are recycled PETG while the palette
names are Bambu PLA Basic names; the manifest is where that gets settled.
The test key and the live key each keep their own manifest, so a staging backend cannot change what production orders are priced against. Declare it with the test key now: Additive Inn stocks from that list until the live key arrives, then send the same list with the live key. If your code is not ready yet, send the same table to Additive Inn by email as markdown (one row per filament: brand, line, code, colour name, hex, which parts use it, monthly spool estimate). It is the first deliverable of this integration, ahead of any code, because stocking takes about a week.
4. SKU and option codes
| SKU | Product | Tile slots | Has plate | Has backprint |
|---|---|---|---|---|
AS-TAPTILE |
Tap Tile | 1 | no | no |
AS-TAPBASE-2 / -3 / -4 |
Tap Base | 2 / 3 / 4 | no | no |
AS-TAPBOARD-2 / -3 / -4 |
Tap Board | 2 / 3 / 4 | yes | no |
AS-TAPBAR-2 / -3 / -4 |
Tap Bar | 2 / 3 / 4 | yes | yes |
AS-TAPBAR-3MAX / AS-TAPBAR-4MAX |
Tap Bar Max | 3 / 4 | yes | yes |
AS-TAPBAR-QR |
Tap Bar QR | 2 + static QR panel | QR upload | yes |
AS-TAPSNAP |
Tap Snap | 4 corner tags | no | UV per side |
AS-LIGHTBOX |
Mini 3D Image Lightbox (on request, not orderable yet) | 0 | no | no |
The Mini Lightbox is quoted on request rather than on the standard list
because it carries electronics (battery, LED, tap sensor). Additive Inn does
electronics assembly too. Send the bill of materials and the assembly steps
(or the kit, if Allin sources it) and it gets a price, a photo-panel file
role and an inserts convention of its own.
Add-on codes: EXTRA-TILE, KEYCHAIN-BASE, EMBOSS-LOGO, BACKPRINT-UV,
BACKPRINT-EMBOSS, TILE-UV, CUSTOM-ICON, CUSTOM-COLOUR,
TAPSNAP-UV-SIDE, RUSH. Only EXTRA-TILE, KEYCHAIN-BASE and
TAPSNAP-UV-SIDE are sent explicitly; the rest are derived from the item.
Built-in icon keys (3D-printed, iconMode: "print"): click, facebook,
giftbox, google, heart, instagram, lazada, linkedin, mail,
paypal, shopee, shopping-bag, spotify, telegram, tiktok,
website, whatsapp, wifi, x, youtube. Any of these can also be sent
as iconMode: "uv" with artwork if the customer wants a full-colour icon.
5. Mapping from the existing order fields
| Your field | Additive Inn field |
|---|---|
order reference TAP-456 |
partnerOrderRef; Idempotency-Key: TAP-456-r<n> |
slug (tapbar-2, tapbar-max + variant tap-bar-3-max / tap-bar-4-max) |
items[].sku per section 4 |
colour per part (body, plate, tiles, icons, sleeves, taptoconnect, qrcode) |
items[].colours (taptoconnect becomes text) |
| per-tile icon + link, custom QR upload, payment QR upload, custom icon URL | items[].tiles[] with iconMode and artworkFileId |
logo upload, logo_mode (2d / emboss), logo_emboss_color |
items[].logo (2d becomes uv) + a uv-logo file, or emboss geometry in the 3MF |
backprint_design, backprint_mode, backprint_emboss_color, backprint_offset_x/y, backprint_scale |
items[].backprint + uv-backprint file(s) + placement |
| extra tiles | addOns[] EXTRA-TILE with their own tiles |
keychain |
addOns[] KEYCHAIN-BASE |
quantity |
items[].qty |
delivery (standard / expedite), need-by |
fulfilment.priority (expedite becomes rush), fulfilment.needBy |
business_name, name, wa_number |
customer (send only what the label needs) |
street_address, unit, postcode |
fulfilment.shipTo only when mode is additive_inn_ship |
| the configurator capture / proof | proof.imageUrl, proof.approvedAt |
| Tap Snap front/back uploads | uv-card-front / uv-card-back files + TAPSNAP-UV-SIDE add-ons |
| Lightbox photo | photo-panel file role, once AS-LIGHTBOX is priced |
6. Implementation checklist
- Config:
ADDITIVE_INN_API_BASE,ADDITIVE_INN_API_KEY,ADDITIVE_INN_WEBHOOK_SECRETfrom secrets. A boolean feature flagADDITIVE_INN_ENABLED, default off. - Data model:
makeron the order (studio-node|additive-inn);additiveInn: { orderId, status, priceListVersion, pricing, eta, lastEventId, lastEventAt, webhookFailed, tiles[{lineRef, slot, tagUid}], tracking, sentAt, revision }. - Price list cache: fetch on boot and daily; expose it to the admin UI so the margin per order is visible before sending.
- Export: a function that takes an approved order and produces the 3MF
per line item with the object naming, the
printmanifest, the artwork PNGs atprintAreaMm, and the sha256 + byte count of each, uploaded to Blob as public. Fail loudly if any object the SKU needs is missing. - Adapter: build the request body, post with
Idempotency-Key, store the response. On409 price_list_stalerefresh the list, recompute the margin, and surface it to the admin rather than silently resending. - Outbox: a queue row per send attempt; worker with exponential backoff (30 s, 2 min, 10 min, 1 h, 6 h); the same idempotency key on every retry.
- Webhook endpoint
POST /api/additive-inn/webhook: read the raw body, verify,200immediately, enqueue; the worker dedupes onid, updates the ERP status, stores tag UIDs and tracking, and posts the customer notification you already send on status changes. Register it withPUT /webhookfrom each environment (test key for staging, live key for production), confirm withPOST /webhook/ping, and answerpingevents with200and no other effect. A daily job that callsPOST /orders/{id}/redeliverfor any order showingwebhookFailedcovers a long outage on your side. - Polling fallback: hourly
GET /orders?since=for orders in a non-terminal state; reconcile. - Admin UI: assign / send / resend / cancel buttons, status pill, ETA,
the pricing snapshot beside retail, a link to
additiveInn.url, theneeds_infomessage with a reply box that calls/notes. - Cancellation: before
printingcall/cancel; after, show the409and tell the admin to WhatsApp Additive Inn. - Revisions: an order edited after sending is a new
POST /orderswithsupersedesset to the oldorderIdand the revision bumped in theIdempotency-Key. No PUT exists. Once printing has started a revision is refused; talk to Additive Inn.
7. Acceptance tests (test key)
- Post a Tap Bar 2 with UV logo, one custom-QR tile, a UV backprint, one
extra tile, a keychain. Expect
201, statusreceived, pricing withBACKPRINT-UV,TILE-UV,EXTRA-TILE,KEYCHAIN-BASE, all filesstoredwithsha256Verified: true. - Post the same body with the same key:
200, identicalorderId. - Change
qtyand post with the same key:409 idempotency_conflict. - Post with a wrong
sha256:422withfile_checksum_mismatchat the rightpath. - Post a Tap Base 2 whose manifest omits
sleeve2:422 missing_object. - Post with
expectedPriceListVersionset to one more than the currentversionfromGET /price-list:409 price_list_stalewithcurrentVersion. - Ask Additive Inn to walk the test order
receivedtocompleted; assert your ERP status follows each webhook, tag UIDs land on the right slots, a replayed event is ignored, and a bad signature is rejected with401. - Cancel a fresh test order:
200. Ask Additive Inn to move one toprintingand cancel it:409 already_in_production. - Kill your outbound network mid-send, restore it, and confirm exactly one order exists at Additive Inn.
- Webhooks:
PUT /webhookwith your staging URL,POST /webhook/pinganswersdelivered: true, and your endpoint verified the signature. Change one byte of a captured body and confirm your endpoint rejects it with401. Point the URL at something that answers500, trigger an event (post a test order), confirm it is retried within about six minutes, then restore the URL.
8. Do not
- Do not send payment details, card data, or the customer's email or address
unless
fulfilment.modeneeds the address. - Do not send unapproved orders on the live key.
proof.approvedAtis required there for a reason. - Do not compute prices locally and treat them as final; the response is the price. Local numbers are for the margin display only.
- Do not put the API key in client-side code or in URLs.
- Do not build an MCP server for this in v1. It is a server-to-server REST integration. If your development AI wants to explore the API interactively, a plain HTTP client against the test key is enough.
9. Confirm with Additive Inn before go-live
- Keys: you receive the test key and the webhook signing secret first; the live key follows once the test run in section 7 passes and the first-article invoice is settled. Register your webhook URLs yourself (section 2.7).
- The materials manifest (section 3.5), sent early: stocking takes about a week, and nothing prints until the spools are on the shelf.
- Consumables: Additive Inn supplies the NTAG215 coins and magnets unless Allin says he would rather supply them in bulk.
- Fulfilment default for the first orders (your courier collects from Additive Inn, or Additive Inn ships) and whose packaging.
- The pause heights (
pauseAtZmm) and insert types for each SKU, from your actual geometry, before the first-article run. - Whether the Mini Lightbox joins the list, and if so its bill of materials.
- Billing (section 2.9): the partner account, the month-1 float, Net 7, and the two hold events your admin screen must show before the first live order.