Skip to content

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 categories and months to 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_note and per-commitment forecast_source to 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_axis keys 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 use period_axis to address reported cells.

  • Added optional counts_only on list_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, rows is empty and size is zero. It cannot be combined with for_zapier. The default remains false; existing callers need no change.

  • Added optional commitment_id to 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_record on get_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_declaration on One commitment's plan of record at cost, on its own fiscal months (GET /api/v1/commitments/{commitment_id}/money), scope planning: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 is null where the company has declared neither, on a boxed commitment and on a rolling one alike. Additive; no action needed.

  • Added drafted and received per month, per category, on One commitment's plan of record at cost, on its own fiscal months (GET /api/v1/commitments/{commitment_id}/money), scope planning: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_order is unchanged: placed purchase-order money at its expected delivery month. drafted is 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. received is 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 than on_order for a month. A month the book was read for that holds nothing in a band is a measured 0; all three bands are null together, 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 on What this account did last time, and how each style is likely to do now (GET /api/v1/order-book/account-anchor), scope order_book:read. The baseline engine now predicts per DOOR, and each row carries door_id, name, share, units and rung, the word for the evidence the figure stands on. measured is 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_average is a door that reported none of those styles but does sell the category; account_average is a door that reported neither, which where nothing was measured at all is the level divided by the doors on file; not_measured is a door on a style the engine could not size, and it carries no figure at all. The units add up to the style's own level_units exactly, so the door column and the figure above it cannot disagree. Each row also carries the door's own evidence: sold_last_season as the account reported it, sold_corrected with the shelf accounted for, out_of_stock_days, availability, weeks_reported and styles_reported, because a figure lifted by a third for a shop that was dark for eight weeks has to be able to say so, and availability: null says 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 distinction baseline itself draws one level up. Additive; no action needed.

  • Added predicted_qty, doors_reporting and doors_on_file to every row of Every account's book against its own prior season, read at the same days before start (GET /api/v1/order-book/accounts), scope order_book:read. predicted_qty is 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 with booked_qty and ranking by the difference. commitment_id narrows 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_reporting and doors_on_file are how many doors that prediction stands on, out of how many are open. doors_reporting is 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_file is 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_qty to every row of One account's book, style by style (GET /api/v1/order-book/account-styles), scope order_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_qty to every style on What one account did last time, and how it is likely to do now (GET /api/v1/order-book/account-anchor), and Changed baseline.by_door[] on the same read so every row states its source, scope order_book:read. ordered_qty is 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 carrying door_id, name, door_reference, units, share, rung, rung_label and source, where name is 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. rung is measured (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) or not_measured (the account has no sized level, so every door carries a null figure and never a zero). The rows sum to level_units by construction. source is account_split on every row today, the account's level divided at read time; the engine's own per-door figure carries door_baseline on the same key, with that door's own evidence beside it, and it is served wherever the engine has rows: account_split answers 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 and by_door is empty. The key is ABSENT, with by_door_reason carrying 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), scope order_book:read: prior_sold_qty, prior_available_qty, prior_oos_days and doors now count only what the account itself reported. A derived row 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_id and category to One account's sell-out by door, the grain a plan is actually delivered at (GET /api/v1/sell-out/doors), scope sell_out:read, all optional and composing with AND. variant_id is the SKU, product_id the style, family_id the product family the style joins, and category the 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_week included, 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 measured units: 0, with on_hand and sell_through null, since nothing in the scope was ever counted on that shelf. scope_reason says why a narrowing could not be made, or null: a catalogue with no product families cannot answer family_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_week to every door row and to account_total on One account's sell-out by door, the grain a plan is actually delivered at (GET /api/v1/sell-out/doors), scope sell_out:read. The weeks behind each figure, keyed by the week's own start date, each cell holding units, on_hand and variants. 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 null units. A cell's units add up to the row's units, and a cell's on_hand adds up to nothing, since the row's own on_hand is each variant's last reading. An account reporting one total carries the map on account_total, which is the only week series it has. Additive; no action needed.

  • Changed the plan block on One commitment's plan of record at cost, on its own fiscal months (GET /api/v1/commitments/{commitment_id}/money), scope planning:read: each month under categories now carries what has actually been MARKED DOWN beside what was planned. realised_markdown is at cost, the plan's own basis, and is the only pair that may be subtracted from planned_markdown_to_close_cents; realised_markdown_at_retail is what the season handed over at the till, beside it and never subtracted from a figure stated at cost. markdown_left_to_close_cents is that subtraction, with markdown_left_to_close_reason where it cannot be made. realised_markdown_units and realised_markdown_to_date_cents round out the pair. A month still ahead carries null and never a zero, and realised_markdown_reason says 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_scope to The lines of one count, with the difference (GET /api/v1/stocktakes/{stocktake_id}/table), scope stocktakes:read. all returns every line in the count, differences returns only counted lines whose count differs from system stock (leaving out both the matched lines and the uncounted ones), and uncounted returns only the lines nobody has counted yet - the work left to do on a count part way through. Additive; no action needed.

  • Deprecated variance_only on The lines of one count, with the difference (GET /api/v1/stocktakes/{stocktake_id}/table), scope stocktakes:read, in favour of line_scope=differences. variance_only=true still works, honoured where line_scope is 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), scope sandbox: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 answers 200 whether it wrote or not, with seeded and already_seeded saying which and written counting 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 refused 409 and 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), scope order_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_id is required, and so is one of season_code or commitment_id. Where the account has no booked line in scope, or every line it has names no size, shares_bp is null with a reason rather than an empty object. Additive; no action needed.

  • Changed inventory_value on One product with its stock and the ranges its variants span (GET /api/v1/product/{product_id}) and One variant with its stock and its suppliers (GET /api/v1/variants/{variant_id}), scope products:read: the stock at cost is null when no cost is on file for the variant or its product. It read 0 before, 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), scope cash:read, so planned_out_coverage names measured beside folded: what the cap bounds is the money this read performed, folded plus the cells that ran and then failed, so it is never smaller than folded. 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), scope purchase_orders:read, to the shape the route actually serves. It showed a paged table, offset, size, filtered_max_size and a rows array of orders each carrying a nested supplier_update of kind, proposed_delivery_date, detected_at and source. No field of that name has ever been served here: the answer is items, one entry per pending signal with purchase_order_id, display_name, signal_type and reported_at, beside total_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 items now has its four fields spelled out, signal_type is enumerated (reschedule, change_quantity, ship, cancel, confirm, change_price), reported_at is 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}), scope products:read, so the two absence codes are readable. size_curve.fallback_reason_code was 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), with size_curve.fallback_reason stated to be that code in words and safe to render as it stands. price_band_reason was not named in the reference at all and now is, with its reason of no_category, no_price, category_too_thin or no_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 order and send shipping notice (/api/v1/sales/orders/{order_id} and its cancel, confirm, invoice and shipping-notice doors), scopes orders:write; the three states served apart on get an invoice (GET /api/v1/sales/invoices/{invoice_id}) and the refund leg's null on get customer (GET /api/v1/sales/customers/{customer_id}), scope orders:read; the soft reservation and the two figures behind a mastered hard total on One product's position at a warehouse, decomposed (GET /api/v1/inventory/position); the slice paths and the unmodelled remainder on get forecast accuracy (GET /api/v1/inventory/forecast-accuracy); the optional mapping targets on import returns (POST /api/v1/sales/returns/import); the two not_matched keys spelled where they live on both sell-out doors (POST /api/v1/sell-out/import and /api/v1/sell-out/preview); and the sibling to call instead on open a purchase order, Net sales at category × channel type × period, Stock on hand at each period boundary, How much of this book can still walk and Recognise 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_missing to the constraint keys Create purchase orders from the bench (POST /api/v1/organizations/{organization_id}/purchase-orders/from-basket), scope purchase_orders:write, can refuse with, and changed the sentence in message.desc so 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 a supplier_min_order_value violation short by up to the whole of the supplier's minimum - an order-scoped refusal with line_id, variant_id and variant_title all 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, actual and shortfall are 1, 0 and 1 in eaches - 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_cost on a created line may now be null rather than 0, which the field has always allowed. Additive: scope, unit and bound take no new values, every violation still carries the same sixteen keys, and a client switching on the keys it knows is unaffected.

    key is now declared x-extensible-enum rather than enum, 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 closed enum was 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 own label for one you do not. Nothing has been removed or renamed, and nothing will be without a new date train.

  • Changed not_matched on Recognise a retailer's report and say what would happen, writes nothing (POST /api/v1/sell-out/preview), scope sell_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 back total equal to rows_read with an empty by_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 resolver Land a retailer's own sell-out report against one named account (POST /api/v1/sell-out/import) runs, over the same rows, so by_reason, by_reason_words, examples and total here are what that import then reports on the same bytes. A caller who read not_matched.total as "rows that will not land" was reading rows_read and now gets the real figure; one who treated an empty by_reason as normal will start seeing keys. No field was added, removed or retyped, and rows_written and rows_changed stay 0: the call still writes nothing.

  • Changed the published by_reason example on the same operation. It showed unknown_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 are matched, matched_on_sku_only, invalid_barcode, unknown_product and no_identifier, and the reference for both sell-out operations now names all five and says they are counted for every row, so they sum to rows_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 its examples entry 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 in not_matched.examples when 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_inferred to the variant rows of Get sales table (GET /api/v1/sales/table), scope sales:read. lost_sales_units and missed_revenue have 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 are lost_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_days and lost_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), scope sell_out:read. Each door and the account total now carry value_sold_cents with the currency it is in, and returned. Additive; no action needed.

    value_sold_cents is 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. returned is units brought back and is not subtracted from units: 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 null and value_absent / returns_absent carry the sentence Not reported; reading a null as 0 would claim shoppers spent nothing. Where a window spans two currencies, value_sold_cents is withheld and value_absent says why - adding pence to cents is a number with no unit, and nothing here converts between them.

  • Added reorder_rules to Import a sell-out file (POST /api/v1/sell-out/import), scope sell_out:write. Where the uploaded file also carried the retailer's OWN reorder policy, read says how many (account, variant) rules landed and not_stated how 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. A 0 on 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 weeks to The open buy on each supplier's terms clock (GET /api/v1/commitments/cash-gate), scope cash: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 new served_weeks block says what was served out of what, and every figure outside weeks still 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}), scope sales:read, says about exclude_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_training to the reference for Retrieve paginated table data for sales velocity events (GET /api/v1/sales/velocity/events/table), scope sales: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 baseline example on What one account did last time, and how it is likely to do now (GET /api/v1/order-book/account-anchor), scope order_book:read, so it shows values this read can actually answer. The published example carried basis: measured_at_account and grade: B, neither of which is a value the grade is ever stored as, under the keys doors and weeks where the answer carries doors_reporting and weeks_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), scope order_book:read. Both fields ride on every graded style and neither had its values written down, so no caller could switch on either. basis is the rung the grade was read from, one of account_predecessor, account_kit_member, account_cohort, account_family, brand_analog, sell_in_only or none, in ladder order: the first rung with enough reported weeks at that account votes alone. evidence is how far to trust it, one of measured, uncorrected, borrowed, brand or none. It is evidence and not basis that 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. grade is winner, sleeper or bleeder, and state is graded, magnitude_only, brand_fallback, sell_in_only or refused. Nothing on the wire moved.

  • Changed expected_on on 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) and Get return (GET /api/v1/sales/returns/{return_id}), scope returns:read, and Create return (POST /api/v1/sales/returns), Receive return (POST /api/v1/sales/returns/{return_id}/receive) and Cancel return (POST /api/v1/sales/returns/{return_id}/cancel), scope returns:write. Create return already ACCEPTED 2026-09-12 on the way in and answered Sep 12, 2026 on the way out, so the same field had two shapes on one round trip. received_at, created_at and closed_at beside it are unchanged instants.

  • Changed variant_ids on Get allocation matrix (GET /api/v1/inventory/allocation-matrix), scope inventory: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_out on Get the cash gate (GET /api/v1/commitments/cash-gate), scope commitments:read, to be NULLABLE, and added contracted_out_source beside 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 read null as "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 as 0.0 in that state the ladder said the book takes nothing out of the bank all year beside an open buy of millions. contracted_out_source says once which leg spoke: placed, freight, placed+freight, no_open_buy, or null where neither could, which is the only reading in which the weeks are null too. no_open_buy is the opposite reading and a measured zero: the order pad is empty, so nothing leaves the bank on that line.

  • Added split to every row of List purchase orders (GET /api/v1/organizations/{organization_id}/purchase-orders), scope purchase_orders:read: where that order's units stand, as recorded, recommended or nowhere_to_go. It is null on 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 reads recommended even where its recommendation would leave units with no destination. Additive; no action needed.

  • Added row_grain to the response of Get sales velocity table (GET /api/v1/sales/velocity/table), scope sales:read. It says what grain the rows you were served are, variants or products. It is a property of the envelope rather than an echo of the type you 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_title to every style on List account styles (GET /api/v1/order-book/account-styles), scope order_book:read. The catalogue's name for the style, falling back to the variant's own, and null where neither carries one - a name is said or absent, never built out of style_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 rate on Get returns summary (GET /api/v1/sales/returns/summary), scope returns:read, to rate_points, and the number it carries from a fraction to POINTS: 2.4 is 2.4%, where the old rate would have said 0.024. A caller reading rate has to move to rate_points and stop multiplying by 100. The unit is in the name deliberately: served as a bare rate holding a fraction, the one percent formatter a face reaches for printed 0.02% - a hundredth of the truth, and plausible enough that nobody queries it. rate_points is null with rate_reason beside it where nothing shipped in the window, exactly as rate was: a return rate over no shipments is not 0%. The read also gains as_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), scope orders:read, answers, to carry the whole house paging shape: offset, size and max_size now sit beside the filtered_max_size it already served, and as_of states when the page was read. filtered_max_size is what the filters counted and max_size the 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_at and lifecycle_updated_at on 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) and Create sales order (POST on that same path), scopes orders:read and orders:write, and the orders[] an account carries on Get customer (GET /api/v1/sales/customers/{customer_id}), scope orders: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_at and last_order_at on 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, to List 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 a lifecycle, 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's exceptions_open counts. Additive; no action needed.

  • Changed limit on List 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, and filtered_max_size states 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 on Get return (GET /api/v1/sales/returns/{return_id}), scope returns:read, and Receive return (POST /api/v1/sales/returns/{return_id}/receive), scope returns:write. unit_cost_cents and currency are replaced by a single unit_cost money 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_title and location_name are served beside the ids they belong to, so a table cell prints a SKU and a warehouse rather than two opaque keys. A caller reading unit_cost_cents or currency has to move to unit_cost.

  • Added retail_price_amount to each purchase order line, on Get purchase order (GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}, scope purchase_orders:read), Update purchase order (PATCH on that same path) and Duplicate purchase order (POST .../duplicate), the last two scope purchase_orders:write. All three answer with the same order object, so all three carry it. It is the retail counterpart of the unit_cost already on the line, read off the line's own purchase_order_line_items.retail_price_amount and 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 taxes and total_sales on Get sales table (GET /api/v1/sales/table, scope sales:read): both may now be null, on the same terms discounts already is. sale_orders.total_tax_amount is nullable, and a book that records no tax was served 0 for both, which reads as a measurement nobody made and put a total below its own net. A recorded 0.00 still serves 0. 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-Key the contract states for Record a return expected back (POST /api/v1/sales/returns, scope returns:write): it published maxLength: 120 where the server has always taken 255, which is the length every other write on the surface takes and the length its twin Create 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_excludes until somebody had typed the grant onto that organisation's record. They are The open buy on each supplier's terms clock (GET /api/v1/commitments/cash-gate, scope cash:read); Cash from the buy, a year of weeks on the terms clock, The fiscal year week by week, against the plan and the envelope and The Monday pack for one 4-5-4 retail week (the three GET /api/v1/finance/reports/... reads, scope reports:read); One commitment's plan of record at cost, on its own fiscal months and Every locked version of one commitment's plan of record (GET /api/v1/commitments/{commitment_id}/money and the /plan/versions beneath it, scope planning:read); and 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, scope purchase_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 read Sold 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 holding cash:read, reports:read, planning:read or purchase_orders:read needs nothing done to it - and nothing below Pro reaches any of the seven. If you integrate against a Pro customer that was answering 403 plan_excludes here, 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:read operations and the twenty-eight operations sold with Tightly Connect - every operation on accounts, order_book and sell_out, and the six wholesale ones on orders - refused 403 plan_excludes on 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_reason and status_audit to the purchase order a caller reads back, across four operations. Get purchase order (GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}, scope purchase_orders:read), Update purchase order (PATCH on that same path) and Duplicate purchase order (POST /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/duplicate), the last two scope purchase_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, scope purchase_orders:read) gains on_hold and hold_reason on every row and does not carry status_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_hold is 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_reason is the holder's own words, set whenever on_hold is 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_audit is the append-only log of intent, one entry per act with event, actor, reason, via and at, where actor is a user id or api_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 carry on_hold and hold_reason. Neither carries status_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_hold is 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_source to the export=true response of get sales velocity table (GET /api/v1/sales/velocity/table) and to get variant bundle contributions (GET /api/v1/sales/velocity/variants/{variant_id}/bundle-contributions), both scope sales:read. It is ch when the analytics mirror served the read and pg when PostgreSQL did, the same two words the paged read of get sales velocity table has 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_id reaches 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) and Every EDI document in or out (GET /api/v1/wholesale/edi/documents) narrow trading_partner_id to the key's account and refuse another 403 account_mismatch; Create a sales order and File one order a retailer sent take 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 orders is 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 without partner_id, and a signed-in session, are unchanged. List the people who have bought gains an optional trading_partner_id filter, one account's own record, which is what the narrowing rides on. Additive for every unbound key; no action needed.

  • Changed Idempotency-Key on every public write: only a 2xx answer is remembered. A write that answered 4xx or 5xx now releases its key the way a write that raised always did, so a retry after a 502 or after the in-flight 409 is 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) and Record a return expected back (POST /api/v1/sales/returns): the thirty-day Idempotency-Key these 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's PO-1001 could be answered with another's order, or refused 409 as a reuse of it. A signed-in session's keys keep the organisation's scope. No shape changed.

  • Changed the 503 a public write meets when the idempotency store cannot be reached: it now carries Retry-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) and Answer 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 carries created_by of api_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_id filter to A page of the product catalogue, one row per product (GET /api/v1/products/table) and A page of variants, one row per SKU (GET /api/v1/variants/table), both scope products:read. It takes eq and in, the shape vendor and category already have on these two reads. The key was not in either allowlist before, and an unknown key refuses the whole request 400 rather 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, so in answers 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_absence and price_sensitivity_absence_word on the variant rows of Get sales table (GET /api/v1/sales/table), scope sales:read, to admit null to the enumeration each one publishes. All three have been documented as nullable since they landed, and price_sensitivity_absence is 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, and null is admitted at the end of each list rather than among them.

  • Changed grain on CustomField, served by List custom fields (GET /api/v1/variants/custom-fields) and Get custom field (GET /api/v1/variants/custom-fields/{id}), scope products:read, to admit null to 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 only product and variant. 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 null and the contract says so on BOTH halves - the type and the enumeration. It is not spelled by omitting the key. A null is 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 - terms on Create invoice, reason and refund.timing on Create return, and lines[].disposition on Receive 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), scope sales: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 that price_sensitivity was there at all. The example row now carries all four, a scored line with its price_sensitivity_computed_at stamp and both absence fields null, which is the pairing rule from the side most rows are on. The summary also says what a type=products page does not carry: a product row has no pricing columns at all. No action needed.

  • Added withheld_words to Get a sales order (GET /api/v1/sales/orders/{order_id}) and to List customers (GET /api/v1/sales/customers). It reads Not held on a tenant whose storage may not hold a shopper at all, and null on one whose may. Without it a null customer, a null ship_to and 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 reads Not held whatever 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 turned Keep customer details on, 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_absence to the variant rows of Get sales table (GET /api/v1/sales/table), scope sales:read, and documented price_sensitivity and price_sensitivity_computed_at beside it, which were already served and had never been described. A null price_sensitivity has 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_absence is set only where the tier is null, and is not_run, not_scored or not_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, and GET /api/v1/variants/{variant_id}/price-sensitivity answers it with the sentence for a line a reader opens. price_sensitivity_absence_word is 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_sensitivity on the variant rows of Get sales table (GET /api/v1/sales/table), scope sales: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 to STILL_LEARNING covers 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_hand figure that One account's sell-out by door (GET /api/v1/sell-out/doors), scope sell_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 every sell_through computed against it was wrong by the same factor. units is a flow, so a window of it is that window's sales; on_hand is 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 under on_hand and sell_through do, 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}), scope products:read, serves when the size curve on a style is the labelled default because the engine refused to measure one. size_curve.fallback_reason was 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_code is now a closed token beside it, one of 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 or one_size_sold, and the sentence is derived from it at read time. The sentences are unchanged, moved rather than rewritten, so a reader rendering fallback_reason sees exactly the words it saw yesterday. The code is null on a refusal recorded before the codes existed, where the sentence is that stored prose; both are null where nothing was refused. Additive; no action needed.

  • Added grades_state and grades_reason to the response header of What one account did last time (GET /api/v1/order-book/account-anchor), scope order_book:read. A style with no grade has always been served with the baseline key 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_state is served, not_on_plan or unreadable, and grades_reason carries a sentence for not_on_plan only, the same text a 403 plan_excludes serves. It is a different question from engine_state beside 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 403 on the nineteen Products and Variants operations says about the plan door. Every one of them read that the public API itself is 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 network parameter to List EDI documents (GET /api/v1/wholesale/edi/documents), scope accounts: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. Pass network=sps_commerce for the EDI lane alone; the other four values are email, upload, link and portal, and omitting the parameter serves every lane exactly as it does today. A lane outside that set is refused with a 400 naming 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/products and GET /api/v1/pim/v1/products/{product_id}, say sells them, and the sentence their 403 serves. Both read sold with the Product Data add-on, and the 403 example read It 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 says included with Essentials+ and Pro and the refusal says It 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_with value the reach matrix serves for the pim resource (GET /api/v1/developer/scopes), from pim_addon to essentials_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 literal pim_addon for that one row reads essentials_plus instead; every other row is unchanged.

  • Added Send the account this order's shipping notice (POST /api/v1/sales/orders/{order_id}/shipping-notice), scope orders:write and 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. See docs/api/orders.md.

  • Added Invoice this order for what shipped (POST /api/v1/sales/orders/{order_id}/invoice), scope orders:write and 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_cents and freight_cents are 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), scope orders:read. The receivable side of the record, newest first, filtered by account, state or order, for reconciling accounts receivable against what shipped. It joined the Orders resource rather than opening an invoices one, 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}), scope orders: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), scope orders: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. See docs/api/orders.md.

  • Added List EDI documents (GET /api/v1/wholesale/edi/documents), scope accounts: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 200 carries 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}) serves reading, the reader's own stored frame that facts, lines, matched and header_match are 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 403 on 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, so plan_excludes is 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 refused plan_excludes on every operation, these nineteen among them. The sentence reads This 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. Their 403 said 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, list ip_not_allowed beside scope_missing and plan_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, scope purchase_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}) and Take 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, and GET /api/v1/sales/orders/{order_id} gains a document block naming it. The account is named in account_id and is never read off the letterhead. All four need orders:read or orders:write and a plan that includes Tightly Connect, which is sold with Essentials+. Additive; no action needed. See docs/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), scope reports:read: returns is now returns_value, markdown is now markdown_value, and intake_margin is now declared_intake_margin. Each old key was also a word in the metric dictionary that names a different thing -- returns a unit count, markdown a rate, intake_margin a 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 (Returns reads Returns value, Intake margin reads Declared 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_source and projected_in_reason to the reference for The open buy on each supplier's terms clock (GET /api/v1/commitments/cash-gate), scope cash:read. Both ride on every answer and neither was written down. They say whether the weeks ahead were projected at all: projected_in_source is forward_curve where the forward curve was read and null where it was not, and projected_in_reason carries 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 carry settled_in and basis null, 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", read projected_in_source instead: null there means unread, not zero. No shape moved.

  • Changed the figures chase_reserve and open_to_commit_envelope answer with, on POST /api/v1/metrics/query and GET /api/v1/metrics, scope metrics:read. chase_reserve is 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_envelope gains 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), scope reports:read, which was published earlier today. The cover figure on the head is keyed cover_forward, not cover. It is struck in weeks, and cover is 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 carried cover should read cover_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}/money and its /plan/versions) and the three trade reports each said they were sold with Commitments and then showed a 403 example reading It 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 Pro and then showed a 403 example reading It is sold with Essentials+. One operation, two answers to the same question. Every one of them now reads Sold with Essentials+, and names the capability the product's own word, Tightly Connect, rather than the wire's wholesale. 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_mismatch to the 403 of 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 at GET /order-book/account-styles and GET /order-book/account-anchor; the reads and writes that take an account refuse one that names another, in trading_partner_id, in account_id or in the path. Additive; no action needed.

  • Changed the 403 on Record order book lines (POST /api/v1/order-book/lines), scope order_book:write, which named the wrong code for the same refusal. It said a key issued to one account that names another is refused forbidden and that the code is not one a caller can branch on. It is account_mismatch and it is. The wire has answered that since yesterday; only the reference still said otherwise.

  • Changed the 403 on Get purchase order commitment draw (GET /api/v1/organizations/{organization_id}/purchase-orders/{purchase_order_id}/commitment-draw), scope purchase_orders:read, which said plan_excludes is served when Purchase 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/products and GET /api/v1/pim/v1/products/{product_id}, say they are sold with. Each read sold with the PIM add-on above a 403 example reading It 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 403 of 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), scope accounts: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 refused File format not supported. Upload a CSV file., Get product subcategories answers desc The subcategories on file, a variant's price-sensitivity summary now reads Sales dropped ~11% when price increased. rather than leading with the condition, and the two sell-out desc sentences read preview only; nothing has been imported and recorded; 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 read message.code instead.

  • Changed the 401 on the two operations of the PIM key door, GET /api/v1/pim/v1/products and GET /api/v1/pim/v1/products/{product_id}: data.error now reads The API key is not valid., the sentence every other operation answers with key_invalid, instead of Invalid 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_mismatch when 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 on message.code sees exactly what it saw.

  • Changed the code on three refusals, so each one can be branched on. A reused Idempotency-Key sent with a different request now answers message.codeidempotency_conflict, and one sent while the first request is still running answers idempotency_in_flight with Retry-After: 1. Both are 409 and both used to carry conflict, 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 answers account_mismatch rather than forbidden. No status moved, no sentence moved and no shape moved; a client that branches on the status or reads desc sees 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), scope purchase_orders:read, to ignore for_zapier when 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) and List contacts (GET /api/v1/contacts/table), scope suppliers:read, the same way: for_zapier is 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-Id and X-Tightly-Region on each answer it can give, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every 2xx, and Retry-After on 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 429 and a shared 503 to all 121 operations. Both were always possible on any of them and neither was declared. 429 is the key spending its 600 reads or 120 writes a minute; 503 is the database briefly out of reach. Each carries the usual envelope, a code of rate_limited or POSTGRES_UNAVAILABLE, and a Retry-After saying how long to wait. A generated client gains two branches it could not see before. Additive; no action needed.

  • Added Tightly-Version as 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 beside Idempotency-Key, declared on every write the day before, in the reference's shared parameters, 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 three X-RateLimit-* and Retry-After join X-Request-Id and X-Tightly-Region on 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 Orders resource, scope orders:read: List customers (GET /api/v1/sales/customers) and Get 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. A search term 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. See docs/api/orders.md.

  • Changed The open buy on each supplier's terms clock (GET /api/v1/commitments/cash-gate): adds the optional scenario parameter and, when it is given, a top-level scenario block. With it the same ladder is served with one line moved: planned_out becomes 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 in openapi/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 are plan.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_declared and reports.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) and Every 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 no planning:write and 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 refuse plan_excludes without it. Get the MFP grid now reads The 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 cash resource and the scope cash: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, and contracted_out and planned_out overlap by construction, so adding them counts the same buy twice. Sold with Commitments. Additive; no action needed.

  • Added the reports resource and the scope reports: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) and Cash 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 metrics resource and the scope metrics:read, over two reads: The metric dictionary (GET /api/v1/metrics) and Compile 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 costs metrics:read and not a write, because it asks a question and changes nothing: there is no metrics:write and 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 takes Idempotency-Key for 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_excludes refusal 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+. The code is unchanged (plan_excludes), the status is unchanged (403), and the refusal is raised on exactly the same conditions. A client that branches on message.code needs no action. A client that matched on the text of message.desc has 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 pro and now read Essentials+: 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/scopes reports the same move in its sold_with cell, as the token essentials_plus.

  • Added a not_assessed grade 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), scope order_book:read. A style whose baseline carries state: not_assessed with a reason_code and a sentence is 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 of baseline: null, which goes on meaning the pass ran and never assessed that style. Three absences, three answers: not_assessed with a reason, null for 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: null means. 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 values engine.state can 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-Key as 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 every POST, PATCH, PUT and DELETE, referencing one shared IdempotencyKey component, 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_start and cancel_date wherever a purchase order carries them: Create a purchase order, Update a purchase order, Get a purchase order and List purchase orders. Both were published as bare date strings, so which end of the ship window each named was a guess. ship_window_start is the first day the vendor may ship, which is not the day the goods are expected (expected_delivery_date); cancel_date is 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 engine to How this variant's sales have moved when its price moved (GET /api/v1/variants/{variant_id}/price-sensitivity), scope products:read. It is the block every engine read on the platform now carries: name, state (one of not_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, borrowed or prior), confidence (a word, never a percentage) and run_id. A store the engine has never reached carries only name and state. 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), scope order_book:read, so the header says which morning its grades are from: grades_as_of is the completion stamp of the last grading run that succeeded, engine_state is that engine's state (never_ran, not_measured, running, fresh, stale, partial, failed or not_applicable), next_run_at is when it runs again, and engine carries the same facts in the same block shape the price read uses. One difference in that shape is worth coding for: here state is 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_reason to One product, with its catalogue fields, its stock and the ranges its variants span (GET /api/v1/product/{product_id}) and to every row of A page of the product catalogue, one row per product (GET /api/v1/products/table), scope products: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_count to every row of A page of the product catalogue, one row per product (GET /api/v1/products/table), scope products: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 503 with a Retry-After header naming the seconds to wait and message.desc reading "The database is unavailable right now. Try again in a few seconds."; the same conditions used to answer 500 with "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 refused 503 too, 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 on code, which is POSTGRES_UNAVAILABLE or SERVICE_UNAVAILABLE depending on which seam refused. Nothing about any operation's shape moved and no train was opened.

  • Added X-Tightly-Region to every response, refused or not, beside the X-Request-Id that was already there. It names the home that served the call, us for 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 in dump_openapi.py for whoever owns the build.

  • Added currency to seven order-book reads, scope order_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, and One 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: functional is the book's own currency and the denomination every amount carries, so a _usd suffix in a key name is that functional currency and never a claim of dollars; transaction is what the lines were agreed in where they agree on one and null where they do not; reporting is what a reader of this organisation asked to see, with a dated rate between the last two, served and never applied. basis is same, converted or unconverted and reason carries the absence word. What is actually walking (GET /api/v1/order-book/signals) does not carry it. Additive; no action needed.

  • Added week_grid on each bucket and a week_grids map to the sales velocity table's totals (GET /api/v1/sales/velocity/table/totals), scope sales: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 carry week_grid: null and an empty week_grids, because those buckets are not weeks. Additive; no action needed.

  • Added kind, edi and brands to every supplier read, scopes suppliers:read and suppliers: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 as A 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 lanes and Create suppliers, with their contacts, in one call. kind is factory, vendor or distributor, and null when nobody has stated one, which is what an import from a connector always means. edi is the identifiers an EDI trade runs on, {trading_partner_id, vendor_number, account_number}, where vendor_number is how Tightly numbers them and account_number is how they number Tightly; it is null and never an empty object when nobody has recorded any. brands is the brands bought from that counterparty, each with brand_id, name and owned for private label, and an empty list is a real answer. The update takes kind, edi and brand_ids to state them: edi and brand_ids are stated whole, so what is sent replaces what is on file, edi: null or [] clears, and omitting a key leaves it alone. edi_stated and kind_stated on the answer are server-set and are not fields to send. The create takes kind only. Additive; no action needed.

  • Added ship_window_start and cancel_date to purchase orders, scopes purchase_orders:read and purchase_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. A cancel_date before ship_window_start is refused 400 The cancel date cannot be before the first ship date. They are accepted on open a purchase order and update purchase order and answered on both, on list purchase orders, on get purchase order and on duplicate purchase order. Additive; no action needed.

  • Added label, grain, bound_to and used_in on The custom fields this organisation keeps on its variants (GET /api/v1/variants/custom-fields) and on Get custom field (GET /api/v1/variants/custom-fields/{custom_field_id}), scope products:read. label is what a person calls the field and name is the key its values are stored under, so print the label. grain says which bag holds the values, product or variant, and is null where the definition does not say. bound_to names 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 with field_read_from_source. used_in counts 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), scope suppliers: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_body and email.quoted_body come back null and email.body_withheld says so; a mailbox shared with Tightly only has its messages left out of the section, and the item then carries email: null with its order, status and message_key unchanged. 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, tag and waits_on_you are 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_body and body_withheld are 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 happen and Land 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_on and we_read_it_as.week_anchor_dow name 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. period is 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 on needs_you that 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): confirmed now travels beside mapping. An account that has answered the four but that no format profile recognises used to get its column proposal with confirmed null, 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), scope suppliers: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 into clean_body, quoted_body and body_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 Movements resource, scope movements: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 refused filter_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. See docs/api/movements.md.

  • Added One product's position at a warehouse (GET /api/v1/inventory/position), scope inventory:read. What is there, what is held, what is coming and what is left to sell, with the derivation of each: on_hand with the name of which figure it is, reserved broken down by why each unit is spoken for and by which order holds it, expected_inbound broken down by the order it is coming on, and atp. The identity on_hand - reserved.hard + expected_inbound.quantity + expected_inbound.returns_expected = atp holds on every answer. Additive; no action needed. See docs/api/movements.md.

  • Added the Orders resource, scopes orders:read and orders: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) and Import 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 an Idempotency-Key and 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. See docs/api/orders.md.

  • Added the Returns resource, scopes returns:read and returns: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) and Import 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 refused refund_is_the_channels. The record requires an Idempotency-Key and keeps it 30 days; the receipt needs none, because recording the same figures twice writes the ledger once. Summarise returns serves the 30-day return rate and both figures it is made of, so no caller computes one of its own. Additive; no action needed. See docs/api/returns.md.

  • Changed what an order does about stock, on Create a sales order, Change a sales order, Cancel a sales order and Allocate 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, and lines[].reservations on every answer says what each line holds and where. A line the shelf cannot cover holds what it can and records the rest as shortfall; 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 order gains 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 mapping to the 200 on Recognise a retailer's report and say what would happen (POST /api/v1/sell-out/preview), together with the optional query parameter file_name. A retailer whose report no format profile recognises used to come back refused, with nothing to do but send us the file. Given account_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, in mapping. recognised_as and refused are both null on that answer, because nothing claimed the file and nothing was turned away. file_name records 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 happen and Land 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, and recognised_as names 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 of not_declared, no_door, mixed_currency, unreadable_file, no_columns or nothing_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 the container_plan an 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), scope order_book:write. The order book's write joins the public surface, and Order book becomes 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 Planning as a resource, with planning:read and two operations: Get the MFP grid (GET /api/v1/mfp/{mfp_id}/table) and Get 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 refused 409. 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/sandbox creates a twin of an organisation - same plan, same words, its own tenant - seeded separately and reaching no outside system. A sandbox mints tly_test_ keys and nothing else, and a tly_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_id when 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 refused 403 with both accounts named. Omitted, a key reaches the whole organisation exactly as it does today. Additive.

  • Added movements and exceptions_opened to the answer of Record 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) and Mark 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. movements is [{id, variant_id, quantity_delta}] and exceptions_opened is [{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 delivery and Remove a delivery recorded in error (DELETE …/deliveries/{delivery_id}): both are refused 409 delivery_has_posted_bill once 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 400 variant_not_on_order naming 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 by suppliers:read and suppliers:write. Stocktakes reads and writes: nine operations, four reads and five writes, minted by stocktakes:read and stocktakes:write. A write scope includes its read, so a key minted with suppliers:write alone still lists the suppliers it creates. The contract now describes 118 operations over ten resources, and the ten the Scopes page offers are the ten openapi/public.json describes: 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/variants and /api/v1/product-subcategories, with the scope products: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/suppliers and /api/v1/contacts, ten reads and thirteen writes, with the scopes suppliers:read and suppliers: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 scopes stocktakes:read and stocktakes: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) and Discard 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 why Stock stays 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:read and stocktakes:write back. 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, so POST /developer/keys mints all five again and GET /developer/scopes lists 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 carrying contact_name, contact_email and contact_phone, which the API has never served: the rows go through the same payload Get supplier details answers, so each carries a primary_contact object, province, is_archived, lead_time_source, integrations, payment_terms_days and 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 suppliers and List contacts, which parameters are part of the contract: for_zapier is deliberately not one, for the reason it came off List 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 suppliers derives 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 supplier ignores contacts, but primary_contact is 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 suppliers answers {"status": "success"} and Promote staged vendors answers the plain body ok, neither of them the standard envelope. Get contact note, Update contact note and Delete contact note find 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-tool tag, as it already stripped public. 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 an LlmToolApi holding 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 products and List variants taught sort_args as field:desc, which the parser refuses 400; the form is the one every other table documents, comma-separated columns with - for descending. Remove variant from supplier said a pair not on file answers 204; it is refused 404, so a repeated delete is a 404. Create contact note said a note against an unknown contact is accepted; the foreign key refuses it. Get variant price sensitivity documented a 404 the route never gives - an unknown id answers the unclassified shape. List SKU events and List variant SKU events named a closed list of 15 event types where the reads serve 17; the list is now stated as open. Update supplier gained is_archived, clear_variant_supplier_lead_time and the mail re-sync that domains starts; Create suppliers now says the local-part name default only fires when no preferred_contact_method is 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-tool tag, and a reader was getting a prompt: "OMIT IT unless the user has named a specific Commitment", "use execute_sql with a GROUP BY" (a tool no key can call), 5,561 characters of rendering rules on Get 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_url on every refusal example, from https://developers.tightly.io/api/guides/errors#<code> to https://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, to developers.tightly.io, when that name exists.

  • Changed the 401 a key that cannot be used receives: it now carries message.code, message.doc_url and message.request_id like every other keyed refusal, which is what this changelog and the errors guide already described. Additive on the body; the status, the sentence and the X-Request-Id header 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 naming line_items. Sending that field is refused, as it always was. To create orders with their lines, use Create purchase orders in bulk.

  • Removed the for_zapier query parameter from List 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, suppliers and stocktakes scope rows from the offered set. The Scopes page sold them, POST /developer/keys minted 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 receives 403 not_public on 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 warehouse and Export 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 order and the CSV export, the order's container_plan and the per-line cartons, cbm, kg, hs_code, landed_unit_cost and landed_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_sentence on a manufacturing order - what completing it did, in one served sentence. Nullable on every other order. Additive; no action needed.

  • Added fill_container to Update 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, /sales and /sales/velocity alike), 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 optional held_qty, the hold units already inside booked_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 optional held_qty_from_account, held_by_name and held_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 optional portal_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 by tightly-cli dump-openapi --public and drift-checked in CI. It carries the ApiKey bearer scheme, the 2026-11 train, and no operations yet - operations join it as their specifications are finished.

Tightly API, version 2026-11.