Skip to content
P

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. 404 for an invoice that does not exist or belongs to another tenant (deliberately not 403, which would confirm the id exists); 409 INVALID_STATE when the archive has not landed yet. Requires billing:read.
  • POST /api/v1/admin/orgs/{id}/invoices/upload-url and POST /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, or null.
  • hostedUrl and pdfUrl are unchanged and remain the fallback: they are what a client should offer while archived is false, so a customer is never left without a document. Prefer documentUrl whenever 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 (or null), plus availablePlans: the tiers you can buy right now, priced net of VAT, filtered to intervals that have a configured provider price. portalAvailable and selfServeAvailable tell 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/pdfUrl are long-lived provider URLs, not presigned URLs of ours.
  • POST /api/v1/billing/checkout-session and POST …/portal-session — return a provider-hosted URL to follow. Both require create on billing; starting a session leads to a charge, so read is 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-Headers now names it, along with X-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 planCode request 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-url accepts video/mp4, video/webm and video/quicktime, with a 100 MB limit each. assetType gains video, and AssetView.assetType can now return it.
  • assetType must agree with mimeType: a video/* type requires assetType: "video", and an image/* type requires "image". A disagreement returns 422. Document types are unconstrained, so a PDF may still be document, certificate or other.
  • 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 expiresAt rather 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}/renditions still 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.

  • sizeBytes in 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 default and constant transform operations inside attributeMap.

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 gains facets: [{ "field": "...", "counts": [{ "value": "...", "count": 12 }] }].
  • qualityBandred | amber | green, filtering on data completeness. qualityScore is 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) and supplierName, which the buyer catalogue previously rendered as because it was declared but never indexed.
  • categoryName — a new filter param. The categoryName facet returns names, so this is what makes that facet actionable (categoryId still 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}/reprocess202 { 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, add withTotal=true to the query string. Clients that ignored total need no change. Cursor pagination (items + nextCursor) is unchanged — see Pagination.