Cart-level discounts on SALE
POST /operations for type: "sale" now accepts an optional cart_level_discounts[] array — discounts applied to the order as a whole, not apportioned onto specific line items.- Each entry carries an optional
description(free-text label, e.g. promo code) and a required positiveamountdecimal string. line_items[].total_amountvalues must already reflect their post-discount totals —cart_level_discounts[]is documentation of the order-level reduction, not a re-pricing instruction.- The same array is returned on
saleoperation responses, with server-issuedidper entry. - For German fiscalization: each cart-level discount emits a separate
Rabattbusiness case in the DSFinV-K cashpoint-closing archive, slotted between the line-itemUmsatzlines and any gift-cardMehrzweckgutscheinEinloesunglines. Descriptions over 255 characters are trimmed for the DSFinV-K text field; the original is preserved in the operation response.
Gift card redemptions
payments[] on POST /operations/{id}/complete now accepts an optional gift_card_id field — the integrator-supplied identifier of the voucher being redeemed.gift_card_idis required whenmethod: "gift_card". Sending agift_cardpayment without it (or with empty/whitespace) returns422 gift_card_id_required.- The same field is returned on payments in the operation response.
- For German fiscalization: each
gift_cardpayment on asalenow emits aMehrzweckgutscheinEinloesungline in the DSFinV-K cashpoint-closing archive, carrying the redeemed amount (negative) andvoucher_id. Per §3 Abs. 15 Satz 2 UStG, the redemption is anti-payment rather than revenue, so the gift-card leg is no longer reported underpayment_types; the SALE’sfull_amount_incl_vatis reduced by the redeemed total and a balancing entry is added toamounts_per_vat_idat the non-taxable bucket. No integrator action needed beyond settinggift_card_id.
Gift card line itemsLine items on
POST /operations (for sale, return, exchange) now accept an optional type field with values item (default) or gift_card.gift_cardlines represent multi-purpose voucher sales (Mehrzweckgutschein). All taxes on agift_cardline must carryrate: "0"andtax_amount: "0"— non-zero rates are rejected with422 gift_card_tax_rate_must_be_zero. VAT on multi-purpose vouchers is deferred to redemption.- An optional
gift_card_idfield carries the integrator-supplied gift card identifier. - The same
typeandgift_card_idfields are returned on line items in operation responses. - For German fiscalization:
gift_cardlines emitMehrzweckgutscheinKauf(instead ofUmsatz) in the DSFinV-K cashpoint-closing archive. No integrator action needed beyond settingtype.
tax_amount consistency checkEach line_items[].taxes[].tax_amount is now checked against total_amount × rate_i / (1 + Σ rate_j) at 8 dp; mismatches return 422 tax_amount_precision_invalid. Submit tax_amount rounded to 8 dp. See Decimal precision.Line items now require
sku_identifierEach entry in line_items[] on POST /operations (for sale, return, exchange) now requires a sku_identifier field — the merchant’s stable variant identifier (e.g. "TSHIRT-BLUE-M").Identifies a unique number used to maintain and manage the item, product, or merchandise category in the company’s systems. Example: A Tshirt has one sku for each size + color combination.Breaking — sku_identifier is mandatory, as it’s required for regulatory exportsThe same field is returned on every line item in the operation response.Session close split into count + closeEnd-of-shift is now a two-step flow: the merchant counts the drawer first (
session_cash_count), then seals the session (session_close). The two events attest to different fiscal facts and produce up to two TSE signatures from one end-of-shift sequence.session_cash_count— new operation type. Carriescounted_closing_amount(and optionaldiscrepancy_note). Server returnsexpected_closing_amountand signedclosing_cashcount_diff(counted − expected). The variance is fiscally attested.session_close— slimmed. Now carries only the optional closing pull/add (closing_adjustment_amountsigned: negative = cash out, positive = cash in; plusclosing_adjustment_note). Fiscally signed only when the pull is non-zero; null/zero is a state-transition close with no Fiskaly signature.
session_close no longer accepts counted_closing_amount or discrepancy_note. Those fields moved to session_cash_count. Bodies that include them on a close return 400.Breaking — session_close requires a preceding session_cash_count. A close on an un-counted session returns 409 session_not_counted.Breaking — once counted, the session is frozen. Posting sale / return / exchange / session_cash_adjustment / a second session_cash_count against a counted session returns 409 session_already_counted. Only session_close is accepted from the counted state.Breaking — SessionCloseOperation response shape narrowed. counted_closing_amount and discrepancy_note are no longer on the close response (they’re on the SessionCashCountOperation response).New session lifecycle state. Sessions transition open → counted → closed (was open → closed). The counted state is short-lived in the happy path — most integrators will POST count then close back-to-back.Update integrator end-of-shift flows to count first, then close, before your next release. See Sessions for the walkthrough.Register sessions: new operation types + session-bound POS operationsThree new operation types model the cash-drawer shift on a fiscalized register:
session_open— start a shift with a counted opening floatsession_cash_adjustment— record mid-shift cash movement (drop, pay-in, correction)session_close— end the shift with a merchant-counted closing balance
POST /operations (no separate sessions endpoint) and carry the same resource_version / ETag mechanics as goods-movement operations. They are born status: "completed" — there is no POST .../complete step for session events.Breaking — POS goods-movement now requires an open session. Sending POST /operations with source: "POS" on a register that has no open session is rejected with 409 no_open_session. Open the register’s session with session_open before posting POS sales, returns, or exchanges. At most one open session per register; opening a second while one is open returns 409 session_invalid_state.New field — session_id on operation responses. Every operation response now carries session_id on the envelope. For POS goods-movement, it’s the session the operation was rung up during. For session-event variants, it’s the session being opened/adjusted/closed. For ONLINE goods-movement (no register), it’s null.Breaking — GET /operations/{id} is now a discriminated union on type. Six variants: SaleOperation, ReturnOperation, ExchangeOperation, SessionOpenOperation, SessionCashAdjustmentOperation, SessionCloseOperation. Each variant carries only the fields meaningful to its type — SESSION_* responses no longer include zero-padded pretax_amount / total_amount / line_items / payments / fiscal_information, and goods-movement responses no longer include null session_*_details placeholders. SALE responses also drop the always-null related_operation_id and external_related_operation.See Sessions for the full walkthrough.Update integrators that issue POS operations to bracket each shift with session_open / session_close before your next release.Merchant
tax_id removed; Italian fiscal_identity reshapedThe top-level Merchant.tax_id field has been removed from create, update, and response shapes. Tax identifiers now live exclusively on fiscal_identity, where they can vary by country.The Italian fiscal_identity object (country_code: "ITA") has been replaced:- Removed (both optional):
vat_id,codice_fiscale. - Added (all required):
tax_number(Codice Fiscale — 11 numeric digits or 16 alphanumeric characters),vat_number(Partita IVA — 11 numeric digits),fisconline_user(16 alphanumeric characters; the Codice Fiscale of the person delegated to access the Agenzia delle Entrate portal),fisconline_password,fisconline_pin. legal_entity_type(COMPANY | INDIVIDUAL) is unchanged.
DEU) and Austrian (AUT) fiscal_identity shapes are unchanged.Update integrator code that creates or updates Italian merchants, and stop sending the top-level tax_id field for all merchants, before your next release.New endpoints:
POST /exports, GET /exports/{exportId}Two new endpoints expose asynchronous fiscal export jobs.POST /exports enqueues a job and returns 202 Accepted with { id, type, status: "pending", register_id?, from, to, created_at }. Required body fields: type (dsfinvk is the first available type), from and to (ISO 8601 date-times). Optional register_id scopes the export to a single register; when omitted, the export covers all eligible registers under the merchant for the matching fiscal regime.GET /exports/{exportId} returns the job. Once status is completed, download_url is a signed URL to fetch the artifact. When status is failed, error.message (and optional error.details) describes why.Both endpoints require the X-OpenFiskal-Merchant header.external_related_operation accepted on returns/exchangesPOST /operations with type: "return" or type: "exchange" now accepts a new optional field, external_related_operation, for cases where the original sale was never ingested into OpenFiskal (typically during platform migration before backfill).- Mutually exclusive with
related_operation_id— exactly one must be set on a return or exchange. - Both fields on the nested object are required strings.
- The same field is echoed on the operation response.
related_operation_id when the original sale exists in OpenFiskal; use external_related_operation otherwise.Sign convention enforced on
total_amount and line_items[].total_amountPOST /operations now rejects payloads whose amount signs do not match Operation.type:sale:total_amountand everyline_items[].total_amountmust be>= 0.return:total_amountand everyline_items[].total_amountmust be<= 0.exchange: no sign constraint (an exchange may net positive, negative, or zero).
sale (freebie) and return (zero-value return). Violations return 422 Unprocessable Entity.Sign is the only on-wire signal Fiskaly’s TSE has to distinguish a sale from a return signature, so OpenFiskal enforces it before signing.Update return flows that previously sent positive amounts on type: "return" to send non-positive amounts before your next release.fiscal_identities[].country renamed to country_code; DE fields now requiredThe discriminator field on every fiscal_identity entry has been renamed from country to country_code. This applies to all three country variants (DEU, AUT, ITA) on both the create/update payload and the merchant response. Payloads that send country will reject.For DEU fiscal identities, tax_number (Steuernummer) and vat_id (USt-IdNr) are now both required. Previously both were optional. AUT and ITA requirements are unchanged.Update integrator code that creates or parses merchant fiscal_identities before your next release.OpenAPI JSON now served at
/openapi-jsonThe current production OpenAPI spec is now served at:- Production:
https://api.openfiskal.com/openapi-json - Sandbox:
https://sandbox.api.openfiskal.com/openapi-json
/docs-json is preserved as a 301 redirect to the new path. Import the new URL into Postman, Insomnia, or an OpenAPI code generator to scaffold a client.The OpenAPI spec always matches the live API implementation.Operation.status enum corrected; 412 payload includes expected_resource_versionThe Operation.status enum has been corrected from 'open' | 'completed' | 'cancelled' to 'open' | 'completed' | 'voided'. Operations that you POST /operations/{id}/void resolve to status: "voided", not cancelled. There were never any operations with status: "cancelled" in production — the OpenAPI value was wrong.412 Precondition Failed error bodies now include details.expected_resource_version alongside details.current_resource_version. The message field is the literal string "Resource version mismatch." (previously "The supplied If-Match value is stale.").Update Operation.status parsers to accept voided, and update any UI that surfaces the precondition_failed error message string.Operation type
refund renamed to returnThe Operation.type enum now uses return instead of refund for operations where goods come back from the customer. The valid values are sale | return | exchange.OpenFiskal now distinguishes goods movement from money movement: operations model goods (sale/return/exchange), refund is reserved for money movement (a negative payment transaction on an operation) and is not an operation type. This unlocks flows the old terminology could not express cleanly — return with store credit (goods back, no money), goodwill refund (money back, no goods), or partial refunds on kept items.POST /operationswithtype: "refund"will now reject. Usetype: "return".- Response shapes now return
type: "return"for what was previouslytype: "refund". Payment.statusenum is unchanged —refundedis still the correct status for a payment that has been reversed, because it describes money movement.
KassenSichV schema changes
FiscalInformationKassenSichVVerification: renamedtse_serialtotss_serial_numberandclient_idtopos_client_serial_number. Both remain required.FiscalInformationKassenSichVEndEvent: added requiredpublic_key(string).FiscalInformationKassenSichVStartEvent: removedtransaction_counterandsignature.signed_atis now the only required field.
Country code casingClarified that the
country field on Address and on the legal entity schema must be an ISO 3166-1 alpha-3 code in uppercase (for example, DEU). No behavior change; existing uppercase values continue to validate.New endpoint:
POST /registers/{registerId}/decommissionPermanently retires a fiscalized register. The endpoint calls the underlying TSE provider to decommission the register’s fiscal components and returns the updated register with decommissionedAt populated. Currently DE/KassenSichV only; AT/IT will follow as those regimes ship publicly.Conflicts return 409 decommission_conflict:- Register has not been fiscalized.
- Register has already been decommissioned.