Skip to main content

Overview

A register session captures one shift on a fiscalized register. You open a session at the start of the shift, every Sale, Return, Exchange that follows binds to it, the merchant counts the drawer at the end of the shift, then you close the session. Sessions exist only for POS. ONLINE operations have no register and therefore no session. Sessions are opened, adjusted, counted, and closed by sending session-event operations through the same POST /operations endpoint that ingests sales, returns, and exchanges. This was intentional since under many regimes a session open, cash adjustment, count, or close is itself a fiscal event that must be tracked, securely persisted, and signed.

Session lifecycle

[open] ──► [counted] ──► [closed]
  • open — the session accepts goods-movement operations and cash adjustments
  • counted — the merchant has counted the drawer and the system has signed the counted-vs-expected variance. The session is frozen: only session_close is accepted from here. No further sales / returns / exchanges / adjustments / repeat counts.
  • closed — the session is final; new operations on the register need a new session_open
A register has at most one in-progress (open or counted) session at a time. Opening a second session while one is still in progress is rejected.

Session-event operation types

TypePurpose
session_openStart a new shift on a register with a counted opening float
session_cash_adjustmentRecord a mid-shift cash movement (drop, pay-in, till correction)
session_cash_countCount the drawer at end of shift; produce the signed counted-vs-expected variance
session_closeSeal the session, optionally moving cash out (closing pull) or in (next-shift float top-up)
All four flow through POST /operations, return an operation resource, and carry the same resource_version / ETag mechanics as goods-movement operations.

Opening a session

Send a session_open operation with the register, currency, and counted opening float.
curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses-2026-04-30-reg_abc123-open" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_open",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "opening_balance_amount": "50.00",
    "opening_note": "Float supplied by office manager."
  }'
The response carries the new session_id. It is recommended to persist this.
{
  "id": "op_ses_open_001",
  "type": "session_open",
  "register_id": "reg_abc123",
  "session_id": "ses_abc123",
  "status": "completed",
  "currency": "EUR",
  "resource_version": 1
}
The operation status is completed because session-event operations are final the moment OpenFiskal accepts them, i.e. there is no POST .../complete step. Don’t confuse it with the session’s lifecycle state (the open / counted / closed flag tracked separately).

Goods movements bind to the session

Every sale, return, and exchange you send on a POS register carries the session_id of the open session in the response. You do not set session_id on the request — OpenFiskal resolves it from the register’s currently open session. If you call POST /operations with source: POS on a register that has no open session, the request is rejected.

Mid-shift cash adjustments

Use session_cash_adjustment for any cash movement that is not a sale or return: cash drops to the safe, pay-ins, till corrections. The cash_amount is signed: negative for cash out, positive for cash in. Multiple adjustments per session are allowed.
curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-adj-001" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_cash_adjustment",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "cash_amount": "-20.00",
    "note": "Cash drop to safe."
  }'

Counting the drawer

End-of-shift is two POSTs: first the merchant counts the drawer (session_cash_count), then they seal the session (session_close). The two events are split because they attest to different fiscal facts:
  • The count attests what was actually in the drawer vs what the system expected. This is the variance attestation — the regulator’s primary interest. Fiscally signed as a standalone event.
  • The close records the optional cash movement at end of shift (cash pulled to the safe / float top-up for the next shift). When non-zero, fiscally signed as a cash-movement event.
Send session_cash_count with the merchant-counted closing balance. OpenFiskal computes the expected balance (opening float + cash sales − cash returns + adjustments), returns the diff, and signs the variance.
curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-count" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_cash_count",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "counted_closing_amount": "127.50",
    "discrepancy_note": "Two coins from the day before were unaccounted for."
  }'
The response carries the system-calculated expected_closing_amount and the signed closing_cashcount_diff. The merchant UI typically displays “you’re 5€ over” / “you’re 5€ short” / “on the dot” using the diff value.
{
  "id": "op_ses_count_001",
  "type": "session_cash_count",
  "session_id": "ses_abc123",
  "register_id": "reg_abc123",
  "status": "completed",
  "currency": "EUR",
  "counted_closing_amount": "127.50",
  "expected_closing_amount": "132.50",
  "closing_cashcount_diff": "-5.00",
  "discrepancy_note": "Two coins from the day before were unaccounted for."
}
One count per session. A second session_cash_count POST against the same session returns 409 session_already_counted. The integrator’s UX should only fire the request once the merchant has confirmed their count. The count freezes the session. Once the count is accepted, the session is frozen against further sales / returns / exchanges / adjustments / repeat counts — anything other than session_close returns 409 session_already_counted. The freeze is what makes the signed variance legally meaningful: expected cannot drift between count and close.

Closing a session

Send session_close to seal the session. The body requires business_date (the posting date in YYYY-MM-DD format) and optionally carries closing_adjustment_amount (signed: negative = pull cash out, positive = add cash in) and closing_adjustment_note. Counting must have happened first — session_close on an un-counted session returns 409 session_not_counted.
curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-close" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_close",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "business_date": "2026-04-30",
    "closing_adjustment_amount": "-100.00",
    "closing_adjustment_note": "Cash pulled to safe deposit."
  }'
