API changelog
API changelog
Developer-facing changes to the /api/v1 REST API, newest first. Non-breaking additions
(new optional fields, new endpoints) are noted for awareness; anything that changes an existing
response or request shape is called out as Changed.
2026-09-03 — invoice archive
Added. PIE now keeps its own durable copy of every invoice it issues and serves it from there, rather than linking out to the payment provider. § 147 AO / § 257 HGB require ten-year retention and that obligation is ours; a provider link is a document somebody else holds, on their terms, for as long as they choose.
GET /api/v1/billing/invoices/{id}/document?format=pdf|payload— returns{ url, expiresInSeconds }, a short-TTL presigned link to our archived copy. Do not store or cache the URL; request it again instead. Each call is audited.404for an invoice that does not exist or belongs to another tenant (deliberately not403, which would confirm the id exists);409 INVALID_STATEwhen the archive has not landed yet. Requiresbilling:read.POST /api/v1/admin/orgs/{id}/invoices/upload-urlandPOST /api/v1/admin/orgs/{id}/invoices— platform-tier only. Record an invoice issued outside the payment provider (an Enterprise agreement) into the same archive. The invoice id and storage key are minted server-side and cannot be supplied by the caller; the confirm step verifies the uploaded object exists before recording a row that points at it. Amounts are in minor units, like every other invoice.
Changed. GET /api/v1/billing/invoices items gained two optional fields.
archived(boolean) — true once our own copy exists.documentUrl(string | null) — the PATH to the document endpoint above, ornull.hostedUrlandpdfUrlare unchanged and remain the fallback: they are what a client should offer whilearchivedis false, so a customer is never left without a document. PreferdocumentUrlwhenever it is present.
Changed. Three more provider webhook event types are now handled internally
(invoice.finalized, invoice.marked_uncollectible, invoice.voided). No request or response
shape changes; the effect visible to a client is that a finalized-but-unpaid invoice now appears
in GET /api/v1/billing/invoices, where previously it did not appear at all.
2026-09-02 — self-serve billing, VAT, and annual plans
Added. Tenant-facing billing endpoints, all own-org with the organisation taken from the authenticated token only.
GET /api/v1/billing/subscription— the current subscription (ornull), plusavailablePlans: the tiers you can buy right now, priced net of VAT, filtered to intervals that have a configured provider price.portalAvailableandselfServeAvailabletell a client which controls to render rather than making it guess.GET /api/v1/billing/invoices— cursor-paginated, newest first. Amounts are in minor units exactly as the provider reports them.hostedUrl/pdfUrlare long-lived provider URLs, not presigned URLs of ours.POST /api/v1/billing/checkout-sessionandPOST …/portal-session— return a provider-hosted URL to follow. Both requirecreateonbilling; starting a session leads to a charge, soreadis the wrong verb. Redirect URLs are derived server-side and are not accepted from the request.- There is deliberately no
PATCH /api/v1/billing/subscription. Upgrade, downgrade, cancel and payment-method changes all happen in the provider's customer portal.
Changed. X-Entitlement-Warning is now readable by browsers.
- The header has been emitted on responses approaching a plan ceiling since P4-SPEC-08, but CORS
never exposed it, so no cross-origin client could read it.
Access-Control-Expose-Headersnow names it, along withX-Request-ID— quote the latter to support.
Changed. POST /api/v1/public/signup provisions on the Free tier.
- It previously provisioned Starter regardless of what was requested. Paid tiers are now reached
from Settings → Billing via checkout. The
planCoderequest field is removed; it was always overridden server-side, and a client still sending it is ignored rather than rejected.
Changed. Prices are net of VAT.
- Published prices exclude VAT. Where VAT applies it is added at checkout; EU businesses supplying a valid VAT ID are reverse-charged. Annual billing is available at ten months' price.
Notifications. Four billing events were added — invoice paid, subscription cancelled, payment recovered, and plan limit reached. The last is un-opt-out-able: it fires when work has just been refused.
2026-08-19 — video assets
Added. The asset library accepts video.
POST /api/v1/assets/upload-urlacceptsvideo/mp4,video/webmandvideo/quicktime, with a 100 MB limit each.assetTypegainsvideo, andAssetView.assetTypecan now return it.assetTypemust agree withmimeType: avideo/*type requiresassetType: "video", and animage/*type requires"image". A disagreement returns 422. Document types are unconstrained, so a PDF may still bedocument,certificateorother.- Presigned upload URLs for video are valid for 1 hour rather than 10 minutes — 100 MB does not
reliably transfer inside the shorter window on a slow connection, and a PUT that outlives the
window cannot be confirmed. Read
expiresAtrather than assuming a fixed value. - Video is stored and served as-is. There is no transcoding and no poster frame:
POST /api/v1/assets/{id}/renditionsstill returns 422 for anything that is not an image, and no thumbnail is generated on upload.
2026-08-19 — asset uploads are validated against the stored file
Changed. POST /api/v1/assets/confirm now validates the object that was actually uploaded,
not the numbers in the request body.
sizeBytesin the confirm body is ignored (and is no longer required — it stays accepted so existing clients keep working). The size that is stored, and that counts against your plan's storage entitlement, is the one the object store reports.- The size limit is enforced against that real size. A confirm whose stored object exceeds the limit returns 422, and the uploaded object is deleted — a refused upload no longer leaves bytes behind.
- The leading bytes of the file are checked against the declared MIME type. A file whose content does not match what was declared is rejected with 422 and deleted.
- The PDF limit is now 50 MB (was 100 MB). Images are unchanged at 20 MB. The limit applies at confirm time, so already-stored assets are unaffected.
Added. POST /api/v1/assets/upload-url accepts an optional sizeBytes. Supply it and an
oversized upload is refused before any bytes are transferred, instead of after.
Changed. A brand portal's branding.logoAssetId is only honoured when it refers to an image.
Pointing it at a PDF or other non-image asset now omits the logo from the published portal instead
of publishing that file. Published portal logos are served without authentication, so this was a
way to make any library asset public.
2026-08-19 — transformRules removed from the channel API
Changed. The transformRules field is gone from POST /api/v1/channels,
PATCH /api/v1/channels/{id}, and every channel response body.
- It was accepted, stored and echoed back, but the syndication engine never read it — a channel's
transformation has always been driven entirely by
attributeMap. - Sending it now returns 422: channel request bodies reject unknown fields.
- Nothing it reserved is lost. Channel-wide defaults and constants are expressible today through
the
defaultandconstanttransform operations insideattributeMap.
If you were sending transformRules, remove it from the request. If you were reading it from a
response, you were reading a value that was always {}.
2026-08-13 — search facets, publication verdicts, and a behaviour change on syndication
Added. GET /api/v1/search/products gains faceting.
facetBy— comma-separated, from a fixed allowlist:categoryId,categoryName,tags,status,qualityScore,supplierName. Anything else is a 422. When supplied, the response gainsfacets: [{ "field": "...", "counts": [{ "value": "...", "count": 12 }] }].qualityBand—red|amber|green, filtering on data completeness.qualityScoreis faceted as those same three bands (not raw 0–100 values), using the thresholds the quality dashboard already uses: green ≥ 90, amber 60–89, red below 60.- Product documents gain optional
qualityScore(the minimum completeness across the supplier's enabled locales — absent when the product has never been scored) andsupplierName, which the buyer catalogue previously rendered as—because it was declared but never indexed. categoryName— a new filter param. ThecategoryNamefacet returns names, so this is what makes that facet actionable (categoryIdstill takes a UUID).- Facet counts are always computed inside your own access scope, so they never reveal the existence of products you cannot read.
Added. POST /api/v1/rendition-presets/{id}/reprocess → 202 { enqueued, hasMore }.
Re-renders the renditions a preset edit made stale, in bounded pages — call until hasMore is false.
PATCH /rendition-presets/{id} now also re-renders up to a bounded number inline when it changes
ops, reporting the same object as reprocess.
Added. GET /api/v1/channels/{id}/publications items gain evaluation — the per-publication
verdict (missingRequired, transformErrors, warnings, localeFallbacks) — and externalRef,
the destination-assigned record id. GET /api/v1/analytics/export accepts from / to (the PDF previously rendered a
single-point trend regardless of window).
Changed — syndication now refuses to deliver a knowingly-incomplete record. A product whose
attribute-map transform fails, or which is missing a target marked required, is now skipped with
the reason in last_error and evaluation instead of being delivered with that field dropped.
If you relied on partial delivery, those products will stop arriving at the destination until the
mapping or the product data is fixed — which is the point: the previous behaviour sent silently wrong
data to third parties.
Changed — a channel's locale now actually applies. Localized name/attribute overrides are
resolved against the channel's locale (they were previously ignored entirely, so every channel
emitted the canonical values). Expect the first publish after this release to re-send products that
have translations, because their payloads legitimately change. evaluation.localeFallbacks lists
fields that fell back to the canonical value because the channel's locale has no override.
Fixed. Delivery retries now work (a transient destination failure is retried rather than being recorded as a permanent failure on the first attempt), batch delivery honours per-record results instead of applying one verdict to the whole set, and a re-delivery to an addressable destination updates the existing record instead of creating a duplicate.
2026-08-11 — list total is now opt-in
Changed. GET /api/v1/products and GET /api/v1/admin/orgs/{id}/products no longer include the
total count in the response by default. Pass ?withTotal=true to include it.
- Why: the per-page
COUNT(*)dominated list latency at scale (it scans far more than the page), and cursor pagination never needs a total. The default list response is now just{ "items": [...], "nextCursor": "..." }. - Migrating: if you read
total, addwithTotal=trueto the query string. Clients that ignoredtotalneed no change. Cursor pagination (items+nextCursor) is unchanged — see Pagination.