Appearance
Changelog
Every change to the public surface, newest first, one entry per change, additive ones included. The current train is 2026-11, and every key is pinned to it at mint. A breaking change never lands on a published train, so an entry below either opens a new train or needs nothing from you. See Versioning for what counts as breaking.
Train 2026-11, current
2026-09-13
- Added whole-book account evidence (
GET /api/v1/commitments/{commitment_id}/accounts), with bounded account pages, separate cohort/reporting dates, reporting coverage and explicit missing values. Existing per-style reads remain available.
2026-09-12
Changed
Record bookings(POST /api/v1/order-book/lines) now refuses JSON bodies above 1 MiB with HTTP 413 before writing. The existing 1,000-line maximum remains; use the file-import endpoint for larger books.Added optional
categoriesandmonthsto each folded commitment in Ledger plan-side (GET /api/v1/mfp/{mfp_id}/plan-side). These record the category and fiscal-month coverage used to verify complete locked-budget comparisons; missing coverage stays unavailable. Existing callers need no change.Added
plan_of_record.forecast_noteand per-commitmentforecast_sourceto Ledger plan-side (GET /api/v1/mfp/{mfp_id}/plan-side). The source distinguishes live forecasts from signed consensus and includes the recorded version, cycle and signing time where present. Budget metadata remains separate. Additive; no action needed.
2026-09-11
Added exact
period_axiskeys and bounds to sell-out coverage and doors. Coverage also serves each account's own cadence axis. Unambiguous weekly cell keys keep their ISO start date; monthly and same-start/different-end weekly keys contain both dates, preserving overlapping weekly and monthly reports without inventing weekly values. Callers displaying monthly feeds should useperiod_axisto address reported cells.Added optional
counts_onlyonlist_suppliers(GET /api/v1/inventory/suppliers/table). Set true to return filtered and whole-book counts without supplier rows or delivery scoring. Counts ignore pagination,rowsis empty andsizeis zero. It cannot be combined withfor_zapier. The default remains false; existing callers need no change.Added optional
commitment_idto Bench conversion. Naming a commitment limits the operation to its assigned items, including future seasons; converted orders retain that commitment. Omit the field to retain existing behavior.Added optional
include_plan_of_recordonget_plan_side(GET /api/v1/mfp/{mfp_id}/plan-side). It defaults to true; existing callers need no change. Set false to read the saved MFP plan without waiting for commitment money grids. The four unread commitment cost lines are null with a not-requested explanation at every grain.
2026-09-10
Added
company_declarationonOne commitment's plan of record at cost, on its own fiscal months(GET /api/v1/commitments/{commitment_id}/money), scopeplanning:read. The finance lead's own cover target, in weeks, and markdown rate, in basis points, for the plan's fiscal year, served once for the whole grid rather than per category or month. A category's plan cell may hold a different figure of its own; this block is how a caller reads what the company declared beside what a cell states. Either figure isnullwhere the company has declared neither, on a boxed commitment and on a rolling one alike. Additive; no action needed.Added
draftedandreceivedper month, per category, onOne commitment's plan of record at cost, on its own fiscal months(GET /api/v1/commitments/{commitment_id}/money), scopeplanning:read. The order book now arrives in three bands on the same fiscal months, in the same integer cents at cost, off one scan and under one refusal.on_orderis unchanged: placed purchase-order money at its expected delivery month.draftedis purchase-order money still in draft at the same grain, written up with no envelope consumed, and its own band because a draft is not an order.receivedis the delivered subset of the placed lines at the same expected delivery month, so it is the landed slice of that month's on-order figure rather than an actual-arrival series, and it is never more thanon_orderfor a month. A month the book was read for that holds nothing in a band is a measured0; all three bands arenulltogether, with the reason on the category, where the book cannot answer for the scope at all. Additive; no action needed.Added
baseline.by_door[]to every style onWhat this account did last time, and how each style is likely to do now(GET /api/v1/order-book/account-anchor), scopeorder_book:read. The baseline engine now predicts per DOOR, and each row carriesdoor_id,name,share,unitsandrung, the word for the evidence the figure stands on.measuredis the door's own history of the styles the grade was measured on, taken at the rate it sold them while it was trading: corrected for the days its shelf was empty, and for the weeks it never reported at all.category_averageis a door that reported none of those styles but does sell the category;account_averageis a door that reported neither, which where nothing was measured at all is the level divided by the doors on file;not_measuredis a door on a style the engine could not size, and it carries no figure at all. Theunitsadd up to the style's ownlevel_unitsexactly, so the door column and the figure above it cannot disagree. Each row also carries the door's own evidence:sold_last_seasonas the account reported it,sold_correctedwith the shelf accounted for,out_of_stock_days,availability,weeks_reportedandstyles_reported, because a figure lifted by a third for a shop that was dark for eight weeks has to be able to say so, andavailability: nullsays no week at that door carried a shelf reading, so the units are taken at face value.by_door: []is a real answer and the commonest one on an account that reports a single total: it has no door to break down. The key ABSENT means the split could not be read, the same distinctionbaselineitself draws one level up. Additive; no action needed.Added
predicted_qty,doors_reportinganddoors_on_fileto every row ofEvery account's book against its own prior season, read at the same days before start(GET /api/v1/order-book/accounts), scopeorder_book:read.predicted_qtyis what we expect that account's doors to sell, summed over the styles sized for it, so ordered and predicted sit on one row: this is the read that answers which accounts have under ordered against their prediction, by comparing it withbooked_qtyand ranking by the difference.commitment_idnarrows it to one Commitment's own sizing. It is null where the sizing was read and the account carries none, and the key is absent where it could not be read at all; it is never 0, because a zero would say we expect that account to sell nothing.doors_reportinganddoors_on_fileare how many doors that prediction stands on, out of how many are open.doors_reportingis the engine's own count, written when it grades, so it is the measured estate rather than the whole feed, and it is null where nothing has graded the account;doors_on_fileis a count of the account's open doors, and no door on file is what an account reporting one total looks like. Additive; no action needed.Added
predicted_qtyto every row ofOne account's book, style by style(GET /api/v1/order-book/account-styles), scopeorder_book:read: the same figure per style, beside what that account has booked of it. Null where the style is unsized, absent where the sizing could not be read, never 0. Additive; no action needed.Added
ordered_qtyto every style onWhat one account did last time, and how it is likely to do now(GET /api/v1/order-book/account-anchor), and Changedbaseline.by_door[]on the same read so every row states its source, scopeorder_book:read.ordered_qtyis this season's live booked units for the same account and style, read off the same statement as the prior figures, so the gap against the prediction is one subtraction on one row rather than a crossing of two reads taken at two instants; a style in scope with no live line carries 0 and not null, because the book was read and taking none of a style is the reading.baseline.by_door[]delivers the account's level to each door it has open, one row each carryingdoor_id,name,door_reference,units,share,rung,rung_labelandsource, wherenameis the buyer's own key for the same field so one shape answers on both chairs and on the engine's own per-door rows.rungismeasured(the door sold the styles the grade stood on),category_average(the engine's alone: it reported none of them but does sell the category),account_average(the door is on file and reported none of them, so it takes the account's average door) ornot_measured(the account has no sized level, so every door carries a null figure and never a zero). The rows sum tolevel_unitsby construction.sourceisaccount_spliton every row today, the account's level divided at read time; the engine's own per-door figure carriesdoor_baselineon the same key, with that door's own evidence beside it, and it is served wherever the engine has rows:account_splitanswers only for a graded style the per-door pass has not reached yet, so a reader can tell a corrected figure from a divided one. An account reporting one total has no doors on file andby_dooris empty. The key is ABSENT, withby_door_reasoncarrying the sentence, where the split could not be read at all: the grades stand either way, because the split is an addition on top of figures that are already complete. Additive; no action needed.Changed the sell-out figures on
What one account did last time, and how it is likely to do now(GET /api/v1/order-book/account-anchor), scopeorder_book:read:prior_sold_qty,prior_available_qty,prior_oos_daysanddoorsnow count only what the account itself reported. Aderivedrow is our own arithmetic on our own shipments carrying the account's name, and this read counted it, so a brand whose feed holds derived rows was shown its own shipments as what its shoppers bought. Every other reader of that data already filtered it. FIGURES CHANGE on any account with derived sell-out rows, and they change downward; nothing else on the response moves.Added
product_id,variant_id,family_idandcategorytoOne account's sell-out by door, the grain a plan is actually delivered at(GET /api/v1/sell-out/doors), scopesell_out:read, all optional and composing with AND.variant_idis the SKU,product_idthe style,family_idthe product family the style joins, andcategorythe catalogue category, matched on its id or on its name, exactly and case-sensitively ("knitwear" does not find "Knitwear"). Every per-door figure narrows with them,by_weekincluded, and all four are echoed on the answer so a narrowed read is tellable from a wide one. The door list is the same list under every filter: a door with nothing in the scope keeps its row and carries a measuredunits: 0, withon_handandsell_throughnull, since nothing in the scope was ever counted on that shelf.scope_reasonsays why a narrowing could not be made, or null: a catalogue with no product families cannot answerfamily_id, and a filter that matched nothing looks exactly like one this catalogue cannot answer once every figure is a zero. Unstated, nothing narrows and the answer is byte for byte what it was. Additive; no action needed.Added
by_weekto every door row and toaccount_totalonOne account's sell-out by door, the grain a plan is actually delivered at(GET /api/v1/sell-out/doors), scopesell_out:read. The weeks behind each figure, keyed by the week's own start date, each cell holdingunits,on_handandvariants. A week the account did not report is absent from the map rather than present as a zero, because a dead week and a week nobody sent are different facts; a week that carried a shelf count and no sales figure is present with a nullunits. A cell'sunitsadd up to the row'sunits, and a cell'son_handadds up to nothing, since the row's ownon_handis each variant's last reading. An account reporting one total carries the map onaccount_total, which is the only week series it has. Additive; no action needed.Changed the
planblock onOne commitment's plan of record at cost, on its own fiscal months(GET /api/v1/commitments/{commitment_id}/money), scopeplanning:read: each month undercategoriesnow carries what has actually been MARKED DOWN beside what was planned.realised_markdownis at cost, the plan's own basis, and is the only pair that may be subtracted fromplanned_markdown_to_close_cents;realised_markdown_at_retailis what the season handed over at the till, beside it and never subtracted from a figure stated at cost.markdown_left_to_close_centsis that subtraction, withmarkdown_left_to_close_reasonwhere it cannot be made.realised_markdown_unitsandrealised_markdown_to_date_centsround out the pair. A month still ahead carries null and never a zero, andrealised_markdown_reasonsays why a whole row has none - a season that has not opened, a channel scope, or a read that could not be taken. Additive; no action needed.Added
line_scopetoThe lines of one count, with the difference(GET /api/v1/stocktakes/{stocktake_id}/table), scopestocktakes:read.allreturns every line in the count,differencesreturns only counted lines whose count differs from system stock (leaving out both the matched lines and the uncounted ones), anduncountedreturns only the lines nobody has counted yet - the work left to do on a count part way through. Additive; no action needed.Deprecated
variance_onlyonThe lines of one count, with the difference(GET /api/v1/stocktakes/{stocktake_id}/table), scopestocktakes:read, in favour ofline_scope=differences.variance_only=truestill works, honoured whereline_scopeis not given; no action needed yet.
2026-09-09
Added
Fill this sandbox with a starter catalogue so every other operation has something to answer(POST /api/v1/sandbox/seed), scopesandbox:write, and with it a new resource, Sandbox, on the reach matrix. It writes a small starter set into the sandbox the key belongs to: two locations, two sales channels, two suppliers, three products with two variants each, and a stock level for every variant at both locations. Until now a sandbox arrived empty and the only thing that could fill it was a staff command, so a key that worked read a catalogue with nothing in it. It answers200whether it wrote or not, withseededandalready_seededsaying which andwrittencounting the rows this call inserted, so it is safe to call before every run. Nothing is truncated, updated or deleted. An organisation that is not a sandbox is refused409and nothing is written. Additive; no action needed, and a key minted before today needs Sandbox ticked to reach it.Added
One account's booked lines folded into its own size shares(GET /api/v1/order-book/account-size-curve), scopeorder_book:read. One account's booked lines folded into its own size shares in basis points summing to 10000, beside the buy's own curve.trading_partner_idis required, and so is one ofseason_codeorcommitment_id. Where the account has no booked line in scope, or every line it has names no size,shares_bpis null with areasonrather than an empty object. Additive; no action needed.Changed
inventory_valueonOne product with its stock and the ranges its variants span(GET /api/v1/product/{product_id}) andOne variant with its stock and its suppliers(GET /api/v1/variants/{variant_id}), scopeproducts:read: the stock at cost is null when no cost is on file for the variant or its product. It read0before, which looked like a real figure. Readers that arithmetic on the field must treat null as absent.Changed the 200 description on
The open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate), scopecash:read, soplanned_out_coveragenamesmeasuredbesidefolded: what the cap bounds is the money this read performed, folded plus the cells that ran and then failed, so it is never smaller thanfolded. The field was already on the wire (#7641); the committed spec had not been regenerated with it. Additive; no action needed.
2026-09-08
Changed the response example on
List purchase orders with pending supplier updates(GET /api/v1/organizations/{organization_id}/purchase-orders/with-supplier-updates), scopepurchase_orders:read, to the shape the route actually serves. It showed a paged table,offset,size,filtered_max_sizeand arowsarray of orders each carrying a nestedsupplier_updateofkind,proposed_delivery_date,detected_atandsource. No field of that name has ever been served here: the answer isitems, one entry per pending signal withpurchase_order_id,display_name,signal_typeandreported_at, besidetotal_count. Anyone who built against the example rather than the schema built against a body that has never arrived. The schema in the reference was right the whole time and has not moved, so this changes no contract; it corrects the picture printed beside it.Changed the reference entry for that operation so it names the row. Every entry in
itemsnow has its four fields spelled out,signal_typeis enumerated (reschedule,change_quantity,ship,cancel,confirm,change_price),reported_atis stated to be the source email's own timestamp and null where that email carried no date, and the tiebreak under the email-date ordering is written down. Additive; nothing on the wire moved.Changed the reference entry for
One product with its stock and the ranges its variants span(GET /api/v1/product/{product_id}), scopeproducts:read, so the two absence codes are readable.size_curve.fallback_reason_codewas named without its values; all seven are now printed on the 200 (no_stock_history,a_run_size_never_observed,a_sold_size_outside_the_run,run_broken_every_day,too_few_clean_days,too_few_clean_units,one_size_sold), withsize_curve.fallback_reasonstated to be that code in words and safe to render as it stands.price_band_reasonwas not named in the reference at all and now is, with itsreasonofno_category,no_price,category_too_thinorno_spread. Both were served already; only the page changed.Changed sixteen more reference entries that were each missing a fact about their own answer. A specification carries two prose blocks and the public build publishes one of them, so anything written only in the other reached no reader and no gate could tell, because the generated file agreed with itself. What moved across, by operation: the states a write is allowed from and the states a document passes through on
cancel sales order,update sales order,confirm sales order,invoice a sales orderandsend shipping notice(/api/v1/sales/orders/{order_id}and itscancel,confirm,invoiceandshipping-noticedoors), scopesorders:write; the three states served apart onget an invoice(GET /api/v1/sales/invoices/{invoice_id}) and the refund leg's null onget customer(GET /api/v1/sales/customers/{customer_id}), scopeorders:read; the soft reservation and the two figures behind a mastered hard total onOne product's position at a warehouse, decomposed(GET /api/v1/inventory/position); the slice paths and the unmodelled remainder onget forecast accuracy(GET /api/v1/inventory/forecast-accuracy); the optional mapping targets onimport returns(POST /api/v1/sales/returns/import); the twonot_matchedkeys spelled where they live on both sell-out doors (POST /api/v1/sell-out/importand/api/v1/sell-out/preview); and the sibling to call instead onopen a purchase order,Net sales at category × channel type × period,Stock on hand at each period boundary,How much of this book can still walkandRecognise a retailer's report and say what would happen, writes nothing. Every one of these was already true of the answer you receive; none is a new field, a new value or a new refusal, and no client needs to change.Added
variant_unit_cost_missingto the constraint keysCreate purchase orders from the bench(POST /api/v1/organizations/{organization_id}/purchase-orders/from-basket), scopepurchase_orders:write, can refuse with, and changed the sentence inmessage.descso it names the reason. A bench line whose variant has no unit cost on file anywhere used to be valued at zero, which dragged the prospective order's total down and produced asupplier_min_order_valueviolation short by up to the whole of the supplier's minimum - an order-scoped refusal withline_id,variant_idandvariant_titleall null, so nothing on the page could be pointed at and no remedy existed. An unknown cost can only ADD value, so an order below the minimum on its priced lines alone is not KNOWN to be below it. The refusal now names the unpriced LINES instead, one violation each, carrying all three identifiers;limit,actualandshortfallare1,0and1ineaches- a count of unit costs owed, not money, because the amount such a line is worth is precisely what nobody knows. An order whose PRICED lines already clear the minimum is no longer refused at all.unit_coston a created line may now benullrather than0, which the field has always allowed. Additive:scope,unitandboundtake no new values, every violation still carries the same sixteen keys, and a client switching on the keys it knows is unaffected.keyis now declaredx-extensible-enumrather thanenum, with the same four values. A bracket is one row of a table in this codebase, and weight, cube, pallet-fill and truck-fill arrive the moment a column exists to read them, so a closedenumwas the wrong promise: it made every future bracket a breaking change, which is how a set that is meant to grow ends up undocumented instead. Switch on the keys you know and fall back to the violation's ownlabelfor one you do not. Nothing has been removed or renamed, and nothing will be without a new date train.Changed
not_matchedonRecognise a retailer's report and say what would happen, writes nothing(POST /api/v1/sell-out/preview), scopesell_out:write, so it reports what the import will actually do. It resolved nothing at all: the call recognised the file's format and stopped before the catalogue, so every response came backtotalequal torows_readwith an emptyby_reason- a file every row of which matches was reported as matching none of them, with no reason beside the count. It now runs the resolverLand a retailer's own sell-out report against one named account(POST /api/v1/sell-out/import) runs, over the same rows, soby_reason,by_reason_words,examplesandtotalhere are what that import then reports on the same bytes. A caller who readnot_matched.totalas "rows that will not land" was readingrows_readand now gets the real figure; one who treated an emptyby_reasonas normal will start seeing keys. No field was added, removed or retyped, androws_writtenandrows_changedstay 0: the call still writes nothing.Changed the published
by_reasonexample on the same operation. It showedunknown_sku, which no code path has ever produced, so anyone who built a branch or a fixture on that token built one that never runs. The five the resolver actually emits arematched,matched_on_sku_only,invalid_barcode,unknown_productandno_identifier, and the reference for both sell-out operations now names all five and says they are counted for every row, so they sum torows_read. The import's own example moved with it: it showed 63,034 rows written out of 63,034 read with 118 unmatched, three figures that cannot all be true at once, and itsexamplesentry was a SKU under a reason that only ever names a barcode.Changed what
Land a retailer's own sell-out report against one named account(POST /api/v1/sell-out/import) returns innot_matched.exampleswhen nothing in the file matched. That refusal named no values at all, so the one report where the count carries no information carried no examples either. Additive; the field was already there and was empty.Added
stockout_days_inferredto the variant rows ofGet sales table(GET /api/v1/sales/table), scopesales:read.lost_sales_unitsandmissed_revenuehave always rested on a count of days a line held nothing anywhere, and every one of those days used to be a snapshot: the archive looked, and the shelf summed to zero. An organisation whose history has been backfilled also carries days that nobody snapshotted, read out of the order stream because the line sold, went silent for longer than its own selling cadence explains, and sold again. Those are evidence and not measurement, and the total said nothing about how much of it was which. This is that count. Present it as inferred and never as measured. The window's stock-out days arelost_sales_dated_days + lost_sales_latest_days, so the part that was actually observed is that sum minus this field. It reads 0 wherever every day was snapshotted, which is every organisation whose history has not been backfilled, so a caller that ignores it reads exactly what it read before. Additive; no action needed.The two counts beside it,
lost_sales_dated_daysandlost_sales_latest_days, answer a different question and are unchanged in meaning: they say how each day was PRICED, not how it was established. A day can be inferred and still priced at a dated rate, and often is.Added the money an account reported, and the units that came back, to
Sell-out by door(GET /api/v1/sell-out/doors), scopesell_out:read. Each door and the account total now carryvalue_sold_centswith thecurrencyit is in, andreturned. Additive; no action needed.value_sold_centsis what SHOPPERS SPENT at that door over the window, in whole cents - divide by 100 and show the code beside it. It is the account's takings at their own shelf price, so it is larger than what they paid you and it lands in a different period.returnedis units brought back and is not subtracted fromunits: a return lands in the week the shopper walked in rather than the week they bought, so a window can carry more returns than sales.Most accounts report neither, and the response says so in words rather than in zeros. Measured across nine real retailer exports, four report no value and eight report no returns. Where an account has never sent one the figure is
nullandvalue_absent/returns_absentcarry the sentenceNot reported; reading a null as0would claim shoppers spent nothing. Where a window spans two currencies,value_sold_centsis withheld andvalue_absentsays why - adding pence to cents is a number with no unit, and nothing here converts between them.Added
reorder_rulestoImport a sell-out file(POST /api/v1/sell-out/import), scopesell_out:write. Where the uploaded file also carried the retailer's OWN reorder policy,readsays how many (account, variant) rules landed andnot_statedhow many of their rows could not state one. Additive; no action needed.It is
null, not a zero, for an account that sends no policy sheet at all, which is almost all of them: measured across nine real retailer exports, one does. A0on every import would read as a failure of ours rather than as a thing that account does not do, so absence is absence, and a zero beside a refusal count means their sheet was there and none of it landed.Added
weekstoThe open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate), scopecash:read. An optional whole number saying how many weeks of the ladder to serve, from the first. It narrows the ROWS and never the arithmetic: a week's balance stands on every week before it, so the ladder is computed over the whole year exactly as before and the slice is taken on the way out, and the figures a caller asking for four weeks sees are identical to the ones it would see asking for all of them. A newserved_weeksblock says what was served out of what, and every figure outsideweeksstill speaks for the whole year. Omitted, the answer is byte for byte what it was. Additive; no action needed.Changed what
Get a specific sales velocity event by ID(GET /api/v1/sales/velocity/events/{event_id}), scopesales:read, says aboutexclude_from_training. The reference ended on "The mark cannot be set at all", which was the reference being wrong rather than a fact about the field. A planner sets the mark when the event is created or edited in Tightly, and it is the one thing that makes a window already in the past worth recording at all. What is true of an organisation whose events table cannot record the mark is narrower and now says so: the field reads false there, and a save asking for the mark is refused rather than saved without it. No operation in this reference sets the mark. Nothing on the wire moved.Added
exclude_from_trainingto the reference forRetrieve paginated table data for sales velocity events(GET /api/v1/sales/velocity/events/table), scopesales:read. It rides on every row of that page and was written down nowhere, so a developer parsing the page met a boolean the reference had never mentioned. It says whether the baseline has been told not to learn from that window, and it reads false on an organisation whose events table cannot record the mark. The example row now carries it. No shape moved.Changed the worked
baselineexample onWhat one account did last time, and how it is likely to do now(GET /api/v1/order-book/account-anchor), scopeorder_book:read, so it shows values this read can actually answer. The published example carriedbasis: measured_at_accountandgrade: B, neither of which is a value the grade is ever stored as, under the keysdoorsandweekswhere the answer carriesdoors_reportingandweeks_reported, above a sentence in a shape the read does not compose. Anyone who built a fixture or a switch from that page built it wrong. The example now shows two styles, one graded from the account's own record and one borrowed from our analog, with the sentences the read composes for them.Added the provenance pair to the reference for
What one account did last time, and how it is likely to do now(GET /api/v1/order-book/account-anchor), scopeorder_book:read. Both fields ride on every graded style and neither had its values written down, so no caller could switch on either.basisis the rung the grade was read from, one ofaccount_predecessor,account_kit_member,account_cohort,account_family,brand_analog,sell_in_onlyornone, in ladder order: the first rung with enough reported weeks at that account votes alone.evidenceis how far to trust it, one ofmeasured,uncorrected,borrowed,brandornone. It isevidenceand notbasisthat carries the borrowed against measured label, since one clean predecessor of the account's own doors is a measurement and several blended is a borrowing while the figure looks identical either way.gradeiswinner,sleeperorbleeder, andstateisgraded,magnitude_only,brand_fallback,sell_in_onlyorrefused. Nothing on the wire moved.Changed
expected_onon every return header from English words (Sep 12, 2026) to a plain calendar date (2026-09-12). A caller parsing the English has to move to the date. It is the day a return is expected back, so it stays a date and does not become an instant. Five published operations carry a return header and all five moved together:List returns(GET /api/v1/sales/returns) andGet return(GET /api/v1/sales/returns/{return_id}), scopereturns:read, andCreate return(POST /api/v1/sales/returns),Receive return(POST /api/v1/sales/returns/{return_id}/receive) andCancel return(POST /api/v1/sales/returns/{return_id}/cancel), scopereturns:write.Create returnalready ACCEPTED2026-09-12on the way in and answeredSep 12, 2026on the way out, so the same field had two shapes on one round trip.received_at,created_atandclosed_atbeside it are unchanged instants.Changed
variant_idsonGet allocation matrix(GET /api/v1/inventory/allocation-matrix), scopeinventory:read, to accept up to 50 ids rather than 5. A style routinely has more than five lines, and asking for its whole grid was a 400 -[0]: Maximum 5 variant IDs allowed- so no caller could read a normal product in one request. The read answers in two set-based queries whatever the count. Repeated ids are also de-duplicated now, first mention keeping its place, where the same id twice used to return the same variant twice. Additive; no action needed, and a caller already batching at five may keep doing so.Changed
contracted_outonGet the cash gate(GET /api/v1/commitments/cash-gate), scopecommitments:read, to be NULLABLE, and addedcontracted_out_sourcebeside it. Additive for a caller that already treats a week's figures as absent-or-present; a caller that assumed a number there has to readnullas "not measured". Two legs feed that figure and both can be silent - the open buy's lag placement speaks only for the buy whose supplier terms are on file, and the freight only where a container plan quotes one - and served as0.0in that state the ladder said the book takes nothing out of the bank all year beside an open buy of millions.contracted_out_sourcesays once which leg spoke:placed,freight,placed+freight,no_open_buy, ornullwhere neither could, which is the only reading in which the weeks are null too.no_open_buyis the opposite reading and a measured zero: the order pad is empty, so nothing leaves the bank on that line.Added
splitto every row ofList purchase orders(GET /api/v1/organizations/{organization_id}/purchase-orders), scopepurchase_orders:read: where that order's units stand, asrecorded,recommendedornowhere_to_go. It isnullon an order with no products, because there is nothing on it to split, which is an absence rather than a fourth state, so a caller must not read the null as a value. Measured on the split already RECORDED against the order and never on a recomputed recommendation: an order nobody has split yet readsrecommendedeven where its recommendation would leave units with no destination. Additive; no action needed.Added
row_grainto the response ofGet sales velocity table(GET /api/v1/sales/velocity/table), scopesales:read. It says what grain the rows you were served are,variantsorproducts. It is a property of the envelope rather than an echo of thetypeyou sent, so a caller can check the answer against its own request instead of inferring the grain from which counter is present. Additive; no action needed.Added
product_titleto every style onList account styles(GET /api/v1/order-book/account-styles), scopeorder_book:read. The catalogue's name for the style, falling back to the variant's own, andnullwhere neither carries one - a name is said or absent, never built out ofstyle_ref, which is a key and reads as one. Additive; no action needed. Until now this read served no name at all, so a caller drawing an account's book had to join it against a second read to print anything but a reference.
2026-09-08
Changed
rateonGet returns summary(GET /api/v1/sales/returns/summary), scopereturns:read, torate_points, and the number it carries from a fraction to POINTS:2.4is 2.4%, where the oldratewould have said0.024. A caller readingratehas to move torate_pointsand stop multiplying by 100. The unit is in the name deliberately: served as a barerateholding a fraction, the one percent formatter a face reaches for printed0.02%- a hundredth of the truth, and plausible enough that nobody queries it.rate_pointsis null withrate_reasonbeside it where nothing shipped in the window, exactly asratewas: a return rate over no shipments is not 0%. The read also gainsas_of, the instant the two counts were read, so a figure on a page can say when it was true rather than falling back to the reader's clock.Changed the page
List sales orders(GET /api/v1/sales/orders), scopeorders:read, answers, to carry the whole house paging shape:offset,sizeandmax_sizenow sit beside thefiltered_max_sizeit already served, andas_ofstates when the page was read.filtered_max_sizeis what the filters counted andmax_sizethe book behind them, so a caller handed a page can always tell it from the whole of it. Additive; a caller that ignores the new keys reads exactly what it read yesterday.Changed
created_atandlifecycle_updated_aton every order row from English words (Sep 5, 2026) to ISO 8601 instants (2026-09-05T09:12:00+00:00). A caller parsing the English has to move to the instant. The words were also losing the time of day, so two orders updated four hours apart sorted as a tie; the instant carries it. A field is a machine value and the words were only ever right in a sentence a person reads. One order row is served by three published reads and all three moved together:List sales orders(GET /api/v1/sales/orders) andCreate sales order(POSTon that same path), scopesorders:readandorders:write, and theorders[]an account carries onGet customer(GET /api/v1/sales/customers/{customer_id}), scopeorders:read. The reference published the words for the last two until today, which was the reference being wrong rather than a second change.first_order_atandlast_order_aton the customer itself are NOT affected: they are a day a person reads and they stay words.Added
needs_you, an optional boolean query filter, toList sales orders. True narrows the book to the orders somebody has to answer for: a wholesale draft nobody has confirmed, or an order carrying an open exception. It is not expressible as alifecycle, which is why it is a filter of its own, and it is served rather than narrowed by the caller so that it counts the same book the header'sexceptions_opencounts. Additive; no action needed.Changed
limitonList sales orders, maximum from 100 to 500, and above the maximum the read is now REFUSED rather than trimmed. A caller handed 500 of 4,000 rows with no warning has wrong data, andfiltered_max_sizestates the book's true size beside the rows so the two can never disagree silently. Every request that was legal before is still legal; no action needed.Changed the
movements[]entries onGet return(GET /api/v1/sales/returns/{return_id}), scopereturns:read, andReceive return(POST /api/v1/sales/returns/{return_id}/receive), scopereturns:write.unit_cost_centsandcurrencyare replaced by a singleunit_costmoney object -{cents, currency, usd, reason}- which is the shape every other money on the wire already has, and which can carry the sentence saying why a cost is absent instead of leaving a bare null nobody can explain.sku,variant_titleandlocation_nameare served beside the ids they belong to, so a table cell prints a SKU and a warehouse rather than two opaque keys. A caller readingunit_cost_centsorcurrencyhas to move tounit_cost.Added
retail_price_amountto each purchase order line, onGet purchase order(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}, scopepurchase_orders:read),Update purchase order(PATCHon that same path) andDuplicate purchase order(POST .../duplicate), the last two scopepurchase_orders:write. All three answer with the same order object, so all three carry it. It is the retail counterpart of theunit_costalready on the line, read off the line's ownpurchase_order_line_items.retail_price_amountand not off whatever the variant happens to be asking today - a figure struck months after the buy was made and wrong by every price move since. A retailer plans her open to buy at retail and lands her receipts against the line, so a receipt has to be readable at both; until now the detail read served only the cost. Null means nobody stated a retail, never 0: a line valued at zero reports goods given away, which is a different fact from goods nobody priced. Additive and nullable; no action needed.Changed
taxesandtotal_salesonGet sales table(GET /api/v1/sales/table, scopesales:read): both may now benull, on the same termsdiscountsalready is.sale_orders.total_tax_amountis nullable, and a book that records no tax was served0for both, which reads as a measurement nobody made and put a total below its own net. A recorded0.00still serves0. Additive in shape, since neither field was ever required; a caller that treated the two as always-numeric should read a null as "not measured".
2026-09-07
Changed the length of
Idempotency-Keythe contract states forRecord a return expected back(POST /api/v1/sales/returns, scopereturns:write): it publishedmaxLength: 120where the server has always taken 255, which is the length every other write on the surface takes and the length its twinCreate a sales order(POST /api/v1/sales/orders) has always published. Nothing changed on the server and no key that was accepted before is refused now. What was wrong was the number a client generator reads: a client built from the published contract validated the header before sending it and refused a key of 121 to 255 characters locally, so an integrator keying returns off their own document reference - a return-authorisation id from an ERP runs past 120 characters where a UUID does not - met a refusal Tightly never made and saw no request id for it. The parameter's description now names the limit, as the sales-order one does. Additive; no action needed, though a client generated from an earlier copy of the spec is worth regenerating if it checks the header's length before sending.Changed which organisations reach the seven operations sold with Commitments, and it is a widening: every organisation on Tightly Pro now reaches them, where a key was refused
403 plan_excludesuntil somebody had typed the grant onto that organisation's record. They areThe open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate, scopecash:read);Cash from the buy, a year of weeks on the terms clock,The fiscal year week by week, against the plan and the envelopeandThe Monday pack for one 4-5-4 retail week(the threeGET /api/v1/finance/reports/...reads, scopereports:read);One commitment's plan of record at cost, on its own fiscal monthsandEvery locked version of one commitment's plan of record(GET /api/v1/commitments/{commitment_id}/moneyand the/plan/versionsbeneath it, scopeplanning:read); andWhat this order would draw from its Commitments, before it is placed(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/commitment-draw, scopepurchase_orders:read). The plan of record is not something sold beside Pro, it is what Pro is, so a customer who buys Pro holds it the moment the plan lands rather than when somebody remembers a second step. Every page and every refusal on these operations already readSold with Pro; what moved is that the server now keeps that promise on its own. No shape changed and no scope was added - a key already holdingcash:read,reports:read,planning:readorpurchase_orders:readneeds nothing done to it - and nothing below Pro reaches any of the seven. If you integrate against a Pro customer that was answering403 plan_excludeshere, it answers now.Changed how an organisation's plan is read where the feature list stored on its record is older than the plan it is on, which widens Product data and Tightly Connect for organisations on Essentials+ and Pro. The row a rung includes is now read as a floor beneath whatever that record stores, so an organisation a person moved onto a paid rung reaches what the rung includes even where the list written when the organisation was first created never named it. Until now that floor could be resolved for one stored plan code and not for any of the others, so the two
pim:readoperations and the twenty-eight operations sold with Tightly Connect - every operation onaccounts,order_bookandsell_out, and the six wholesale ones onorders- refused403 plan_excludeson organisations whose plan does include them. Those answer now. This only ever widens: no organisation loses a key it held, an organisation that already held one is unaffected, and no rung below Essentials+ reads a floor at all.Added
on_hold,hold_reasonandstatus_auditto the purchase order a caller reads back, across four operations.Get purchase order(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}, scopepurchase_orders:read),Update purchase order(PATCHon that same path) andDuplicate purchase order(POST /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/duplicate), the last two scopepurchase_orders:write, all answer with the same order object, so all three carry all three fields - a caller that PATCHes an order or copies one sees the hold state in the reply without a second read.List purchase orders(GET /api/v1/organizations/{organization_id}/purchase-orders, scopepurchase_orders:read) gainson_holdandhold_reasonon every row and does not carrystatus_audit: that is one log per order, and a page of fifty rows would carry fifty logs nobody reads a row at a time. All three fields were already written by the acts that take them - approve, hold, release, issue, push-to-WMS - and echoed only in each act's own response, so a caller that held an order and then read it back saw no trace of the hold while the server went on refusing to advance the order.on_holdis a non-null boolean, false by default: an order is held or it is not, and a nullable flag would give a reader three states to render for a fact that has two.hold_reasonis the holder's own words, set wheneveron_holdis true and null otherwise - a hold with no reason is refused at the door, so there is no "held, no reason given" state to render.status_auditis the append-only log of intent, one entry per act withevent,actor,reason,viaandat, whereactoris a user id orapi_key:<key_id>for a keyed act; it is[], not null, on an order nobody has acted on. Two payloads outside the published surface move as well, and neither keeps the shape it had: the outbound purchase-order webhook and the bare-array Zapier read both now carryon_holdandhold_reason. Neither carriesstatus_audit, and on both the key is absent rather than empty, because an empty array reads as "nothing has ever happened to this order". The log is withheld from those two for the reason the list row does not carry it, and one more: its entries identify somebody inside the business, which is not a thing to hand a third-party endpoint on a field nobody asked for.on_holdis not a filter key; this is a read, not a new way to narrow the list. Every one of these changes is additive; no action needed.Added
data_sourceto theexport=trueresponse ofget sales velocity table(GET /api/v1/sales/velocity/table) and toget variant bundle contributions(GET /api/v1/sales/velocity/variants/{variant_id}/bundle-contributions), both scopesales:read. It ischwhen the analytics mirror served the read andpgwhen PostgreSQL did, the same two words the paged read ofget sales velocity tablehas always answered. Both of these reads already dispatched to the mirror and already turned any failure or ineligibility into a correct PostgreSQL answer, so the two outcomes were identical on the wire: a mirror that had quietly stopped being used looked exactly like one that was working, on the largest read on the platform. The figures are the same whichever store answers - this says which one did, not what it said. Additive; no action needed.Changed what a key minted with
partner_idreaches on Orders.List sales orders(GET /api/v1/sales/orders),List the invoices raised to accounts(GET /api/v1/sales/invoices),List the people who have bought(GET /api/v1/sales/customers),Every order file that has arrived(GET /api/v1/sales/orders/documents) andEvery EDI document in or out(GET /api/v1/wholesale/edi/documents) narrowtrading_partner_idto the key's account and refuse another403account_mismatch;Create a sales orderandFile one order a retailer senttake the key's account where none was named and refuse another; an order, an invoice, a customer or an order file read or acted on by its id is refused when it is not that account's, and the refusal never says whose it is;Import a mapped file of sales ordersis refused for a bound key outright, because a file names its accounts row by row. The 2026-09-05 entry promised that a bound key reaches its account only, and these operations were published after the binding without asking. A key minted withoutpartner_id, and a signed-in session, are unchanged.List the people who have boughtgains an optionaltrading_partner_idfilter, one account's own record, which is what the narrowing rides on. Additive for every unbound key; no action needed.Changed
Idempotency-Keyon every public write: only a2xxanswer is remembered. A write that answered4xxor5xxnow releases its key the way a write that raised always did, so a retry after a502or after the in-flight409is a fresh attempt rather than the same refusal replayed for twenty-four hours. Sending no header behaves exactly as before.Changed
Create a sales order(POST /api/v1/sales/orders) andRecord a return expected back(POST /api/v1/sales/returns): the thirty-dayIdempotency-Keythese two keep is now scoped to the API key that sent it, as the 2026-09-05 entry promised for every key. It had been scoped to the organisation, so one integrator'sPO-1001could be answered with another's order, or refused409as a reuse of it. A signed-in session's keys keep the organisation's scope. No shape changed.Changed the
503a public write meets when the idempotency store cannot be reached: it now carriesRetry-After, the seconds the shared 503 answer has always told a reader to wait, where this one refusal had named none. The sentence is unchanged and the write is still not attempted.Changed
File one order a retailer sent(POST /api/v1/sales/orders/documents) andAnswer a draft line by line and open the order(POST /api/v1/sales/orders/{order_id}/confirm), for a request made with an API key: the draft the file becomes carriescreated_byofapi_key:<key_id>, and the template its confirm publishes is confirmed by the same, where both had read as filed by Tightly's own mail queue. A person's request is unchanged. This is the attribution every other keyed write on the surface has carried since 2026-09-05.Added a
collection_idfilter toA page of the product catalogue, one row per product(GET /api/v1/products/table) andA page of variants, one row per SKU(GET /api/v1/variants/table), both scopeproducts:read. It takeseqandin, the shapevendorandcategoryalready have on these two reads. The key was not in either allowlist before, and an unknown key refuses the whole request400rather than being ignored, so a catalogue sync could not narrow to a collection at all. The id is a collection as the store syncs it, not a curated collection: a curated collection's id matches nothing here rather than being refused. A product can sit in several collections, soinanswers every product in any of the ids given, and on the variants read it matches the variant's own product. Additive; no action needed.Changed
price_sensitivity,price_sensitivity_absenceandprice_sensitivity_absence_wordon the variant rows ofGet sales table(GET /api/v1/sales/table), scopesales:read, to admitnullto the enumeration each one publishes. All three have been documented as nullable since they landed, andprice_sensitivity_absenceis null on every row that carries a tier, which is most of them. The enumerations beside them listed only the words, so the two halves of the same field disagreed: the type said a null was legal and the enumeration said it was not. A validator generated from the contract rejected ordinary responses, and codegen produced a type with no room for the null the read sends. Nothing served changed. The words are the same words in the same order, andnullis admitted at the end of each list rather than among them.Changed
grainonCustomField, served byList custom fields(GET /api/v1/variants/custom-fields) andGet custom field(GET /api/v1/variants/custom-fields/{id}), scopeproducts:read, to admitnullto the enumeration it publishes. The same defect as the three pricing columns above and the last one of its kind on the response side: the field has always been documented "Null where the definition does not say", which is every definition made before a field could be added from a connection, and the enumeration beside it listed onlyproductandvariant. Nothing served changed.The rule these four now follow, because the next reader will meet it again: where a read serves a fixed row and a column is blank on some rows, the blank is an explicit
nulland the contract says so on BOTH halves - the type and the enumeration. It is not spelled by omitting the key. Anullis a statement ("we looked, and there is nothing here"); a missing key is silence, and silence cannot be told apart from an older server, a narrower projection, or a grain that never carried the column at all. With this entry no published response field is left in the broken state. Four REQUEST properties are in the mirror-image state -termsonCreate invoice,reasonandrefund.timingonCreate return, andlines[].dispositiononReceive return- and they are deliberately untouched: on a request the server decides what it accepts, so the fix there may be to narrow the type rather than widen the enumeration, and that is a question for whoever owns those writes.Changed the worked example and the summary of
Get sales table(GET /api/v1/sales/table), scopesales:read, so both show the four pricing columns the read has been serving since 6 September. The row in the reference stopped at nineteen keys and the summary listed the columns without naming them, so a developer reading the page saw a shape with no pricing in it and had to take the changelog's word thatprice_sensitivitywas there at all. The example row now carries all four, a scored line with itsprice_sensitivity_computed_atstamp and both absence fields null, which is the pairing rule from the side most rows are on. The summary also says what atype=productspage does not carry: a product row has no pricing columns at all. No action needed.Added
withheld_wordstoGet a sales order(GET /api/v1/sales/orders/{order_id}) and toList customers(GET /api/v1/sales/customers). It readsNot heldon a tenant whose storage may not hold a shopper at all, andnullon one whose may. Without it a nullcustomer, a nullship_toand an empty customer list are each two different facts a caller cannot tell apart: nobody gave the value, or Tightly does not hold it. Three things decide which, and all three have to be true. The plan is Essentials+ or Pro, so a Lite or Essentials organisation never holds a shopper and has no way to turn it on. The store's Shopify install is the organisation's own app rather than Tightly's App Store listing, which declares on behalf of every store on it that no shopper personal data is held, so a paying organisation still on that listing readsNot heldwhatever its setting says; an organisation with no Shopify install has no such declaration to contradict, and the plan and the setting decide it alone. And the organisation has turnedKeep customer detailson, which is off until an admin turns it on (Byron, 6 September 2026). Additive and nullable; no action needed, and a caller that ignores the field reads exactly what it read yesterday.
2026-09-06
Added
price_sensitivity_absenceto the variant rows ofGet sales table(GET /api/v1/sales/table), scopesales:read, and documentedprice_sensitivityandprice_sensitivity_computed_atbeside it, which were already served and had never been described. A nullprice_sensitivityhas carried four unrelated facts: no tier table on this tenant, an engine that has never run for this store, a store it measured and holds no reading for this line on, and a probe of ours that failed.price_sensitivity_absenceis set only where the tier is null, and isnot_run,not_scoredornot_measured. It deliberately does not say whether the line's product pool could lend it a number: that split needs the per-variant price evidence, one query a line, andGET /api/v1/variants/{variant_id}/price-sensitivityanswers it with the sentence for a line a reader opens.price_sensitivity_absence_wordis served beside the code -Not scored yet,Not scored,Not measured- so a caller prints a cell without composing English from a token, on the same shape the engine health read already uses. Those three are published as an enumeration, in the same order as the codes beside them, so a caller may switch on them; a code we ship without copy serves a null word rather than a generic one. Rewording any of the three is a change to this surface and will arrive here with a date. Additive; no action needed.Changed
price_sensitivityon the variant rows ofGet sales table(GET /api/v1/sales/table), scopesales:read, to publish its members as an enumeration:STILL_LEARNING,INCONSISTENT,LOW,MODERATE,HIGH. The five were already the only values the pricing engine writes and the description already named them in prose; declaring them makes the set generated code can switch on. The reference now also says plainly that the promise not to coalesce a missing tier toSTILL_LEARNINGcovers this read and not every surface: the analytics bindings behind Ask Tightly still substitute it, and that is owed a fix of its own. A narrowing of what a response may contain, not of what a caller may send. No action needed.Changed the
on_handfigure thatOne account's sell-out by door(GET /api/v1/sell-out/doors), scopesell_out:read, serves on each door and in the account totals beside them. It added together every weekly reading in the window, so a twelve-week ask reported roughly twelve times the stock a door actually holds, and everysell_throughcomputed against it was wrong by the same factor.unitsis a flow, so a window of it is that window's sales;on_handis a level, and it is now each variant's LAST reported reading at that door, summed within the door. No operation, field, type or status moved - the numbers underon_handandsell_throughdo, and they fall. A caller comparing today's answer against a figure it stored before this date should expect the drop and not read it as stock leaving the shelf.Changed what
Get one product(GET /api/v1/product/{product_id}), scopeproducts:read, serves when the size curve on a style is the labelled default because the engine refused to measure one.size_curve.fallback_reasonwas the sentence the auto-fill pass had written into storage, so it could not be counted or switched on without matching English, and rewording it meant rewriting stored rows.size_curve.fallback_reason_codeis now a closed token beside it, one ofno_stock_history,a_run_size_never_observed,a_sold_size_outside_the_run,run_broken_every_day,too_few_clean_days,too_few_clean_unitsorone_size_sold, and the sentence is derived from it at read time. The sentences are unchanged, moved rather than rewritten, so a reader renderingfallback_reasonsees exactly the words it saw yesterday. The code isnullon a refusal recorded before the codes existed, where the sentence is that stored prose; both arenullwhere nothing was refused. Additive; no action needed.Added
grades_stateandgrades_reasonto the response header ofWhat one account did last time(GET /api/v1/order-book/account-anchor), scopeorder_book:read. A style with no grade has always been served with thebaselinekey ABSENT, and until now that one shape carried two unrelated facts: an organisation whose plan does not include the baseline engine, and a grading run we could not read. A reader could not tell a door they can open from a morning of ours, and the page said "Not measured" over both.grades_stateisserved,not_on_planorunreadable, andgrades_reasoncarries a sentence fornot_on_planonly, the same text a403 plan_excludesserves. It is a different question fromengine_statebeside it, which is about the RUN rather than about the answer in your hands. No row shape moved and no style gained a key. Additive; no action needed.Changed what the
403on the nineteen Products and Variants operations says about the plan door. Every one of them read that the public API itselfis asked on every keyed request. It is asked of every key minted under that gate, and a key minted before the gate existed is never refused by it, whatever the plan holds. The capability the door asks is new, so every key issued up to now sits outside it: an integration calling Tightly today keeps reading, and only a key minted from here on is judged against the plan on every request, which is the downgrade the door was written for. What a resource is sold with is unchanged and is still asked. A widening, not a tightening. No status, code or shape moved. No action needed.Added the optional
networkparameter toList EDI documents(GET /api/v1/wholesale/edi/documents), scopeaccounts:read. This log holds every lane an order can arrive on and not only EDI: the same table carries a PDF that came in by email, a spreadsheet somebody uploaded, and a file pulled from a link or a portal. Until now there was no way to ask for one lane, so a caller reading the list as its EDI traffic was also being served its paper. Passnetwork=sps_commercefor the EDI lane alone; the other four values areemail,upload,linkandportal, and omitting the parameter serves every lane exactly as it does today. A lane outside that set is refused with a400naming the ones that exist, rather than answered with an empty list that would read as "this organisation exchanges nothing". Additive; no action needed.Changed what the two product data operations,
GET /api/v1/pim/v1/productsandGET /api/v1/pim/v1/products/{product_id}, say sells them, and the sentence their403serves. Both readsold with the Product Data add-on, and the403example readIt is sold with the Product Data add-on.Product data is included with Essentials+ and Pro from today (Byron, 6 September 2026), so the add-on is not a thing a reader can buy any more and naming it told them to ask for something that is no longer on the price list. The prose now saysincluded with Essentials+ and Proand the refusal saysIt is sold with Essentials+.A widening, not a tightening: every organisation that reached these two reads yesterday reaches them today, and the Essentials+ and Pro rungs now reach them without a separate grant. No status, code or shape moved. No action needed.Changed the
sold_withvalue the reach matrix serves for thepimresource (GET /api/v1/developer/scopes), frompim_addontoessentials_plus, and the enum of that field with it. It is the tier token a refusal carries, and it now names a plan rather than a retired add-on. A caller branching on the literalpim_addonfor that one row readsessentials_plusinstead; every other row is unchanged.Added
Send the account this order's shipping notice(POST /api/v1/sales/orders/{order_id}/shipping-notice), scopeorders:writeand a plan that includes wholesale. Builds the 856 from the shipment's own cartons -- one pack level per carton, with the GS1 barcode where the warehouse labelled one -- and sends it through SPS Commerce. It is refused where the warehouse reported no cartons rather than guessing a pack structure, because a receiving dock receives against what the notice claims. Additive; no action needed. Seedocs/api/orders.md.Added
Invoice this order for what shipped(POST /api/v1/sales/orders/{order_id}/invoice), scopeorders:writeand a plan that includes wholesale. The lines are the order's FULFILLED quantities at the price the order was agreed at, never the ordered quantities;tax_centsandfreight_centsare yours and Tightly calculates neither. The document is serialised as an 810 where the account takes one and posted to Xero or QuickBooks as a receivable where a ledger is connected. Three states are served apart -- the document's own, the ledger's and the network's -- and neither the ledger nor the network can fail the invoice. Additive; no action needed.Added
List the invoices raised to accounts(GET /api/v1/sales/invoices), scopeorders:read. The receivable side of the record, newest first, filtered by account, state or order, for reconciling accounts receivable against what shipped. It joined theOrdersresource rather than opening aninvoicesone, because nothing writes an invoice but the act on its order. Additive; no action needed.Added
One invoice, with its lines(GET /api/v1/sales/invoices/{invoice_id}), scopeorders:read. The header, every line at its fulfilled quantity and price, and the ledger and EDI blocks as three separate facts. Additive; no action needed.Added
Confirm a sales order(POST /api/v1/sales/orders/{order_id}/confirm), scopeorders:write. A verdict on every line of a draft - accepted, accepted at a different quantity, or rejected - which opens the order, holds its stock, records each verdict on the account's book line as its next version, and on an order that arrived over EDI sends the acknowledgement (855) back where the account takes one. Every line must carry a verdict: a confirm that accepted the lines nobody mentioned would agree to a retailer's whole order on a rep's behalf. A rejected line stays on the order at a quantity of nothing, with what was asked for beside it. Additive; no action needed. Seedocs/api/orders.md.Added
List EDI documents(GET /api/v1/wholesale/edi/documents), scopeaccounts:read. Every EDI document exchanged with an account, newest first: purchase orders in, acknowledgements, shipping notices and invoices out, and the inventory and sell-out reports that arrive on the same connection. Each row carries the account, the order it became or answered, the state it reached and the sentence behind that state, so a file that was refused or an acknowledgement a network would not take is readable without opening the network's own console. Read only. Additive; no action needed.Changed what two of the four order-file operations say they answer with. Their
200carries no typed schema, so the example is the whole of what a reader has to go on, and on both it showed less than the wire sends.One order file, its reading and the source to check it against(GET /api/v1/sales/orders/documents/{document_id}) servesreading, the reader's own stored frame thatfacts,lines,matchedandheader_matchare drawn from and that also holds the totals it tied and the pages it read; the example never showed it, so the operation named for its reading appeared to serve none.Take a refused order file as the new draft(POST /api/v1/sales/orders/documents/{document_id}/take) answers the whole paper in the shape the read above serves, and its example shows the ten fields the take moves, which read as the whole answer. Both now say what they carry beside what the example draws. Nothing on the wire moved.Changed the
403on nineteen Products and Variants reads, which told you a plan refusal could never reach them.A page of the product catalogue, one row per product(GET /api/v1/products/table),A page of variants, one row per SKU(GET /api/v1/variants/table) and seventeen others each ended with "Products is ungated, soplan_excludesis never served here". That was true when it was written and stopped being true when a key itself became something a plan includes: the public API is asked on every keyed request before the resource behind it is looked at, so an organisation whose plan does not include it is refusedplan_excludeson every operation, these nineteen among them. The sentence readsThis organisation's plan does not include the public API. It is sold with Essentials.The resources are still ungated and that is still the only plan reason either of them serves. Nothing on the wire moved; what moved is what the reference admits can happen.Changed the name the reference shows for the four order-file operations published earlier today, and what they say they are sold with. Each was published under the endpoint's own name in lowercase (
receive order document,list order documents,get order document,take order document) because a stray block above the specification was winning over the head its author wrote; all four now read as the head. Their403said the plan must include "wholesale", a wire word for a thing sold as Tightly Connect, and named no rung, so a reader could not tell what to buy. All four now name Tightly Connect and Essentials+ as the twenty-one other Tightly Connect operations do, listip_not_allowedbesidescope_missingandplan_excludes, and show the plan refusal as their example, carrying the sentence the seam prints. No status, code, scope or shape moved.Changed what
What this order would draw from its Commitments, before it is placed(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/commitment-draw) says it is sold with, scopepurchase_orders:read. It named Commitments and no rung. Commitments is sold with Pro, and only one of the two is a thing a reader can buy. The same defect as the twenty-one below, in the one operation that reading missed. No shape moved.Added four operations for a retailer's own order file:
File one order a retailer sent, and queue its read(POST /api/v1/sales/orders/documents),Every order file that has arrived, newest first(GET /api/v1/sales/orders/documents),One order file, its reading and the source to check it against(GET /api/v1/sales/orders/documents/{document_id}) andTake a refused order file as the new draft(POST /api/v1/sales/orders/documents/{document_id}/take). A purchase order arriving as a PDF, a CSV, a workbook or a photographed page becomes one draft sales order, with a line per garment resolved through that account's own codes; the paper is a row of its own that stands as the order's evidence, andGET /api/v1/sales/orders/{order_id}gains adocumentblock naming it. The account is named inaccount_idand is never read off the letterhead. All four needorders:readororders:writeand a plan that includes Tightly Connect, which is sold with Essentials+. Additive; no action needed. Seedocs/api/orders.md.Changed three row keys on the ledger of
The Monday pack for one 4-5-4 retail week(GET /api/v1/finance/reports/weekly-trade), scopereports:read:returnsis nowreturns_value,markdownis nowmarkdown_value, andintake_marginis nowdeclared_intake_margin. Each old key was also a word in the metric dictionary that names a different thing --returnsa unit count,markdowna rate,intake_margina percentage -- and the ledger serves a value at cost under all three, so the same word meant two things on one surface. Two labels move with their keys (ReturnsreadsReturns value,Intake marginreadsDeclared intake margin); the figures do not. A reader keying on any of the three old names should key on the new one.Added
projected_in_sourceandprojected_in_reasonto the reference forThe open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate), scopecash:read. Both ride on every answer and neither was written down. They say whether the weeks ahead were projected at all:projected_in_sourceisforward_curvewhere the forward curve was read and null where it was not, andprojected_in_reasoncarries the one sentence why. That leg can now be served unread rather than taking the whole ladder down with it - on a book big enough that projecting the weeks ahead runs past the time this read gives it, the projection alone is dropped, the weeks ahead carrysettled_inandbasisnull, and the realised cash, the placed buy, the terms and the levers are served as normal. If you read a run of nulls in the weeks ahead as "no cash expected", readprojected_in_sourceinstead: null there means unread, not zero. No shape moved.Changed the figures
chase_reserveandopen_to_commit_envelopeanswer with, onPOST /api/v1/metrics/queryandGET /api/v1/metrics, scopemetrics:read.chase_reserveis now the reserve still held - the declared span less what has been released out of it - where it served the declared span whole and counted a released pound as money still held back.open_to_commit_envelopegains those releases back, so the two agree with the Commitment room's own ladder and with each other. Neither key's definition changed: both always described what is now served, and it was the arithmetic beneath them that disagreed. Nothing moved on the wire, but the numbers move. A reader reconciling either figure against its own will see a reserve fall and an open to commit rise by exactly what has been released.Changed the head figure key on
The Monday pack for one 4-5-4 retail week(GET /api/v1/finance/reports/weekly-trade), scopereports:read, which was published earlier today. The cover figure on the head is keyedcover_forward, notcover. It is struck in weeks, andcoveris the metric dictionary's word for a figure in days, so the old key promised a number seven times the one it served. The label, the figure and the face are unchanged. A client written against this report in the hours it carriedcovershould readcover_forward.Changed what six operations say they are sold with, the same defect as the twenty-one below and found by the same reading.
The open buy on each supplier's terms clock, the two plan of record reads (GET /api/v1/commitments/{commitment_id}/moneyand its/plan/versions) and the three trade reports each said they were sold with Commitments and then showed a403example readingIt is sold with Pro.Commitments is the capability that gates them and Pro is the plan that sells it, and only one of the two is a thing a reader can buy. All six now name the rung, as the Tightly Connect operations already do. No status, code, scope or shape moved.Changed what twenty-one operations say they are sold with. Tightly Connect moved down a rung to Essentials+ yesterday and the refusal each of them prints moved with it, but the prose above the refusal did not: nine order-book operations, six sell-out operations and six account operations each told a reader
Sold with Proand then showed a403example readingIt is sold with Essentials+.One operation, two answers to the same question. Every one of them now readsSold with Essentials+, and names the capability the product's own word, Tightly Connect, rather than the wire'swholesale. No status, code or shape moved, and no key's reach moved either: what changed yesterday was the plan that sells this, not who holds it.Added
account_mismatchto the403of the twenty operations that can answer it. A key issued to one account has been refused since the account binding shipped, and yesterday that refusal got its own code so a caller could branch on it, but no operation listed the code beside the four it already listed. Two shapes, and each operation now says which is its own: the six order-book reads whose figure covers the whole book refuse a bound key outright and point atGET /order-book/account-stylesandGET /order-book/account-anchor; the reads and writes that take an account refuse one that names another, intrading_partner_id, inaccount_idor in the path. Additive; no action needed.Changed the
403onRecord order book lines(POST /api/v1/order-book/lines), scopeorder_book:write, which named the wrong code for the same refusal. It said a key issued to one account that names another is refusedforbiddenand that the code is not one a caller can branch on. It isaccount_mismatchand it is. The wire has answered that since yesterday; only the reference still said otherwise.Changed the
403onGet purchase order commitment draw(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/commitment-draw), scopepurchase_orders:read, which saidplan_excludesis served whenPurchase orders is sold with Pro. Purchase orders is sold with every plan and every other operation in that resource answers on one. This read alone is gated, on Commitments, which is what its own refusal has always said. The sentence now names Commitments and says it gates this read rather than the resource.Changed what the two product data operations,
GET /api/v1/pim/v1/productsandGET /api/v1/pim/v1/products/{product_id}, say they are sold with. Each readsold with the PIM add-onabove a403example readingIt is sold with the Product Data add-on.One thing to buy, two names for it. Both now use the name on the price list.Changed one sentence in the
403of those same two product data operations, which explained why the plan is re-checked on every request by naming something that cannot happen: "a key minted on a trial keeps resolving after a downgrade". Tightly has no trials and never had one a key could be minted on (Byron, 6 September 2026: "there is no trial ... Trials do not exist on Tightly"), so the sentence justified a real rule with an example a reader could not have met. It now reads "a key minted on Pro keeps resolving after a downgrade", which is the case that does happen and the reason the check is per request. The rule it describes has not changed, no status, code or shape moved, and no key's reach moved. No action needed.Added one sentence to
List accounts(GET /api/v1/wholesale/retailers), scopeaccounts:read, saying that a key issued to one account sees that account alone. This read narrows rather than refusing, which is the right answer to "of these accounts, which are mine" and a silent one to a reader who did not know their key was bound: a dashboard showing one row looks like missing data rather than a working filter. Nothing on the wire moved.Changed the wording of the reference itself, on all 121 published operations. Every summary, description and example lost its em and en dashes and the words RULE 26 refuses, so the pages a developer reads sound like the people who wrote them. Six sentences the wire itself sends moved with their documentation, because a specification quotes what the wire says: a duplicate contact is refused
Contact already exists. The email must be unique., an unsupported supplier upload is refusedFile format not supported. Upload a CSV file.,Get product subcategoriesanswersdescThe subcategories on file, a variant's price-sensitivity summary now readsSales dropped ~11% when price increased.rather than leading with the condition, and the two sell-outdescsentences readpreview only; nothing has been importedandrecorded; this account's reports can now be imported, a semicolon where each had a dash. Nothing else moved: no status, no shape, no field name, no scope. A client that matches one of those six sentences by hand should readmessage.codeinstead.Changed the
401on the two operations of the PIM key door,GET /api/v1/pim/v1/productsandGET /api/v1/pim/v1/products/{product_id}:data.errornow readsThe API key is not valid., the sentence every other operation answers withkey_invalid, instead ofInvalid or revoked key. One bad key is refused in one sentence on every door. No status and no shape moved.Changed the sentence on
account_mismatchwhen a key issued to one account names another. Its closing clause now names the account (It named tp_selfridges.) instead of using a phrase RULE 26 refuses. No code, status or shape moved; a client that branches onmessage.codesees exactly what it saw.Changed the
codeon three refusals, so each one can be branched on. A reusedIdempotency-Keysent with a different request now answersmessage.codeidempotency_conflict, and one sent while the first request is still running answersidempotency_in_flightwithRetry-After: 1. Both are409and both used to carryconflict, which left a client matching on the sentence to tell a refusal that never resolves from one that resolves in a second. A key issued to one account that asks for another, or that asks for a figure covering every account on the book, now answersaccount_mismatchrather thanforbidden. No status moved, no sentence moved and no shape moved; a client that branches on the status or readsdescsees exactly what it saw yesterday, and each code now has its own entry on the errors guide.Changed
List purchase orders(GET /api/v1/organizations/{organization_id}/purchase-orders), scopepurchase_orders:read, to ignorefor_zapierwhen the caller is an API key. That parameter makes the operation answer a bare array of purchase orders instead of the envelope, it exists for one first-party integration, and it has never been part of the reference. A key that sends it now gets the paginated object the reference describes. A signed-in session is unaffected.Changed
List suppliers(GET /api/v1/inventory/suppliers/table) andList contacts(GET /api/v1/contacts/table), scopesuppliers:read, the same way:for_zapieris ignored when the caller is an API key, and a key that sends it gets the paginated object the reference describes. Both used to answer the bare array. A signed-in session is unaffected.Added the headers the seam already sends to the reference. Every operation now declares
X-Request-IdandX-Tightly-Regionon each answer it can give,X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reseton every 2xx, andRetry-Afteron the two answers that ask you to wait. Nothing new is on the wire: all six have been stamped on keyed responses since API keys shipped, and none of them was written down anywhere a client generator or a Try it panel could read. Additive; no action needed.Added a shared
429and a shared503to all 121 operations. Both were always possible on any of them and neither was declared.429is the key spending its 600 reads or 120 writes a minute;503is the database briefly out of reach. Each carries the usual envelope, a code ofrate_limitedorPOSTGRES_UNAVAILABLE, and aRetry-Aftersaying how long to wait. A generated client gains two branches it could not see before. Additive; no action needed.Added
Tightly-Versionas an optional header parameter on every operation. It names the date train to answer on. One train exists today,2026-11, and every key is minted on it, so the call answers on it with or without the header; send it anyway, so a client built now names its train from the start. It sits besideIdempotency-Key, declared on every write the day before, in the reference's sharedparameters, so the two request headers the seam reads are defined once and pointed at from every operation. Additive; no action needed.Changed which response headers a browser may read on
api.app.tightly.io. The threeX-RateLimit-*andRetry-AfterjoinX-Request-IdandX-Tightly-Regionon the exposed list, so a page can read the budget the call it just made spent. A server to server caller was never affected, because the rule this changes is a browser's. Additive; no action needed.
2026-09-05
Added two customer reads under the
Ordersresource, scopeorders:read:List customers(GET /api/v1/sales/customers) andGet a customer(GET /api/v1/sales/customers/{customer_id}). The person an order is for, and their orders, their returns and what has been refunded to them across every channel they bought on. Deliberately thin: a display name, the email's domain, a country and a coarsened region, and never a street address, a phone number or an email in the clear. Asearchterm containing an@is matched against the email's pseudonym, which is the only way to look a customer up by address when no address is stored. There is no write and there will not be one under this scope: a customer is created by the order writers and by the sync, and a second door would make one person twice. Additive; no action needed. Seedocs/api/orders.md.Changed
The open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate): adds the optionalscenarioparameter and, when it is given, a top-levelscenarioblock. With it the same ladder is served with one line moved:planned_outbecomes what that company scenario's plan still has to buy, and the scenario's receipt shift re-dates it by whole weeks. Every other line is unchanged, and a scenario id the book does not carry is refused rather than answered with the plan's own ladder. Additive; a caller who does not send it sees no change.Changed who may publish a buy plan, revert one, cut the open to buy, or undo a cut (
POST /api/v1/mfp/{mfp_id}/publish,POST /api/v1/mfp/{mfp_id}/revert,POST /api/v1/otb/{mfp_id}/resolve/cut,POST /api/v1/otb/{mfp_id}/resolve/uncut). All four now need the approver level on the plan's feature, where they took the editor level before, and each refuses with its own sentence naming the level and the page an admin grants it on. Every other write on the plan stays at editor, so the grid is typed by the same people all week and only the act that restates the published number moved. An organization admin holds an implicit approver on every planning feature, so nobody who could do this yesterday is locked out today. None of the four is on the public API, no key reaches any of them, and none appears inopenapi/public.json: this is disclosed here because it is a tightening, and a tightening is the one kind of change a reader wants to hear about whether or not it is theirs. No action needed for any integration.Changed the audit trail's coverage: every planning write a person performs is now recorded, so the account's audit export (
GET /api/v1/accounts/audit-events/export) carries the buy plan's, the open to buy's, the commitments' and the finance reports' acts beside the team, key, webhook, organization and connection acts it already carried. The new action names areplan.created,plan.settings_set,plan.cell_edited,plan.edit_undone,plan.amendment_proposed,plan.budget_flag_left_unchanged,plan.published,plan.reverted,plan.deleted,plan.test_data_seeded,open_to_buy.cut,open_to_buy.uncut,open_to_buy.envelope_moved,open_to_buy.budget_increase_flagged,open_to_buy.budget_increased,open_to_buy.setup_completed,open_to_buy.guardrail_set,open_to_buy.season_calendar_set,commitment.declared,commitment.amended,commitment.approved,commitment.closed,commitment.retired,commitment.chase_released,commitment.stage_overridden,commitment.stage_override_cleared,commitment.predecessor_set,commitment.budget_locked,commitment.approval_policy_set,commitment.buy_lock_default_set,commitment.coverage_set,finance.terms_declaredandreports.note_saved. A published plan's row carries which version became live. The export's own shape is unchanged. Additive; no action needed, and nothing is recorded for an account until an operator turns recording on.Changed
Planning(planning:read) gains five reads beside BE-API-16's two:List the buy plans(GET /api/v1/mfp),Every published version of a buy plan, and the working tip(GET /api/v1/mfp/{mfp_id}/versions),The plan side of the Open to buy ledger for one plan's fiscal year(GET /api/v1/mfp/{mfp_id}/plan-side),One commitment's plan of record, at cost, on its own fiscal months(GET /api/v1/commitments/{commitment_id}/money) andEvery locked version of one commitment's plan of record(GET /api/v1/commitments/{commitment_id}/money/plan/versions). Read only, and permanently: a plan is typed by a planner who can be asked why, so there is noplanning:writeand none is planned. The grid, the versions and the list are sold with Pro, as the resource is; the plan side is served to every organisation, as it is on the session door; the two commitment reads are sold with Commitments and refuseplan_excludeswithout it.Get the MFP gridnow readsThe buy plan's grid, by category and month, the words the page uses; its path and its shape are unchanged. Additive; no action needed.Added the
cashresource and the scopecash:read, over one read:The open buy on each supplier's terms clock(GET /api/v1/commitments/cash-gate). Fifty-two retail weeks of merchandise cash against the declared floor. It is not a cash flow statement, andcontracted_outandplanned_outoverlap by construction, so adding them counts the same buy twice. Sold with Commitments. Additive; no action needed.Added the
reportsresource and the scopereports:read, over three reads:The Monday pack for one 4-5-4 retail week(GET /api/v1/finance/reports/weekly-trade),The fiscal year week by week, against the plan and the envelope(GET /api/v1/finance/reports/open-to-buy) andCash from the buy, a year of weeks on the terms clock(GET /api/v1/finance/reports/cash-from-the-buy). All three answer one envelope, so a client written for one reads all of them. Four more reports are built and are not on the contract until each one's specification is written. The commentary cell (PUT /api/v1/finance/reports/{kind}/{period}/notes) is not public and will not be: a note is a person's sentence with their name on it. Sold with Commitments. Additive; no action needed.Added the
metricsresource and the scopemetrics:read, over two reads:The metric dictionary(GET /api/v1/metrics) andCompile one query from the dictionary and answer with its rows(POST /api/v1/metrics/query). The dictionary is every metric and dimension the platform defines with one sentence each; the query hands the deterministic compiler a query written against it and answers with rows, the columns, the formats and the window it ran over, with no model in the path. The POST costsmetrics:readand not a write, because it asks a question and changes nothing: there is nometrics:writeand nothing for one to open. It is charged the write allowance by the rate limiter, which reads the verb and is what running a query costs, and it takesIdempotency-Keyfor the same reason: the seam replays by verb, so a query sent twice under one key is answered from its first run. Additive; no action needed.Changed the sentence every
plan_excludesrefusal says, on every operation that can answer one. It named the wire values (This organization's plan does not include wholesale. It is sold with pro.) and now names the product's own words:This organisation's plan does not include Tightly Connect. It is sold with Essentials+.Thecodeis unchanged (plan_excludes), the status is unchanged (403), and the refusal is raised on exactly the same conditions. A client that branches onmessage.codeneeds no action. A client that matched on the text ofmessage.deschas to stop: the sentence is written for a person to read and will be rewritten again.Changed what the order book, sell-out and account operations are sold with, in the same refusal. They read
proand now readEssentials+: Tightly Connect is sold with the Essentials+ plan from today, which is a wider entitlement than before, not a narrower one. No key loses reach.GET /api/v1/developer/scopesreports the same move in itssold_withcell, as the tokenessentials_plus.Added a
not_assessedgrade toWhat one account did last time, per style - and how each style is likely to perform there(GET /api/v1/order-book/account-anchor), scopeorder_book:read. A style whosebaselinecarriesstate: not_assessedwith areason_codeand asentenceis one the last grading run declined to grade for this account at all - its record could not be read, or it reports on conventions nobody has confirmed. It is served in place ofbaseline: null, which goes on meaning the pass ran and never assessed that style. Three absences, three answers:not_assessedwith a reason,nullfor assessed by nobody, and the key missing altogether when the grade could not be read. Additive; no action needed.Changed the reference for the same operation to say what
baseline: nullmeans. It read "the pass assessed the style and graded nothing", which is the opposite of what the read serves: the pass has never assessed that style at that account. The response is unchanged; the sentence describing it was wrong.Changed the reference for
How this variant's sales have moved when its price moved(GET /api/v1/variants/{variant_id}/price-sensitivity) to name the valuesengine.statecan carry -not_run,current,borrowed,insufficient- and to say they describe the figure and never a run. The schema types that field as a plain string, so a reader could not see the set, and the same field name on the account anchor carries a different set. The response is unchanged.Added
Idempotency-Keyas a declared request header on all 40 write operations. This closes the last clause of the entry further down that put the header in the reference's own description: it has been honoured on every public write since it was announced and described in prose ever since, but it was declared on no operation, so a generated client had no typed way to send it. It is now an optional string parameter on everyPOST,PATCH,PUTandDELETE, referencing one sharedIdempotencyKeycomponent, and regenerating your client gives you the argument. Additive; no action needed, and nothing about how the header behaves has changed.Added descriptions to
ship_window_startandcancel_datewherever a purchase order carries them:Create a purchase order,Update a purchase order,Get a purchase orderandList purchase orders. Both were published as baredatestrings, so which end of the ship window each named was a guess.ship_window_startis the first day the vendor may ship, which is not the day the goods are expected (expected_delivery_date);cancel_dateis the last day of that window, inclusive, after which the buyer may walk away from whatever has not shipped. Documentation only; no shape moved.Added
enginetoHow this variant's sales have moved when its price moved(GET /api/v1/variants/{variant_id}/price-sensitivity), scopeproducts:read. It is the block every engine read on the platform now carries:name,state(one ofnot_run,current,borrowed,insufficient),computed_at(the completion stamp of the run that wrote the tier, never the reader's clock),inputs_through,stale_after,basis(measured,borrowedorprior),confidence(a word, never a percentage) andrun_id. A store the engine has never reached carries onlynameandstate. Additive; no action needed.Added four header fields to
What one account did last time, per style - and how each style is likely to perform there(GET /api/v1/order-book/account-anchor), scopeorder_book:read, so the header says which morning its grades are from:grades_as_ofis the completion stamp of the last grading run that succeeded,engine_stateis that engine's state (never_ran,not_measured,running,fresh,stale,partial,failedornot_applicable),next_run_atis when it runs again, andenginecarries the same facts in the same block shape the price read uses. One difference in that shape is worth coding for: herestateis a RUN's state - the eight words just listed - and not the four a figure's block carries, and it is null where the run ledger itself could not be read. They are stamped on every path, including the one where the grades themselves could not be read, and never from the reader's clock, which is what this read used before. Additive; no action needed.Added
price_band_reasontoOne product, with its catalogue fields, its stock and the ranges its variants span(GET /api/v1/product/{product_id}) and to every row ofA page of the product catalogue, one row per product(GET /api/v1/products/table), scopeproducts:read. Where a product has no price band it says why the attribution pass recorded none (reason,priced_products,needed,computed_at,run_id), so an absent band no longer looks like a band nobody has computed yet. Null where the pass has not assessed the product. Additive; no action needed.Added
missing_required_countto every row ofA page of the product catalogue, one row per product(GET /api/v1/products/table), scopeproducts:read: how many of the family's required attributes the product has not answered, counted by the pass that runs after every sync. Null before that pass has run. Additive; no action needed.Changed what every operation answers when the database is briefly out of reach, which is what a failover or a full connection pool looks like from outside. The answer is now
503with aRetry-Afterheader naming the seconds to wait andmessage.descreading "The database is unavailable right now. Try again in a few seconds."; the same conditions used to answer500with "Postgres error" and nothing to wait on, which every client in flight retried at once at the worst possible moment. A write whose idempotency store cannot be reached is refused503too, and that one says plainly that nothing was written. Wait the seconds the header names and send the request again. Where the sentence says nothing was written, the same request is safe to send again; otherwise a read is safe to repeat and a write is not certain either way, because the connection was lost in the middle of it, so read the object back before sending it a second time. Branch on the status rather than oncode, which isPOSTGRES_UNAVAILABLEorSERVICE_UNAVAILABLEdepending on which seam refused. Nothing about any operation's shape moved and no train was opened.Added
X-Tightly-Regionto every response, refused or not, beside theX-Request-Idthat was already there. It names the home that served the call,usfor every organisation today, so an integration can see which one answered without reading a body. Additive; no action needed.Changed the description at the top of the reference to carry the two above and to describe
Idempotency-Key, which the entry below announced on every public write and which no operation in the reference mentioned. All three are true of every one of the 121 operations rather than of any one of them. The header is still not declared as a request parameter on the write operations themselves, so a generated client cannot send it through a typed method yet; that is a change to the artefact's shape and is named indump_openapi.pyfor whoever owns the build.Added
currencyto seven order-book reads, scopeorder_book:read:What is booked for a season, and the one envelope comparison that is like-for-like,Every account's book against its own prior season, read at the same days before start,One account's book, style by style,What one account did last time, per style,How the book was taken,How much of this book is firm, how much can still walk, and how much nobody has said, andOne style's live lines across every account(GET /api/v1/order-book/summary,/accounts,/account-styles,/account-anchor,/curve,/firmness,/style-lines). It says what the figures beside it are in:functionalis the book's own currency and the denomination every amount carries, so a_usdsuffix in a key name is that functional currency and never a claim of dollars;transactionis what the lines were agreed in where they agree on one and null where they do not;reportingis what a reader of this organisation asked to see, with a datedratebetween the last two, served and never applied.basisissame,convertedorunconvertedandreasoncarries the absence word.What is actually walking(GET /api/v1/order-book/signals) does not carry it. Additive; no action needed.Added
week_gridon each bucket and aweek_gridsmap to the sales velocity table's totals (GET /api/v1/sales/velocity/table/totals), scopesales:read. On a weekly chart each bucket names its week's key and the map converts that Monday-keyed week to the plan's 4-5-4 Sunday week: the plan week it falls inside, that week's start and end, its fiscal year and week number, whether the two are aligned, and how the bucket's days split between the plan week and the next. Daily and monthly buckets carryweek_grid: nulland an emptyweek_grids, because those buckets are not weeks. Additive; no action needed.Added
kind,ediandbrandsto every supplier read, scopessuppliers:readandsuppliers:write(GET /api/v1/inventory/suppliers/table,GET /api/v1/inventory/suppliers/{supplier_id},PUT /api/v1/inventory/suppliers/{supplier_id},POST /api/v1/inventory/suppliers), which a reader meets asA page of suppliers with their terms, lead times and delivery record,One supplier whole - terms, contacts, domains, and both views of its warehouses,Change one supplier's terms, warehouses and lanesandCreate suppliers, with their contacts, in one call.kindisfactory,vendorordistributor, and null when nobody has stated one, which is what an import from a connector always means.ediis the identifiers an EDI trade runs on,{trading_partner_id, vendor_number, account_number}, wherevendor_numberis how Tightly numbers them andaccount_numberis how they number Tightly; it is null and never an empty object when nobody has recorded any.brandsis the brands bought from that counterparty, each withbrand_id,nameandownedfor private label, and an empty list is a real answer. The update takeskind,ediandbrand_idsto state them:ediandbrand_idsare stated whole, so what is sent replaces what is on file,edi: nullor[]clears, and omitting a key leaves it alone.edi_statedandkind_statedon the answer are server-set and are not fields to send. The create takeskindonly. Additive; no action needed.Added
ship_window_startandcancel_dateto purchase orders, scopespurchase_orders:readandpurchase_orders:write. They are the ship window the vendor is held to: the first day the goods may ship and the last, inclusive, after which the buyer may walk. Both optional and both absent by default, because most orders in this book are replenishment orders placed against an expected delivery date and no window. Acancel_datebeforeship_window_startis refused400 The cancel date cannot be before the first ship date. They are accepted onopen a purchase orderandupdate purchase orderand answered on both, onlist purchase orders, onget purchase orderand onduplicate purchase order. Additive; no action needed.Added
label,grain,bound_toandused_inonThe custom fields this organisation keeps on its variants(GET /api/v1/variants/custom-fields) and onGet custom field(GET /api/v1/variants/custom-fields/{custom_field_id}), scopeproducts:read.labelis what a person calls the field andnameis the key its values are stored under, so print the label.grainsays which bag holds the values,productorvariant, and is null where the definition does not say.bound_tonames the connection filling the field and the path it reads, and is null for a field nothing fills; while it is set, the field's values are that source's and a write to them is refused withfield_read_from_source.used_incounts the family forms and saved filters that name the field. Additive; no action needed.Changed who may read a supplier's message on
What needs doing about one supplier - orders, readiness and missing terms(GET /api/v1/inventory/suppliers/{supplier_id}/needs-attention), scopesuppliers:read. Every connected mailbox now carries a sharing level, and an API key is never the person who connected one, so a key reads each mailbox at the level that mailbox shares rather than as its owner. At the level a mailbox starts on,email.body,email.html_body,email.clean_bodyandemail.quoted_bodycome back null andemail.body_withheldsays so; a mailbox shared with Tightly only has its messages left out of the section, and the item then carriesemail: nullwith its order, status andmessage_keyunchanged. Nothing else about the item moves, and a key that needs the correspondence itself needs that mailbox set to Full messages by the colleague who connected it. This is a change in what the operation answers rather than in its shape, so no field was removed and no train was opened.Changed the reference for the same operation to say which of the message's fields it fills.
says,files,decision,with_party,tagandwaits_on_youare part of the message shape the Mail reads answer and are null here, because this section reads messages one at a time by id rather than reading a thread.read_at,clean_body,quoted_bodyandbody_withheldare answered. The 200 example now shows a message beside the order it belongs to. Documentation only; no action needed.Changed the week proposed on
Recognise a retailer's report and say what would happenandLand a retailer's own sell-out report against one named account(POST /api/v1/sell-out/preview,POST /api/v1/sell-out/import).we_read_it_as.week_ends_onandwe_read_it_as.week_anchor_downame the day the retailer's reporting week ends, which is the question the account is asked when it confirms its four answers. Retailers who date each week by its first day (a start column, a week banner, an ISO week number) had that first day proposed instead, so a file that agreed with what the account had already confirmed came back reported as a disagreement. A caller that recorded the proposed anchor for one of those retailers sees it move by a day, onto the day the week ends; nothing else about the answer changes.Changed the week a file resolves to where the account's own column mapping reads it, on the same two operations.
periodis taken from the mapping's period-end column when one is bound and from its period-start column otherwise, and either way it is widened to the week the account confirmed its weeks end on. A mapping binding only a period-start column landed every week a day out and reported a week-anchor drift onneeds_youthat had not happened. A mapping binding both columns read start dates as end dates on any file whose end column was missing, landing that week six days early; the week is now read from the bound end column alone, and a file missing it is refused rather than dated from the wrong column. No action needed.Changed the 200 on
Recognise a retailer's report and say what would happen(POST /api/v1/sell-out/preview):confirmednow travels besidemapping. An account that has answered the four but that no format profile recognises used to get its column proposal withconfirmednull, which read as though the account had answered nothing on a page showing that it had. Additive; no action needed.Changed
What needs doing about one supplier - orders, readiness and missing terms(GET /api/v1/inventory/suppliers/{supplier_id}/needs-attention), scopesuppliers:read: each email on the section gains optional fields the Mail face reads -says(what the supplier said about which order, in one sentence),files(the attachments Tightly kept, with what each was recognised as and when it was read),decision(the pending proposal the thread carries),with_party,tag,waits_on_you,read_at, and the body split intoclean_body,quoted_bodyandbody_withheld. Every new field is optional and null when absent; a stored attachment may carry keys beyond the three listed and they are ignored rather than refused. Additive; no action needed.Added the
Movementsresource, scopemovements:read, and its one operation:The stock ledger(GET /api/v1/inventory/movements). Every plus and minus of stock, newest first, each naming the document that caused it. A read must name a product and a warehouse, or a document; one that names neither is refusedfilter_needs_a_pair_or_a_document, because an unfiltered ledger is every stock change an organisation has ever made. Read only, and always will be: a movement is written by the document that moved the stock, so a public write here would be a way to make on hand disagree with the ledger it is the sum of. Additive; no action needed. Seedocs/api/movements.md.Added
One product's position at a warehouse(GET /api/v1/inventory/position), scopeinventory:read. What is there, what is held, what is coming and what is left to sell, with the derivation of each:on_handwith the name of which figure it is,reservedbroken down by why each unit is spoken for and by which order holds it,expected_inboundbroken down by the order it is coming on, andatp. The identityon_hand - reserved.hard + expected_inbound.quantity + expected_inbound.returns_expected = atpholds on every answer. Additive; no action needed. Seedocs/api/movements.md.Added the
Ordersresource, scopesorders:readandorders:write, and its seven operations:Create a sales order(POST /api/v1/sales/orders),List sales orders(GET /api/v1/sales/orders),Get a sales order(GET /api/v1/sales/orders/{order_id}),Change a sales order(PATCH /api/v1/sales/orders/{order_id}),Cancel a sales order(POST /api/v1/sales/orders/{order_id}/cancel),Allocate a sales order(POST /api/v1/sales/orders/{order_id}/allocate) andImport sales orders(POST /api/v1/sales/orders/import). An order is a document Tightly owns a lifecycle for from the moment it exists, whichever door it came in by. The create requires anIdempotency-Keyand keeps it 30 days. Orders that come from a connected channel arrive on the sync and are refused a change here. Additive; no action needed. Seedocs/api/orders.md.Added the
Returnsresource, scopesreturns:readandreturns:write, and its seven operations:Record a return(POST /api/v1/sales/returns),List returns(GET /api/v1/sales/returns),Summarise returns(GET /api/v1/sales/returns/summary),Get a return(GET /api/v1/sales/returns/{return_id}),Record what arrived on a return(POST /api/v1/sales/returns/{return_id}/receive),Cancel a return(POST /api/v1/sales/returns/{return_id}/cancel) andImport returns(POST /api/v1/sales/returns/import). A return is a document about goods, not a refund with a flag on it: what is coming back, where it is expected, what arrived at the dock and what went back on the shelf. Only the restocked units move stock, and only the part of them the order shipped; the rest opens an exception a person decides. The refund on a return is a fact read from the channel and never one Tightly issues, so a refund sent for a channel's order is refusedrefund_is_the_channels. The record requires anIdempotency-Keyand keeps it 30 days; the receipt needs none, because recording the same figures twice writes the ledger once.Summarise returnsserves the 30-day return rate and both figures it is made of, so no caller computes one of its own. Additive; no action needed. Seedocs/api/returns.md.Changed what an order does about stock, on
Create a sales order,Change a sales order,Cancel a sales orderandAllocate a sales order. An order that reaches open now HOLDS stock: each physical line takes what is free at its warehouse (on hand less what is already spoken for, never inbound), a bundle line holds its components, andlines[].reservationson every answer says what each line holds and where. A line the shelf cannot cover holds what it can and records the rest asshortfall; the order is never refused for it and stock never goes negative. A change re-takes the hold against the shelf as it now stands, and a cancel lets it go.Allocate a sales ordergains one refusal,order_reservations_off(409), for an organisation that does not hold stock for orders; every organisation reads that way until an administrator turns it on, and nothing was allocated before. Additive; no action needed.Added
mappingto the 200 onRecognise a retailer's report and say what would happen(POST /api/v1/sell-out/preview), together with the optional query parameterfile_name. A retailer whose report no format profile recognises used to come back refused, with nothing to do but send us the file. Givenaccount_id, the preview now keeps that file's header and a sample of its rows, matches its columns against the fields sell-out is stored in, and answers with one proposal per column and the reason for it, inmapping.recognised_asandrefusedare both null on that answer, because nothing claimed the file and nothing was turned away.file_namerecords what the upload was called so a person reviewing the proposal later can tell which file it came from; it is ignored on a file a profile already recognises. Additive; no action needed.Changed what reads a file on
Recognise a retailer's report and say what would happenandLand a retailer's own sell-out report against one named account(POST /api/v1/sell-out/preview,POST /api/v1/sell-out/import). Once an account's column mapping is confirmed in Tightly, that account's files are read by it wherever no format profile claims them, andrecognised_asnames the confirmed version rather than a profile. Profiles still go first, so a retailer somebody profiled is read exactly as it was. Additive; no action needed.Changed the documented refusals on the same two operations. An account whose column mapping is confirmed but cannot read the file in front of it is refused 400 with
data.reason, one ofnot_declared,no_door,mixed_currency,unreadable_file,no_columnsornothing_to_read, and each reason names the step that is missing. An account already mapped is no longer told its retailer is one we do not recognise. Documentation of refusals the endpoints already answer; no action needed.Changed the example on
Get purchase order(GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}): it now shows thecontainer_planan order carries and the per-line carton fields (cartons,cbm,kg,hs_code,landed_unit_cost,landed_reason) the response already served. Documentation only; no action needed.Added
Record order book lines(POST /api/v1/order-book/lines), scopeorder_book:write. The order book's write joins the public surface, andOrder bookbecomes a read-and-write resource on the reach matrix. It records bookings a person has already taken; it does not place, transmit or acknowledge an order. Additive; no action needed.Changed how an unrecognised account name is answered on that write, and this is the change that made publishing it safe. Through a key, a line naming an account this book does not hold is now refused
400 No account named {name}.- the whole request, before anything is written. Previously the importer minted a trading partner for any name it did not recognise, which is right for a person uploading a historical book at onboarding and wrong for a script: one retailer spelled two ways would have become two accounts with the season split across both. The import page still mints. No published shape changed.Added
Planningas a resource, withplanning:readand two operations:Get the MFP grid(GET /api/v1/mfp/{mfp_id}/table) andGet the season-pool OTB rollup(GET /api/v1/otb/{mfp_id}/rollup). The plan as published and the open to buy that comes out of it; both read the same plan, so the two cannot disagree. Sold with Pro. Read-only on purpose: two writers on one plan is how a planner's own edit disappears under a sync nobody triggered. Additive; no action needed.Added
Idempotency-Key, an optional request header on every public write. Send the same key twice and the second request is answered with the first one's status and body rather than performing the write again - so a client that retried after a timeout ends holding the record's id instead of creating a second one. Reusing a key with a different body, or while the first request is still running, is refused409. A write that failed releases its key, so the retry is a fresh attempt. Keys answer for 24 hours and are scoped to the API key that used them, so two integrators on one organisation may pick the same strings. Additive; sending no header behaves exactly as before.Added sandbox organisations and
tly_test_keys.POST /api/v1/developer/sandboxcreates a twin of an organisation - same plan, same words, its own tenant - seeded separately and reaching no outside system. A sandbox mintstly_test_keys and nothing else, and atly_test_key resolves against a sandbox and nothing else, so a test credential cannot read a real book whichever organisation it is pointed at. Additive.Added an optional
partner_idwhen minting a key (POST /api/v1/developer/keys). A key issued to one account reaches that account only: reads narrow to it, and a write naming another account is refused403with both accounts named. Omitted, a key reaches the whole organisation exactly as it does today. Additive.Added
movementsandexceptions_openedto the answer ofRecord received goods(POST /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/deliveries),Change a recorded delivery(PATCH …/deliveries/{delivery_id}),Record planned deliveries(POST …/deliveries/bulk) andMark a purchase order delivered(POST …/mark_delivered). Every recorded line now writes its movement to the stock ledger in the same request, and what the receipt could not settle - units over what was ordered, units short of what was expected, a SKU the order does not carry - opens a row in the exception queue.movementsis[{id, variant_id, quantity_delta}]andexceptions_openedis[{id, kind, title}]; both are null on a read, which is not the same fact as an empty list. Additive; no action needed.Changed
Change a recorded deliveryandRemove a delivery recorded in error(DELETE …/deliveries/{delivery_id}): both are refused 409delivery_has_posted_billonce the receipt has been billed to Xero or QuickBooks, and removing a delivery gives its units back to the ledger as a reversal before the document goes. A line naming a product the order does not carry is refused 400variant_not_on_ordernaming the SKU, where it used to answer 404.
2026-09-04
Added three resources - Products, Suppliers and Stocktakes - and with them 51 operations, which completes every resource this contract dates for today. Products is read-only: 19 reads over the catalogue, its products, its variants and their custom fields, minted by
products:read. Suppliers reads and writes: 23 operations, ten reads and thirteen writes over suppliers and their contacts, minted bysuppliers:readandsuppliers:write. Stocktakes reads and writes: nine operations, four reads and five writes, minted bystocktakes:readandstocktakes:write. A write scope includes its read, so a key minted withsuppliers:writealone still lists the suppliers it creates. The contract now describes 118 operations over ten resources, and the ten the Scopes page offers are the tenopenapi/public.jsondescribes: nothing is offered that is not documented, and nothing documented is out of reach. Nothing is deprecated and nothing is removed - every key keeps exactly the reach it had, and a key that already held one of these three scopes now opens what it was always ticked for.Added the Products resource: 19 reads over
/api/v1/products,/api/v1/product,/api/v1/variantsand/api/v1/product-subcategories, with the scopeproducts:read. Products, product, variants and custom fields are one resource rather than four, because they are one catalogue to everybody but the router: one scope opens the product table, a product and its variants, a variant with its stock, suppliers and price history, the custom fields this organisation keeps, and the catalogue's own event timeline. It carries no write scope, and will not: the catalogue is written by the PIM write-back and by the connector that syncs it, and a third writer is how a merchant's own edit disappears under a sync nobody triggered. Every operation states the scope a key needs, carries an example of what it answers, and carries the two refusals a key can meet. Additive; no action needed.Added the Suppliers resource: 23 operations over
/api/v1/inventory/suppliersand/api/v1/contacts, ten reads and thirteen writes, with the scopessuppliers:readandsuppliers:write. Contacts are part of Suppliers rather than a resource of their own, because a supplier's contacts are part of the supplier to everyone but the router: one scope opens the supplier, its terms, its warehouses and its contact list. Every operation states the scopes a key needs, carries an example of what it answers, and carries the two refusals a key can meet. Additive; no action needed.Added the Stocktakes resource: nine operations over
/api/v1/stocktakes, four reads and five writes, with the scopesstocktakes:readandstocktakes:write-Open a stock count(POST /api/v1/stocktakes),Every stock count on file(GET /api/v1/stocktakes/table),One stock count(GET /api/v1/stocktakes/{stocktake_id}),The lines of one count(GET /api/v1/stocktakes/{stocktake_id}/table),Stock variance by supplier(GET /api/v1/stocktakes/variance/by-supplier),Set counted quantities(PATCH /api/v1/stocktakes/{stocktake_id}/counts),Import counted quantities from a file(POST /api/v1/stocktakes/{stocktake_id}/import),Close a count(POST /api/v1/stocktakes/{stocktake_id}/post) andDiscard an open count(DELETE /api/v1/stocktakes/{stocktake_id}). This is the one door a third party has to correct a stock figure: every other way a level moves is an ETL sync, which is whyStockstays read-only and this resource carries the only public write on any stock number. Additive; no action needed.Changed the offered scope set, which gains
products:read,suppliers:read,suppliers:write,stocktakes:readandstocktakes:writeback. All five were removed in this date's entry below, when the contract described none of their 51 operations; every one of those operations is described now, soPOST /developer/keysmints all five again andGET /developer/scopeslists all three resources. No scope is withheld at mint today. A key minted for one of them before the withdrawal, and honoured as stored since, reaches the operations it was always ticked for.Changed the response schema of
List suppliers(GET /api/v1/inventory/suppliers/table). Its rows were described as a hand-written shape carryingcontact_name,contact_emailandcontact_phone, which the API has never served: the rows go through the same payloadGet supplier detailsanswers, so each carries aprimary_contactobject,province,is_archived,lead_time_source,integrations,payment_terms_daysand the three delivery rates. Nothing served changed; the specification was wrong. The 200 also now describes the{message, data}envelope rather than the payload alone.Changed, on
List suppliersandList contacts, which parameters are part of the contract:for_zapieris deliberately not one, for the reason it came offList purchase orders. It answers a second, array-shaped body for one first-party integration, and a published operation with two response shapes is a contract no generated client can hold. It still works on the app's own door; it was never in this contract and will not join it.Added to the reference, on the Suppliers writes, the rules that are only visible from the outside once somebody has been caught by them.
Create suppliersderives a supplier's id from its name and ignores a conflict, so posting a name already on file answers that supplier unchanged rather than updating it.Update supplierignorescontacts, butprimary_contactis a real write:{"id": ...}re-points which existing contact is that supplier's main one, creating the supplier-contact link if it was missing. It creates and edits no person, so the contacts operations remain the only door that writes one.Import suppliersanswers{"status": "success"}andPromote staged vendorsanswers the plain bodyok, neither of them the standard envelope.Get contact note,Update contact noteandDelete contact notefind a note by its id alone, so a note paired with the wrong contact in the path still answers, changes or deletes. Documentation only; no served shape changed.Changed the published contract to strip the
llm-tooltag, as it already strippedpublic. 35 operations carried it. It is a build marker naming an internal consumer - the in-app assistant is offered the same routes - and a generator that emits one client class per tag was turning it into anLlmToolApiholding an arbitrary third of the surface. Each operation now carries its resource tag and nothing else. No path, parameter, body or response moved.Changed nine descriptions that did not match what the routes do, found by reading each one against its service.
List productsandList variantstaughtsort_argsasfield:desc, which the parser refuses 400; the form is the one every other table documents, comma-separated columns with-for descending.Remove variant from suppliersaid a pair not on file answers 204; it is refused 404, so a repeated delete is a 404.Create contact notesaid a note against an unknown contact is accepted; the foreign key refuses it.Get variant price sensitivitydocumented a 404 the route never gives - an unknown id answers the unclassified shape.List SKU eventsandList variant SKU eventsnamed a closed list of 15 event types where the reads serve 17; the list is now stated as open.Update suppliergainedis_archived,clear_variant_supplier_lead_timeand the mail re-sync thatdomainsstarts;Create suppliersnow says the local-part name default only fires when nopreferred_contact_methodis sent. Three examples were corrected to the sentences the wire actually returns. Documentation only; no served shape changed.Changed the description of all 67 operations. They were written for the Copilot on 20 of them, which shares the same specification files through the
llm-tooltag, and a reader was getting a prompt: "OMIT IT unless the user has named a specific Commitment", "useexecute_sqlwith a GROUP BY" (a tool no key can call), 5,561 characters of rendering rules onGet inventory table. Each operation now carries prose written for an integrator, and ends with the scope a key needs, from the same table the door enforces. No shape changed.Changed the documented
doc_urlon every refusal example, fromhttps://developers.tightly.io/api/guides/errors#<code>tohttps://app.tightly.io/docs/api/guides/errors#<code>, which is the link the API actually mints and, unlike the first, a name that resolves. The served value is unchanged; the examples were wrong. It will move once more, todevelopers.tightly.io, when that name exists.Changed the 401 a key that cannot be used receives: it now carries
message.code,message.doc_urlandmessage.request_idlike every other keyed refusal, which is what this changelog and the errors guide already described. Additive on the body; the status, the sentence and theX-Request-Idheader are unchanged. Rate-limit headers are still absent from it, and always will be: they count against a key, and this refusal has none to count against.Changed
Create purchase order. It opens ONE order and takes no line items, which the schema has always said and the description contradicted with an example body namingline_items. Sending that field is refused, as it always was. To create orders with their lines, useCreate purchase orders in bulk.Removed the
for_zapierquery parameter fromList purchase orders. It answers a second, array-shaped body for one first-party integration, and a published operation with two response shapes is a contract no generated client can hold. The parameter still works on the app's own door; it is no longer part of this contract.Removed the
products,suppliersandstocktakesscope rows from the offered set. The Scopes page sold them,POST /developer/keysminted them, and the contract described not one of their 51 operations, so a key minted for Stocktakes could post stock that no documentation covered. The three resources return when their operations are specified. A key minted with one of those scopes now receives403 not_publicon those routes, and every other scope it holds is unaffected.Changed the response examples on
Approve purchase order,Hold purchase order,Issue purchase order,Push purchase order to the warehouseandExport purchase order: each now shows what its service returns rather than an invented shape, and push-to-warehouse names Helm WMS, the one integration the fan-out matches. Examples only; no served shape changed.Added, on
Get purchase orderand the CSV export, the order'scontainer_planand the per-linecartons,cbm,kg,hs_code,landed_unit_costandlanded_reason- the box the order fills, frozen when it is confirmed. All nullable; an order with no lane or no carton on file reads null with the reason beside it. Additive; no action needed.Added
completion_sentenceon a manufacturing order - what completing it did, in one served sentence. Nullable on every other order. Additive; no action needed.Added
fill_containertoUpdate purchase order generation settings: whether the nightly generator tops the last container of a proposal up with whole variants the ranker names. Off unless set; a request that omits it leaves the flag as it was. Additive; no action needed.Added the first 67 operations, over seven resources: Purchase orders (26), Sales (15,
/salesand/sales/velocityalike), Order book (8), Sell-out (6), Accounts (6), Stock (4) and Product data (2). Every one states the scopes a key needs, carries an example of what it answers, and carries the two refusals a key can meet - 401 for a key that cannot be used, 403 for a key that may not reach the operation. Additive; no action needed.Added, on those operations, the path parameters the URL already carried. Seventeen purchase-order operations described
{organization_id}nowhere, so a generated client had no way to fill it. Additive: the parameter was always required on the wire.Changed three response schemas that described a shape the API does not serve - the sales table, the sales-velocity table and the inventory table each declared their alternatives as mutually exclusive when every one of them is the same
{message, data}envelope. None of these operations was published before today, so nothing a reader holds has changed.Changed
Every account's book(GET /api/v1/order-book/accounts): adds the optionalheld_qty, the hold units already insidebooked_qty. Additive; a caller who does not ask for it sees no change.Changed
One account's book, style by style(GET /api/v1/order-book/account-styles): adds the optionalheld_qty_from_account,held_by_nameandheld_at- the units an account put on hold by answering a showing in the portal, and who did it and when. Additive; a caller who does not ask for it sees no change.Changed
Every trading account(GET /api/v1/wholesale/retailers): adds the optionalportal_seats, the buyers a brand has seated in the portal for that account. Additive; a caller who does not ask for it sees no change.
2026-09-03
- Added the artefact itself:
openapi/public.json, built from the code bytightly-cli dump-openapi --publicand drift-checked in CI. It carries theApiKeybearer scheme, the2026-11train, and no operations yet - operations join it as their specifications are finished.