business_date is the date of posting for financial-accounting purposes. If the session opens and closes on the same calendar day, use that date. If the session crosses midnight (for example, a bar that closes after 02:00), pick the business day the merchant treats as the operating day; consult your tax advisor when in doubt. When closing_adjustment_amount is non-zero, the close is fiscally signed (a cash movement at close is a fiscal event). Omit the field (or pass 0) for a clean close that doesn’t move cash — those closes don’t produce a separate signature, only a state transition. The drawer state at end of shift is therefore: counted_closing_amount + closing_adjustment_amount = amount left for the next shift. Once the close is completed, the register has no in-progress session. The next shift starts with a new session_open and a fresh session_id.

Walkthrough: a single shift

The scenario: a German bakery opens at 08:00 with a 50 EUR float, rings up two cash sales, drops 20 EUR to the safe at midday, counts the drawer at 17:00 finding 127.50 EUR, then pulls 100 EUR to the safe deposit before sealing the session.
1

Open the session with a 50 EUR float

curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses-2026-04-30-reg_abc123-open" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_open",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "opening_balance_amount": "50.00"
  }'
Response (abbreviated):
{
  "id": "op_01_open",
  "type": "session_open",
  "session_id": "ses_abc123",
  "register_id": "reg_abc123",
  "status": "completed"
}
Capture ses_abc123 — every operation that follows on this register binds to it automatically.
2

Ring up the first sale (4.50 EUR)

Open the sale:
curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: order-1001-start" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "sale",
    "source": "POS",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "external_id": "order-1001",
    "pretax_amount": "4.21",
    "tax_amount": "0.29",
    "tip_amount": "0.00",
    "total_amount": "4.50",
    "line_items": [
      {
        "title": "Brötchen",
        "sku_identifier": "BROETCHEN",
        "quantity": 3,
        "unit_price": "1.50",
        "total_amount": "4.50",
        "taxes": [
          { "name": "MwSt 7%", "rate": "0.07", "tax_amount": "0.29" }
        ]
      }
    ]
  }'
The response carries "session_id": "ses_abc123" even though you did not send it, and the operation comes back with "status": "open". Capture the returned id and resource_version for the complete step.Complete the sale with a cash payment so it counts toward the session’s expected closing balance:
curl -X POST https://api.openfiskal.com/v1/operations/op_01_sale_4.50/complete \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "If-Match: \"1\"" \
  -H "Idempotency-Key: order-1001-complete" \
  -H "Content-Type: application/json" \
  -d '{
    "payments": [
      {
        "payment_id": "pay_1001",
        "method": "cash",
        "amount": "4.50",
        "currency": "EUR",
        "status": "captured"
      }
    ]
  }'
The response is now "status": "completed" and (on a fiscalized German register) carries a TSE signature in fiscal_information.
3

Ring up the second sale (98.00 EUR)

curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: order-1002-start" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "sale",
    "source": "POS",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "external_id": "order-1002",
    "pretax_amount": "91.59",
    "tax_amount": "6.41",
    "tip_amount": "0.00",
    "total_amount": "98.00",
    "line_items": [
      {
        "title": "Catering tray",
        "sku_identifier": "CATERING-TRAY",
        "quantity": 1,
        "unit_price": "98.00",
        "total_amount": "98.00",
        "taxes": [
          { "name": "MwSt 7%", "rate": "0.07", "tax_amount": "6.41" }
        ]
      }
    ]
  }'
Complete the sale the same way the first one was completed (POST /v1/operations/{id}/complete with If-Match: "1" and a payments array). Both sales must be completed for their cash to count toward the expected closing balance below.
4

Drop 20 EUR to the safe at midday

curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-drop-001" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_cash_adjustment",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "cash_amount": "-20.00",
    "note": "Cash drop to safe."
  }'
Expected balance now: 50.00 + 4.50 + 98.00 − 20.00 = 132.50 EUR.
5

Count the drawer at 17:00 (counted 127.50 EUR)

curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-count" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_cash_count",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "counted_closing_amount": "127.50",
    "discrepancy_note": "Five euro short — investigating."
  }'
Response:
{
  "id": "op_05_count",
  "type": "session_cash_count",
  "session_id": "ses_abc123",
  "status": "completed",
  "counted_closing_amount": "127.50",
  "expected_closing_amount": "132.50",
  "closing_cashcount_diff": "-5.00",
  "discrepancy_note": "Five euro short — investigating."
}
The signed variance (closing_cashcount_diff: -5.00) is the regulator’s primary attestation. The session is now counted and frozen — only session_close is accepted from here.
6

Pull 100 EUR to the safe and seal the session

curl -X POST https://api.openfiskal.com/v1/operations \
  -H "Authorization: Bearer $API_KEY" \
  -H "X-OpenFiskal-Merchant: merchant_01HXYZ" \
  -H "Idempotency-Key: ses_abc123-close" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "session_close",
    "register_id": "reg_abc123",
    "currency": "EUR",
    "business_date": "2026-04-30",
    "closing_adjustment_amount": "-100.00",
    "closing_adjustment_note": "Cash pulled to safe deposit."
  }'
The drawer is left with 127.50 − 100.00 = 27.50 EUR for the next shift’s float. The closing_adjustment_amount is fiscally signed (a non-zero pull is a fiscal cash event). The session is now closed. The next shift on reg_abc123 starts with a new session_open and a fresh session_id.Audit trail summary: two TSE signatures came out of this end-of-shift sequence — one for the count’s −5.00 EUR variance, one for the close’s −100.00 EUR pull.

Next steps