Overview
Icon for inFakt Invoicing

inFakt Invoicing

inFakt invoices and KSeF filing

@zanreal/medusa-infakt

Polish invoicing for Medusa v2. Issues an inFakt invoice for every paid order and files the B2B ones to KSeF, Poland's national e-invoicing system.

Full documentation, in English and Polish, is published at https://zanreal.com/docs/oss/medusa-infakt and authored in Copy to clipboarddocs/.

Built against Medusa core Copy to clipboard2.18.0.

The hard part of this integration is not the API calls. It is that issuing an invoice cannot be undone. inFakt's create endpoint has no idempotency key, so a retried request produces a second real, numbered, legally-issued document - and the only way to withdraw one is a formal corrective invoice. Almost every design decision below follows from that.

Contents

  • What it does
  • The legal context
  • Install
  • Options
  • Environment variables
  • How an order becomes an invoice
  • The crash window, and why the create is never retried
  • The total-match guard
  • Orders backfilled from a legacy system
  • Adopting invoices that already exist in inFakt
  • Settlement: does inFakt agree the order was paid?
  • Where the buyer's NIP comes from
  • KSeF
    • The KSeF webhook
  • Operator runbook: needs_review
  • Cross-border VAT
  • Cross-plugin event
  • Admin API
  • Privacy
  • Testing
  • Generating a migration
  • Roadmap
  • Releasing
  • License

What it does

  1. A trigger event (Copy to clipboardpayment.captured by default) queues the order. That is all the event does.
  2. A scheduled worker drives each queued order to completion, sequentially:
    • verifies the order is not already invoiced outside this pipeline, is fully paid, in the configured currency, not canceled, and placed on or after Copy to clipboardstartDate (when one is configured);
    • builds the inFakt payload and verifies the line sum equals the order total exactly;
    • creates the invoice in inFakt and waits for its async task to settle;
    • reads the number inFakt assigned (numbering is entirely inFakt's job);
    • files the invoice to KSeF when required, and polls until KSeF assigns a number;
    • emits Copy to clipboardinfakt.invoice.issued so other plugins can react.
  3. Anything that needs a human lands in Copy to clipboardneeds_review, with a reason, on the Invoicing page in the admin dashboard.

Each step persists its result before the next one starts, and the next step is derived from which columns are still null. A crash at any instant resumes exactly where it stopped on the following tick.

  • KSeF (Krajowy System e-Faktur) is Poland's mandatory national e-invoicing system. Since April 2026, an invoice issued to a buyer identified by a NIP - a B2B invoice
    • must be filed there. Penalties for failing to file start in January 2027.
  • A consumer invoice (no NIP) is outside the system.
  • That shape is why Copy to clipboardksef.mode defaults to Copy to clipboardnip-only and is not a boolean, and why Copy to clipboardksef.requireActive defaults to on in production: a store whose KSeF integration has lapsed looks identical to a store with no B2B orders, and silence there is a legal exposure rather than a failed sync.

This plugin is not legal advice. It automates a filing obligation; confirming that obligation applies to your business, and that your invoices are correct, remains yours.

Install

Copy to clipboard@zanreal/medusa-infakt is on npm:

npm install @zanreal/medusa-infakt

That resolves to a prebuilt tarball - the published package already contains the Copy to clipboard.medusa/server output its Copy to clipboardexports point at, so nothing needs to compile on install.

Copy to clipboardmain keeps moving after a release ships (see Releasing), so if you need a fix or feature that has landed on Copy to clipboardmain but not yet been released, install it as a git dependency instead, pinned to a commit:

// package.json
{
"dependencies": {
"@zanreal/medusa-infakt": "github:zanreal-labs/medusa-infakt#1c7a50c551f59658156d6f0b024996946cd71417"
}
}

Pin to the commit you tested against. Copy to clipboard#main would move under you on the next push to the repository.

Installed this way, the package compiles itself on install - Copy to clipboardprepare runs Copy to clipboardmedusa plugin:build, which turns the checked-out source into the Copy to clipboard.medusa/server output its Copy to clipboardexports point at. pnpm 10 and newer refuse to run that script for a dependency they do not already trust, so a fresh install needs it allowed once, in your project's Copy to clipboardpnpm-workspace.yaml:

# pnpm-workspace.yaml
allowBuilds:
"@zanreal/medusa-infakt@https://codeload.github.com/zanreal-labs/medusa-infakt/tar.gz/1c7a50c551f59658156d6f0b024996946cd71417": true

The key is the exact tarball URL pnpm resolves the pinned commit to, which is why it carries the same SHA as the dependency line above - update both together when you move the pin.

Register it in Copy to clipboardmedusa-config.ts:

import { defineConfig, loadEnv } from "@medusajs/framework/utils";
loadEnv(process.env.NODE_ENV || "development", process.cwd());
module.exports = defineConfig({
// ...
plugins: [
{
resolve: "@zanreal/medusa-infakt",
options: {
// The plugin's enable switch. Unset (or point this at an env var that is
// not set) and the plugin boots inert, with one line in the boot log.
apiKey: process.env.INFAKT_API_KEY,
environment: "production",
// Optional. Leave it unset to invoice every order the pipeline sees.
// Set it when installing onto a store with a back catalogue this plugin
// should not touch - orders placed before it are skipped.
// startDate: "2026-08-01",
currency: "PLN",
taxSymbol: "23",

Then generate and run the migration in the consuming app, as with any other module:

npx medusa db:migrate

Testing against inFakt

inFakt's sandbox (Copy to clipboardapi.sandbox-infakt.pl) has been unreliable, so testing against a real inFakt trial account is the more dependable path. If you do:

  • Set Copy to clipboardksef: { mode: "never" } so nothing is filed to the live KSeF while you are experimenting. It is the only setting this plugin has that intentionally breaks the legal obligation, and it exists for exactly this.
  • Remember that every successful create is a real invoice in that account's numbering series. There is no dry-run mode.

Options

Passed via Copy to clipboardmedusa-config.ts Copy to clipboardplugins[].options. The single object cascades to every module the plugin registers (there is one: Copy to clipboardinfakt).

Option Type Default Notes Copy to clipboardapiKey Copy to clipboardstring - The enable switch. inFakt API key, sent as Copy to clipboardX-inFakt-ApiKey. Absent or blank leaves the plugin inert; see below. Read it from an env var. Copy to clipboardenvironment Copy to clipboard"production" | "sandbox" Copy to clipboard"production" See the sandbox note above. Copy to clipboardstartDate Copy to clipboardstring - Optional, strict Copy to clipboardYYYY-MM-DD. Orders placed before it are skipped. Absent means no floor. See below. Copy to clipboardcurrency Copy to clipboardstring Copy to clipboard"PLN" The domestic currency. Orders in any other currency are skipped unless Copy to clipboardcrossBorder opts them in. Copy to clipboardtaxSymbol Copy to clipboardstring Copy to clipboard"23" inFakt VAT rate symbol for domestic lines. Cross-border lines are decided by the VAT regime, not by this. Copy to clipboardcrossBorder.enabled Copy to clipboardboolean Copy to clipboardfalse Master switch for all cross-border VAT. Off means the plugin behaves exactly as it did before this feature existed. See below. Copy to clipboardcrossBorder.currencies Copy to clipboardstring[] Copy to clipboard[] Extra currencies to invoice, e.g. Copy to clipboard["EUR"]. Only consulted when Copy to clipboardcrossBorder.enabled. Copy to clipboardcrossBorder.viesFallback Copy to clipboard"review" | "consumer" Copy to clipboard"review" What an unreachable VIES means. Copy to clipboardreview parks the order; Copy to clipboardconsumer charges destination VAT. Never zero-rates either way. Copy to clipboardcrossBorder.viesBaseUrl Copy to clipboardstring EU REST endpoint Override the VIES endpoint. Copy to clipboardcrossBorder.viesTimeoutMs Copy to clipboardnumber Copy to clipboard8000 VIES request timeout. Copy to clipboardcrossBorder.emailInvoice Copy to clipboardboolean Copy to clipboardtrue Email cross-border invoices to the buyer. A foreign buyer cannot collect one from KSeF. Copy to clipboardoss.enabled Copy to clipboardboolean Copy to clipboardfalse Switches the OSS code path on. Requires Copy to clipboardcrossBorder.enabled. Read the OSS warning below before enabling. Copy to clipboardoss.registered Copy to clipboardboolean Copy to clipboardfalse Whether the company actually holds a union OSS registration (VIU-R). Without it, EU consumers get the domestic rate below the threshold. Copy to clipboardoss.thresholds Copy to clipboardRecord<string, number> EUR 10 000 / PLN 42 000 Per-currency intra-EU B2C limits, minor units. A currency with no entry parks the order rather than being excluded from the count. Copy to clipboardoss.alertRatio Copy to clipboardnumber Copy to clipboard0.8 Warn at this fraction of the threshold, so there is time to register before orders start parking. Copy to clipboardoss.serviceType Copy to clipboard"electronic" | "broadcasting" | "telecommunications" Copy to clipboard"electronic" inFakt's service taxonomy. Software and license keys are Copy to clipboardelectronic. Copy to clipboardtriggerEvent Copy to clipboard"payment.captured" | "order.placed" Copy to clipboard"payment.captured" Which event queues an order. Medusa has no Copy to clipboardorder.paid event. Copy to clipboardksef.mode Copy to clipboard"nip-only" | "all" | "never" Copy to clipboard"nip-only" Who gets filed. Copy to clipboardnever is for development only. Copy to clipboardksef.requireActive Copy to clipboardboolean Copy to clipboardtrue in production Verify the account's KSeF integration and refuse to run when it is not active. Copy to clipboardksef.decide Copy to clipboard(input) => boolean - Per-invoice predicate. Overrides Copy to clipboardmode entirely, including Copy to clipboardnever. Copy to clipboardnipExtractor Copy to clipboard(order) => string | undefined see below Where to find the buyer's NIP. Copy to clipboardemitIssuedEvent Copy to clipboardboolean Copy to clipboardtrue Emit Copy to clipboardinfakt.invoice.issued once an invoice is issued. Copy to clipboardtimeoutMs Copy to clipboardnumber Copy to clipboard60000 Per-request timeout for inFakt calls. Copy to clipboardsettingsEncryptionKey Copy to clipboardstring - Encrypts an admin-set Copy to clipboardapiKey override at rest. Required before one can be saved from Settings -> inFakt; see below. Read it from an env var. Copy to clipboardwebhookSecret Copy to clipboardstring - The secret inFakt generated for its webhook. The enable switch for Copy to clipboardPOST /hooks/infakt/ksef - unset, that route answers 401 to everything. Read it from an env var. See The KSeF webhook.

Cross-border VAT

Off by default. Without Copy to clipboardcrossBorder.enabled, this plugin invoices the domestic currency only, puts Copy to clipboardtaxSymbol on every line, and skips everything else - exactly as it did before cross-border support existed. Nothing below applies until you opt in.

The decision tree

Every order gets exactly one regime, decided in Copy to clipboardsrc/lib/invoicing/regime.ts:

Destination Buyer Regime Rate On the invoice VAT-UE? Poland anyone Copy to clipboarddomestic Copy to clipboardtaxSymbol (23) nothing extra no Another member state business, VAT id confirmed by VIES Copy to clipboardreverse_charge Copy to clipboardnp "Odwrotne obciazenie / Reverse charge" + basis yes Another member state consumer, below the threshold, not OSS-registered Copy to clipboardeu_b2c_domestic_rate Copy to clipboardtaxSymbol (23) nothing extra no Another member state consumer, above the threshold, not OSS-registered blocked - - - Another member state consumer, OSS-registered Copy to clipboardoss destination country's rate OSS document no Outside the EU (incl. GB) business Copy to clipboardexport_services Copy to clipboardnp out-of-scope annotation no Outside the EU consumer blocked - - -

The EU-consumer row is the one to read twice. Today this store is not registered for OSS, so an EU consumer is charged Polish 23% - not the destination rate, and emphatically not zero. That is correct because of the intra-EU B2C threshold, and it stops being correct the moment the threshold is crossed.

Anything the tree cannot answer becomes Copy to clipboardneeds_review with a reason, never a guess. A late invoice is a support ticket; a wrong one is a liability.

Three things that are easy to get wrong

Copy to clipboardnp is not Copy to clipboard0. inFakt's Copy to clipboard0 is a Polish zero rate; Copy to clipboardnp ("nie podlega") means the supply is outside the scope of Polish VAT. Cross-border services are Copy to clipboardnp. There is no reverse-charge rate symbol any more - inFakt's Copy to clipboardoo expired on 2019-11-01 with the domestic reverse charge - so the legal annotation is carried as invoice text.

"Not Poland" does not mean "no VAT". An EU consumer is never zero-rated. Below the threshold they owe Polish VAT; above it (or once we register for OSS) they owe their own country's. Zero is wrong in both directions.

A reverse charge and an export of services are not the same thing. They carry the same Copy to clipboardnp, but a reverse charge belongs in the VAT-UE summary and an export must never appear there. They are separate regimes for that reason alone.

B2B vs B2C, and what VIES has to do with it

A VAT id that is merely present is not one that is valid. A buyer is treated as a business only when all three hold: an id is supplied, VIES confirms it, and its country matches the billing country. Anything else is a consumer or a park.

VIES has three outcomes, not two, and the third one matters:

  • valid - reverse charge.
  • invalid - the buyer is a consumer; destination VAT applies.
  • unavailable - VIES or a member state's node is down. By default the order parks.

Parking is the default because both automatic answers are wrong in different directions: zero-rating rests on evidence we do not have, and charging destination VAT silently overrides a business customer's own statement about who they are. Set Copy to clipboardcrossBorder.viesFallback: "consumer" to never delay a paid order - that over-collects, which a corrective invoice can fix, rather than under-collecting, which it cannot. Neither setting can produce a reverse charge on an unconfirmed number.

Validate at checkout and cache the result on the order (Copy to clipboardorder.metadata.vies), so a routine VIES outage cannot strand an order the customer has already paid for. The reader accepts Copy to clipboardtrue/Copy to clipboardfalse, Copy to clipboard"valid", or Copy to clipboard{ status, checkedAt, consultationNumber }.

Products must be classified

The place of supply of a service follows the customer; the place of supply of goods follows the goods. So each product needs a marker - Copy to clipboardmetadata.tax_supply set to Copy to clipboard"service" or Copy to clipboard"goods", on the line, the variant or the product.

Unmarked products are not assumed to be services. They keep invoicing normally domestically (a Polish sale is 23% either way) and park on the first foreign order, naming the product. That means no catalogue backfill is needed before shipping this, and no silent wrong answer either.

The intra-EU B2C threshold

A supplier established in one member state may keep taxing intra-EU B2C sales at its own rate while the combined net value of those sales stays at or below EUR 10 000, measured across the current and previous calendar year (art. 28k ust. 2 ustawy o VAT; Directive 2006/112 art. 59c). Above it, the place of supply moves to each consumer's country and OSS registration is required.

Three properties of that rule shape the implementation:

  1. Crossing flips the treatment mid-year, on the transaction that crosses it - not at a period boundary. So the counter is consulted before an invoice is issued, and the pending sale is included in the total before comparing.
  2. There is no safe fallback above the line. Below it, 23% is right because of the threshold; above it the identical 23% is wrong. So a crossing order is parked, never issued at either rate.
  3. Registration is not instantaneous. An alert fires at Copy to clipboardoss.alertRatio (default 80%) through the same admin feed as a parked invoice, so the owner has room to file VIU-R before anything starts blocking. The block at 100% is a backstop, not the notification mechanism.

Where the counter lives. It is derived from the invoices themselves: every Copy to clipboardeu_b2c_domestic_rate row stores Copy to clipboardvat_base_minor and Copy to clipboardvat_currency, and the counter sums those rows. There is no separate ledger to drift out of sync, and an accountant can audit the figure by listing the same rows they would anyway. Only EU B2C counts - reverse-charge B2B and non-EU sales are outside the threshold entirely.

The currency approximation, stated plainly. The limit is EUR 10 000 with a statutory PLN equivalent of 42 000 PLN. Sales may be in either. Doing this exactly needs NBP rates per transaction date, which this plugin does not have and will not invent. Instead it tracks a running total per currency, expresses each as a fraction of that currency's own limit, and sums the fractions. A currency with no configured limit does not get skipped - it parks the order, because silently not counting a currency is the one failure mode the whole mechanism exists to prevent.

OSS: read this before enabling

Copy to clipboardoss.enabled and Copy to clipboardoss.registered are separate flags, and both default to false. Copy to clipboardenabled switches the code path on; Copy to clipboardregistered asserts that destination-rate invoicing is actually lawful for this company. Setting Copy to clipboardenabled without Copy to clipboardregistered changes nothing about which regime an EU consumer gets.

Two further reasons OSS is gated:

1. Checkout has to charge the destination rate first. This plugin decides the rate from inFakt's own Copy to clipboard/moss_vat_rates.json, but the money was already taken by Medusa at whatever rate its tax module was configured with. If those disagree, no correct invoice exists - it would either misstate the tax or misstate the total. The builder therefore cross-checks and parks on a mismatch. If your non-Polish tax regions have no rate configured, every OSS order will park with a reason saying so. That is intended.

2. OSS invoices have their own numbering series. They are a separate document family at inFakt. Downstream, the invoice number is the reference license keys are bought and recovered under, and it is not unique across families - a series that restarts at 1 can collide with an existing VAT invoice number and cause one order's keys to be delivered against another's. A collision guard (Copy to clipboardsrc/lib/invoicing/invoice-number.ts) refuses to announce a number another order already holds, but the numbering inFakt actually assigns to OSS invoices has not been confirmed against a real document.

Delivery is not filing

A Polish B2B buyer collects their invoice from KSeF. A foreign buyer cannot - they have no access to it - so filing a cross-border invoice is not the same as delivering it. The pipeline emails cross-border invoices via inFakt, best-effort, controlled by Copy to clipboardcrossBorder.emailInvoice. Foreign tax ids are still filed to KSeF: Copy to clipboardksef.mode: "nip-only" keys on any tax id, not only a Polish NIP.

Enabling a currency also arms key delivery

Today a non-domestic order is skipped, which means no invoice, no Copy to clipboardinfakt.invoice.issued, and therefore no downstream license-key purchase or delivery. Adding a currency to Copy to clipboardcrossBorder.currencies turns all of that on for those orders.

Why Copy to clipboardcurrency and Copy to clipboardtaxSymbol have defaults at all

Both describe inFakt, not a preference of whoever wrote this plugin. inFakt is a Polish invoicing and bookkeeping service: an account belongs to a Polish registered business, the books it keeps are Polish books, and its ledger currency is PLN, so defaulting to anything else would describe no real inFakt account. Copy to clipboard"23" is likewise inFakt's own symbol for the Polish basic VAT rate, from the same vocabulary as Copy to clipboard"8", Copy to clipboard"5", Copy to clipboard"0", Copy to clipboard"zw" and Copy to clipboard"np" - a value from the integrated service, not a commercial choice.

They are kept deliberately, and the reasoning is repeated next to the constants in Copy to clipboardsrc/lib/options.ts and locked by a test, so that a later sweep for shipped defaults does not delete them by mistake. A store that invoices in another currency or at another rate sets these two options explicitly, and everything else keeps working.

Copy to clipboardapiKey, Copy to clipboardenvironment, Copy to clipboardcurrency, Copy to clipboardtriggerEvent and Copy to clipboardksef.mode can all be overridden live from Settings -> inFakt without a redeploy - see Live overrides below. Every other option in this table stays Copy to clipboardmedusa-config.ts-only.

Every option is validated in the module loader, so a misconfiguration is a boot failure with a precise message rather than an opaque 401 or 422 in the middle of a customer's checkout.

Enablement: Copy to clipboardapiKey, the pause switch, and the environment force-off

The plugin should simply work when it is configured and do nothing when it is not. Copy to clipboardapiKey is that switch at the config level: absent or blank, the plugin boots inert - no order is ever enqueued or invoiced - with one clear line in the boot log and in the admin UI. Set it, and the plugin is fully active.

This is the one option that does not throw when it is missing. Every other option, including Copy to clipboardstartDate, fails loudly at boot when it is malformed.

That is not the whole story, though, because Copy to clipboardapiKey alone is not a safe signal to start invoicing. A store cutting over from a legacy invoicing system has Copy to clipboardapiKey configured from day one - the admin UI needs it to render at all - but invoicing has to stay off until an operator deliberately turns it on. Two more layers sit on top of Copy to clipboardapiKey, checked fresh on every subscriber invocation and every worker tick (not just at boot, because both of these CAN change without a restart):

  1. The pause switch (Copy to clipboardinvoicing_paused, in the Copy to clipboardInfaktSettings table). Editable live from Settings -> inFakt in the admin. Defaults to Copy to clipboardtrue on a fresh install - a store that already has Copy to clipboardapiKey configured does not start issuing invoices the moment it boots. An operator resumes it explicitly.
  2. Copy to clipboardINFAKT_INVOICING_DISABLED (environment variable; Copy to clipboard1, Copy to clipboardtrue or Copy to clipboardyes, case-insensitively). A hard, operator-controlled force-off that cannot be released from inside the admin - it overrides everything, including an admin having already unpaused invoicing. Meant for a deploy-time emergency brake, not day-to-day operation.

The combined answer - Copy to clipboardeffectiveEnabled = apiKeyPresent && !invoicingPaused && !envForceDisabled - is what the subscriber and the worker actually check. Copy to clipboardGET /admin/infakt/settings reports it, along with which of the three is responsible (Copy to clipboardreason: Copy to clipboardactive, Copy to clipboardno_api_key, Copy to clipboardpaused, or Copy to clipboardenv_force_disabled).

Live overrides: Copy to clipboardcurrency, Copy to clipboardksef.mode, Copy to clipboardtriggerEvent, Copy to clipboardenvironment, Copy to clipboardapiKey

Copy to clipboardinvoicing_paused is not the only field this plugin lets an operator change without a redeploy. Settings -> inFakt can also override Copy to clipboardcurrency, Copy to clipboardksef.mode, Copy to clipboardtriggerEvent, Copy to clipboardenvironment and Copy to clipboardapiKey - every one of these plugin options, except Copy to clipboardstartDate, Copy to clipboardtaxSymbol, Copy to clipboardksef.requireActive, Copy to clipboardksef.decide, Copy to clipboardnipExtractor, Copy to clipboardemitIssuedEvent and Copy to clipboardtimeoutMs, which stay Copy to clipboardmedusa-config.ts-only.

Each override is a nullable column on the same Copy to clipboardInfaktSettings singleton row as the pause switch. Null means "not overridden - use the Copy to clipboardmedusa-config.ts value", so shipping this onto an existing install changes nothing until an operator opens the Settings page and saves a field on purpose. Once saved, the override wins outright and is read fresh on every subscriber invocation and every worker tick - see Copy to clipboardmergeEffectiveOptions in Copy to clipboardsrc/lib/invoicing/effective-config.ts.

Copy to clipboardPOST /admin/infakt/settings accepts any subset of Copy to clipboardinvoicing_paused, Copy to clipboardcurrency, Copy to clipboardksef_mode, Copy to clipboardtrigger_event, Copy to clipboardenvironment and Copy to clipboardapi_key - only the fields present in the body are written. Copy to clipboardGET /admin/infakt/settings reports both Copy to clipboardsettings (the raw override, null where unset) and Copy to clipboardeffective (the merged, currently-in-effect value).

Copy to clipboardapiKey is handled differently from the other four. It is a credential, not configuration, so an override is encrypted (AES-256-GCM, via Node's built-in Copy to clipboardcrypto, no new dependency) with the plugin's Copy to clipboardsettingsEncryptionKey option before it is ever written to the database, and it is never read back by any admin route - Copy to clipboardGET /admin/infakt/settings reports only Copy to clipboardapi_key_configured (true from either source) and Copy to clipboardapi_key_override_configured (true when an override specifically is saved). Setting Copy to clipboardsettingsEncryptionKey is required before an Copy to clipboardapiKey override can be saved at all; Copy to clipboardPOST /admin/infakt/settings { "api_key": "..." } answers 400 with a message naming the option otherwise. Copy to clipboardPOST /admin/infakt/settings { "api_key": "" } clears a saved override and falls back to the boot-time Copy to clipboardapiKey. If Copy to clipboardsettingsEncryptionKey is ever rotated or removed, a previously saved override can no longer be decrypted; the plugin falls back to the boot-time Copy to clipboardapiKey silently at every runtime decision point (never a crash), and Copy to clipboardapi_key_override_configured staying Copy to clipboardtrue while invoicing behaves as if it were Copy to clipboardfalse is the signal that this happened.

Copy to clipboardstartDate is optional, not an enable switch

Leave it unset and every order the pipeline otherwise sees is invoiced, subject to every other gate (fully paid, right currency, not canceled, not already invoiced outside this pipeline - see below). There is no back-catalogue risk in leaving it unset on a brand-new store: it is a floor for stores that already have order history this plugin should not touch, not a precondition for the plugin to run.

Set it to add that floor. It must be exactly Copy to clipboardYYYY-MM-DD and a real calendar date - a value that is present but malformed still fails loudly at boot rather than being read as "unset", because a typo here must not silently turn into "invoice everything". The parse has to round-trip: Copy to clipboardDate.parse("2026-02-30") succeeds by rolling over to March 2nd, which would make a fat-fingered floor silently mean a different day than it reads as.

Environment variables

Variable Default Effect Copy to clipboardINFAKT_WORKER_CRON Copy to clipboard*/5 * * * * Cron schedule for the worker job. A reconciliation interval, not a latency budget: a paid order is invoiced immediately by the Copy to clipboardpayment.captured subscriber, and this tick retries whatever that could not finish. Copy to clipboardINFAKT_SETTLEMENT_CRON Copy to clipboard17 * * * * Cron schedule for the settlement reconciliation - the read-only pass that compares inFakt's Copy to clipboardpaid_date with what Medusa captured. A separate job on separate columns, which never takes the invoicing claim and never marks anything paid. Copy to clipboardINFAKT_INVOICING_DISABLED unset Copy to clipboard1/Copy to clipboardtrue/Copy to clipboardyes force-disables invoicing, overriding everything else.

Why the cron is not an option. Medusa evaluates a scheduled job's Copy to clipboardconfig.schedule at plugin-load time, before the DI container - and therefore this plugin's options - exists. There is no supported way for a static Copy to clipboardconfig export to read a resolved module's options, so this one setting has to be an environment variable.

Why the force-off is an environment variable too, and not a plugin option. Unlike the pause switch, this one is deliberately NOT reachable from the admin - an operator flips it at deploy time (or during an incident) without touching the database, and it cannot be undone by anyone clicking around in the admin. See Enablement above.

How an order becomes an invoice

payment.captured -> subscriber -> InfaktInvoice row (status: pending)
|
worker tick (every 5 min, single-flighted)
|
gates: not backfilled, startDate, currency, canceled, fully paid
|
submit_started_at -> POST /async/invoices.json
|
task_reference -> poll until 201 + invoice_uuid
|
invoice_number -> GET /invoices/{uuid}.json
|
ksef_sent_at -> POST /ksef2/documents/{uuid}/send.json (when required)
|
ksef_number -> poll until "success"
| (a configured webhook only
| wakes this poll sooner)
|
event_emitted_at -> emit infakt.invoice.issued
|

The trigger only enqueues

Copy to clipboardpayment.captured fires once per capture, and an order can be captured in parts (several payment collections, a partial capture, a split payment). Invoicing on the first capture would issue an invoice for the full order total against a partial payment.

So the subscriber's only job is to create the ledger row. Every consequential decision belongs to the worker, which is idempotent, restartable, and re-reads live state on every tick. A deferred order needs no second event; the next tick picks it up.

One further event is subscribed and is deliberately not a trigger: Copy to clipboardallegro.order.billing_ready, emitted by Copy to clipboard@zanreal/medusa-allegro the moment a marketplace order's billing address is written. It never enqueues - admission stays with the configured trigger - it only advances a row that is already waiting for that address, instead of leaving it to the next cron tick.

Duplicate delivery is harmless (Copy to clipboardorder_id is unique, so a second enqueue is a no-op). A missed event is recoverable through Copy to clipboardPOST /admin/infakt/enqueue, since Medusa's event delivery is at-most-once.

Runs are single-flighted

The worker takes an atomic claim - one Copy to clipboardUPDATE ... WHERE ... RETURNING against the run state row - and holds it for the whole run. Zero returned rows means the claim was refused; nothing is inferred.

That is not bookkeeping. Two overlapping runs reading the same due row would both pass the crash-window check, both write Copy to clipboardsubmit_started_at, and both POST a create: two real numbered invoices for one order.

A claim older than ten minutes is treated as a crashed process and taken over, so a dead run can never wedge invoicing permanently. Releases are conditional on the claim token, so a taken-over run cannot clear its successor's lock.

The crash window, and why the create is never retried

Copy to clipboardsubmit_started_at is written to the database before the create call. On resume, a row with that marker set but no Copy to clipboardtask_reference means the create may already have reached inFakt.

Such a row goes to Copy to clipboardneeds_review and the create is never retried automatically. That is the one failure mode this design refuses to guess about: inFakt has no idempotency key, so a retried create can issue a second real numbered invoice, and the customer receives two invoices for one order.

Resolving it is a human decision with exactly two outcomes, both on the order's detail page:

  • Link invoice - there is a stray invoice in inFakt. Paste its uuid; the row adopts it and continues from the KSeF step. No new invoice is created.
  • No invoice in inFakt - you checked and there is none. Confirm explicitly, and the create is allowed to run again.

The Retry button is not rendered at all for these rows, and the server refuses a retry on them independently.

Every other failure retries with backoff (base 10 minutes, doubling, capped at 6 hours, 8 attempts), because every other failure is either idempotent or observable. HTTP Copy to clipboard400/403/404/405/409/422 go straight to Copy to clipboardneeds_review - retrying an identical request against those cannot succeed. Copy to clipboard429 and Copy to clipboard5xx are deliberately not in that set.

Waiting is not failing: a deferral (inFakt still processing, KSeF still processing, the order not yet fully paid) does not consume an attempt. An order that sits unpaid for a week still has its full retry budget when the money lands.

The total-match guard

The sum of the invoice lines must equal the order total, grosz for grosz, or the build fails and the row goes to Copy to clipboardneeds_review.

An invoice is a legal statement of what the buyer paid. One that states a different number is worse than no invoice at all, because correcting it needs a formal corrective invoice. So a discount, gift card, credit line or fee adjustment this plugin does not model gets a human's attention rather than being silently absorbed into a line.

Practically:

  • Every amount is converted to integer minor units exactly once. Rounding per unit and again after multiplying is how a line total drifts one grosz from what was charged.
  • The mapper reads each line's Copy to clipboarditem.total (tax-inclusive, post-discount), not Copy to clipboardunit_price * quantity. The latter is pre-discount and would fail this guard on every promoted order.
  • A missing order total is refused outright: there is nothing to verify against, and trusting the line sum blindly is exactly what this guard exists to prevent.

Shipping becomes one line per method that costs anything, labelled Copy to clipboardDostawa - {method name}. Free methods produce no line.

Orders backfilled from a legacy system

A store migrating off an older invoicing system typically ports its order history into Medusa with the invoice it already issued recorded on the order itself, not in this plugin's ledger: Copy to clipboardorder.metadata.invoice_number (and usually Copy to clipboardmetadata.invoice_source, naming where it came from).

The worker treats a non-empty Copy to clipboardinvoice_number in that metadata as a fact, not a suggestion. Before any other check runs, an order carrying one is skipped with Copy to clipboardskip_reason: "already invoiced outside the pipeline", and nothing is ever submitted to inFakt for it. This is a build-time gate, so it holds no matter which path put the row in the queue - an Copy to clipboardorder.placed trigger firing at import time, or an operator manually queuing it through Copy to clipboardPOST /admin/infakt/enqueue.

Two structural facts make this a narrower problem than it first sounds:

  • A backfilled order has no Medusa payment, so Copy to clipboardpayment.captured never fires for it. A store on the default trigger never enqueues these orders at all - the metadata guard above is the safety net for Copy to clipboardorder.placed-triggered stores and for the manual recovery endpoint, not the first line of defense.
  • Nothing about that guard reaches inFakt. It reads the order's own metadata and refuses, which is a decision about this pipeline. Recovering the invoice itself is a separate, deliberate act; see the next section.

An export that produced this metadata can also be WRONG. An order whose invoice number was lost in the export looks, to the guard above, like an order that was never invoiced - while the invoice sits in inFakt, correctly issued and filed. That is what the reconciliation below exists to recover, and it recovers it from inFakt, not from whatever produced the export.

Adopting invoices that already exist in inFakt

Copy to clipboardGET /admin/infakt/reconcile, and the Adopt existing invoices panel on the plugin's settings page.

For a store whose history was invoiced somewhere else: the documents are real, numbered and filed, and only this ledger does not know about them. The reconciliation reads invoices from the inFakt API and matches them to Medusa orders on order data alone. No other system is consulted, and none needs to exist - not the legacy system that issued them, not the export that lost them.

The rules, and why each one is there

Every rule below is a hard gate. There is no score, and no signal can make up for a failing one.

Gate Rule Why Issue date Copy to clipboardinvoice_date within Copy to clipboardtolerance_days (default 7, max 31) of the order's Warsaw calendar day. An undated invoice is dropped. Keeps a repeat customer's later order from matching the earlier invoice for the same basket. Warsaw, because that is the day the invoice itself is dated. Buyer identity B2B: exact normalized NIP. B2C: exact email OR exact normalized full name. The one signal that says these are the same person. Diacritics and NIP prefixes are normalized away first. Gross total Integer equality in grosze, no tolerance. Currency must agree when both state one. An order whose total cannot be read matches nothing and says so. An amount that is close is an amount that is wrong. A one-grosz drift means it is a different document, and an unreadable total is never treated as 0. Uniqueness Exactly one invoice may survive all three, unless the chronological pairing below settles it. Two survivors is the duplicate-invoice case, which is precisely what a human has to look at. Not already taken The invoice must not already be recorded on another ledger row, by uuid or by number. One document settles one order. The number check matters because an imported row may carry only the number. The order's own claim When Copy to clipboardorder.metadata.invoice_number names an invoice, the match must BE that one. An order that names an invoice and matches a different one by amount and buyer is a warning, not a discovery.

What an invoice calls its lines is never compared. Not as a gate, not as a confidence grade, not as a tiebreak. The two systems name a line their own way for perfectly legitimate documents - a catalogue title here, a shortened trade name or a single aggregate line there - so a name check can only ever report a correct match as weaker than it is, and an operator then learns to ignore the grade. The signals are the person, the date and the amount.

Same-day duplicate orders, paired by chronology

One buyer, several orders on one day, all for the same amount, invoiced with several documents that are equally identical: nothing but the order of events separates them, and refusing every one of them helps nobody. So the orders are sorted by the moment they were placed, the invoices by their number within their shared issue date, and the two lists are paired one to one.

That is the ONLY place chronology decides anything here, and it is fenced in hard. It engages only when the two sides are genuinely twins - same buyer, same Warsaw day, same gross total, the very same set of candidate invoices, and those invoices agreeing on issue date, amount and currency - and only when the counts on both sides are equal. Everything else refuses the whole group and says so per order:

  • Counts differ (three orders, two identical invoices): any two of the three could be the invoiced ones, so nothing is determined and nothing is paired.
  • A lone order facing several candidates: there is no duplicate to pair against, so the other document belongs to something outside this scan.
  • The invoices are not twins (different issue dates): something other than chronology separates them, and guessing by nearest date is what this refuses to do.
  • The numbers are not one readable sequence: the sequence is derived, not assumed - every number must share one format and vary in exactly one digit position, which is then the counter. Two formats, two varying positions or a repeated counter refuse.
  • Two orders share a placement instant, or one has none: they cannot be ordered.
  • An order outside the group also matches one of these invoices: pairing could hand over a document that belongs elsewhere.

Any other multi-candidate case is reported, never guessed. Copy to clipboardmatching.ts has a nearest-date tiebreak for the crash-window flow, where a human is already looking at one order and knows an invoice exists; it is deliberately not used here.

The confidence grade

Every proposal is graded Copy to clipboardhigh or Copy to clipboardmedium, and the grade is about what a human should look at rather than whether the match is allowed - every gate above passed either way.

Copy to clipboardhigh when the buyer was identified by a key (a NIP or an email address) and the invoice was issued on the order's day or the day next to it, or when the order names that invoice number itself, which is the order's own claim rather than an inference.

Copy to clipboardmedium when the buyer was matched on a full name alone (two people can share one), when the issue date sat more than a day from the order (still inside the window the operator asked for, but no longer the obvious document), or when the chronological pairing settled it, which is correct only if duplicate orders were invoiced in the order they were placed.

How many invoices happened to be in the date window is recorded as evidence but does not grade: candidates that lost on identity or amount lost on a hard gate, and letting their number darken a survivor would mark every match in a busy week as weaker than the same match in a quiet one.

What it will not do

  • It will not touch an order that already has a ledger row. Not re-match it, not update it, not report it. That is the idempotency guarantee, and it rests on the same unique Copy to clipboardorder_id the enqueue path does: a re-run writes nothing.
  • It will not issue anything. No invoice is created, nothing is sent to KSeF, and no Copy to clipboardinfakt.invoice.issued event is emitted. An adopted row is written straight to Copy to clipboarddone, and Copy to clipboardlistDueInvoices never picks a Copy to clipboarddone row up again.
  • It will not apply anything you did not ask for. Both methods are a dry run unless the POST body carries BOTH Copy to clipboardapply: true and an explicit Copy to clipboardorder_ids list, and the server re-derives each named order's match before writing - a plan that has gone stale between the preview and the click cannot be applied from the client's copy of it.

What is recorded

An adopted row carries Copy to clipboardadopted_at, the invoice's uuid and number, Copy to clipboardcompleted_at set to the day the document was issued, and Copy to clipboardadopted_evidence: the signal that identified the buyer, the gross total, how far the issue date sat from the order, and whether chronology had to tell same-day duplicates apart (Copy to clipboardtie_breaker, with the order's place among them). Signal KINDS and numbers only - never an email or a name, because this table holds no buyer data.

Copy to clipboardksef_required is recorded too, decided from the tax code on the adopted document exactly as Copy to clipboarddecideKsef would have decided it. On a terminal adopted row it is an audit fact, not an instruction: nothing acts on a Copy to clipboarddone row, and the order widget says "not tracked by this plugin" rather than claiming a filing is queued.

Which inFakt endpoints it uses

  • Copy to clipboardGET /invoices.json with Copy to clipboardq[invoice_date_gteq] / Copy to clipboardq[invoice_date_lteq], paged 100 at a time. The date range is the only server-side narrowing that helps: inFakt has no filter for the gross total, and none for the buyer's email or name, so those are applied here, after the page is read. That list response carries every field the rules read - buyer, amount, currency, issue date and number - so the reconciliation makes no per-invoice detail call at all. It used to fetch Copy to clipboardGET /invoices/{uuid}.json for line positions; nothing reads those now.

It is a read. The reconciliation calls nothing that creates, sends or files.

Settlement: does inFakt agree the order was paid?

Issuing an invoice and recording that it was paid are two jobs on two clocks. The first has a legal deadline; the second is bookkeeping. They are kept apart - separate job, separate columns, separate events - because an earlier version wedged them together as a retry loop and held issued, KSeF-filed invoices out of Copy to clipboarddone for fifteen minutes at a time.

Copy to clipboardpaid_date is the signal, and nothing else is. inFakt's Copy to clipboardstatus is a single last-write-wins enum that any later action overwrites, including a plain PDF download. Invoice Copy to clipboard2/09/2026 was marked paid at 12:40:03 and read back three seconds later as Copy to clipboardstatus: "sent" - our own Allegro attachment had fetched the PDF - with its Copy to clipboardpaid_date intact. The amounts are no better in the other direction: invoice Copy to clipboard9/08/2026 carries Copy to clipboardstatus: "paid" together with Copy to clipboardpaid_price: 0. Both amounts are recorded as evidence and neither is ever decisive.

Four nullable columns carry the result, with no backfill: Copy to clipboardsettled_at (inFakt's Copy to clipboardpaid_date), Copy to clipboardsettlement_checked_at (when anyone last looked), Copy to clipboardsettlement_drift (how the two systems disagree, or null) and Copy to clipboardsettlement_paid_minor (evidence only).

Drift code Meaning Copy to clipboardunsettled Captured in full in Medusa, no Copy to clipboardpaid_date in inFakt. The only code a fix could ever safely touch Copy to clipboardrefunded_but_settled Money went back, inFakt still has it settled. Report only Copy to clipboardsettled_without_capture inFakt has it settled, Medusa captured nothing. Report only Copy to clipboardamount_mismatch inFakt has it settled, Medusa captured part of the total. Report only Copy to clipboardunreadable The invoice or the order could not be read well enough to compare

Nothing is fixed automatically in this version - not even Copy to clipboardunsettled. The report names the rows a future fix would touch (Copy to clipboardauto_fixable) so the blast radius can be judged before anything is armed. Adopted invoices are reported and never fixed, whatever their code: their payment bookkeeping belongs to whoever issued them.

It runs on Copy to clipboardpayment.captured, Copy to clipboardpayment.refunded and Copy to clipboardorder.canceled for the one order each names, and hourly at Copy to clipboard17 * * * * as the backstop over a sliding ninety-day window (a full pass is available on demand). A row that settles and agrees is never read again; a row that disagrees is re-read at most every six hours. It takes no invoicing claim, never fetches a PDF, never marks anything paid, and never writes payment state back into Medusa.

Where the buyer's NIP comes from

Medusa core has no field for a business buyer's tax id, so every storefront puts it somewhere different. The default extractor tries, in order:

  1. Copy to clipboardorder.metadata.nip
  2. Copy to clipboardorder.billing_address.metadata.nip
  3. a NIP parsed out of Copy to clipboardorder.billing_address.company

It also accepts Copy to clipboardtax_id, Copy to clipboardtaxId, Copy to clipboardvat_id and Copy to clipboardvatId as metadata keys. Override it entirely with the Copy to clipboardnipExtractor option rather than reshaping your orders:

options: {
nipExtractor: (order) => order.metadata?.company_tax_id as string | undefined,
}

Two deliberate restrictions:

  • The shipping address is never consulted. A company shipping address on a consumer order is common - delivery to an office - and reading it would file that consumer's invoice to KSeF under their employer's NIP.
  • Parsing from Copy to clipboardcompany is strict. The field must contain exactly one ten-digit candidate and it must pass the NIP checksum. Otherwise a phone number or a KRS number in the wrong field would turn a consumer invoice into a B2B one filed under a stranger's number.

A NIP that normalizes to ten digits but fails its checksum is still used. inFakt, and ultimately KSeF, is the authority on whether a number is acceptable; refusing here would park a legally required document over a check this plugin is not the arbiter of.

Source 3 is a compatibility path, not the contract

Copy to clipboardorder.metadata.nip is where a tax id belongs. Reading one out of Copy to clipboardbilling_address.company exists because some upstream systems concatenated the two into the name, and this plugin then has to un-concatenate them - which is inherently approximate. It got it wrong: two invoices went out reading Copy to clipboardFirma ( ), because the strip removed the digits and the word Copy to clipboardNIP but not the brackets they sat in. They were already numbered and already filed to KSeF, so correcting them needs a formal corrective invoice.

Copy to clipboard@zanreal/medusa-allegro writes Copy to clipboardorder.metadata.nip now and leaves the company name alone. Source 3 stays for the orders that predate that, and for storefronts that still do it - but if you control the writer, write the metadata key.

The last gate on a company name

Whatever the cleaning produced, a company name that still looks mangled is never sent. The invoice goes to Copy to clipboardneeds_review instead. Five shapes are refused, all of them residue rather than anything a legal name has: an empty bracket pair, an unbalanced bracket, a leading or trailing dangling separator, a name with no letters or digits at all, and a name that still contains the tax id. A trailing full stop is explicitly fine - Copy to clipboardSp. z o.o. ends in one.

The trade is deliberate and one-directional: a delayed invoice is an operator task on the Copy to clipboardneeds_review queue, a wrong one is a legal document that can only be undone with another legal document.

The same gate, everywhere free text reaches the document

The company name was never the only assembled string on an invoice, and the gate above was only ever applied to it. The shape checks now live in Copy to clipboardtextShapeDefect, and three other places on the document use them.

The VAT regime reads the same company field, and used to read it raw. Copy to clipboarddecideVatRegime treats a non-empty company name as one of the two signals that a non-EU buyer is a business rather than a consumer, and a business supply is invoiced Copy to clipboardnp - outside Polish VAT - with a statutory annotation on the face of the document. That decision was made on Copy to clipboardbilling_address.company verbatim while the payload printed the cleaned value, so one order could be a company to the regime and have no company name at all to the builder. It now goes through Copy to clipboardbusinessNameSignal, which cleans the field and then puts it through the full Copy to clipboardcompanyNameDefect gate. A non-EU buyer whose company field holds only residue is Copy to clipboardblocked - a Copy to clipboardneeds_review row - rather than an invoice asserting a place of supply outside Poland.

Note which branch of the gate does the work there: Copy to clipboardcleanCompanyName deliberately falls back to the raw value when cleaning empties it, so a field holding nothing but Copy to clipboard(5261040828) survives as itself - the brackets balance, digits are present, nothing dangles. Only "the name still contains the tax id" catches it.

A shipping line is a concatenation too. Copy to clipboardshippingLineName glued the carrier name behind Copy to clipboardDostawa - on nothing but truthiness, so a method named Copy to clipboard" " or Copy to clipboard"-" printed Copy to clipboardDostawa - on an invoice line: the same stranded separator as Copy to clipboardFirma ( ), one field over. A method with no usable name is now simply Copy to clipboardDostawa, which is what the line means.

A line item name is glued and then un-glued. Copy to clipboardlineItemName joins the product title to the variant title with Copy to clipboard" - ", and where the variant title already begins with the product title it strips the shared prefix back off. That strip removed only whitespace and hyphens, so a catalogue that separates its own variants with something else - Copy to clipboardAntivirus Plus / 1 rok - came out as Copy to clipboardAntivirus Plus - / 1 rok. It now drops every leading non-alphanumeric, and falls back to the product name alone rather than printing a bare separator.

Truncation cut inside a character. Service names are capped at 255 for inFakt, and Copy to clipboardString.prototype.slice counts UTF-16 code units - a marketplace title whose 255th boundary landed inside a surrogate pair emitted a lone surrogate, which is a replacement glyph on the customer's PDF. Copy to clipboardtruncateServiceName walks whole code points and trims whatever separator the cut leaves behind.

None of these park an invoice; they are shape fixes at the point of assembly rather than gates. Only the regime's company-name signal can send a row to Copy to clipboardneeds_review, and only in the direction of asking a human rather than issuing a document.

KSeF

Modes

Copy to clipboardksef.mode Behaviour Copy to clipboardnip-only (default) A buyer with a NIP is filed. A consumer is not. What the law wants. Copy to clipboardall Every invoice is filed, including consumer ones. Copy to clipboardnever Nothing is filed. Development and testing only.

Copy to clipboardksef.decide overrides the mode entirely, including Copy to clipboardnever. An operator who wrote a predicate has made a more specific statement than the mode does; the recorded reason says which of the two answered, so the override is visible in the audit trail.

The decision is frozen onto the row at build time, with its reason, in Copy to clipboardksef_required and Copy to clipboardksef_decision_reason. Re-deriving it from live config on a later tick would let a mid-flight Copy to clipboardksef.mode change reclassify an invoice that has already been issued.

Copy to clipboardrequireActive

With Copy to clipboardksef.requireActive on (the default in production), the worker verifies the inFakt account's KSeF integration via Copy to clipboardGET /ksef2/integration.json and fails the whole run loudly when it is not active - a clear error in the log and a red run state in the admin UI.

Letting the rows accumulate instead would be worse. An inactive integration makes every B2B submit fail with a 422, which is non-retryable, so every company invoice would quietly park itself for a human while a legal deadline passed. A red run state is something an operator notices; a growing queue is not.

The check runs at most hourly, and immediately when the integration is known to be inactive, so fixing it in inFakt takes effect on the next tick. Re-check KSeF on Settings -> inFakt forces it right away.

A failed check is recorded as an error, never as Copy to clipboardactive: false. "We could not reach inFakt" and "your integration has lapsed" call for completely different responses.

The KSeF webhook

inFakt's KSeF documentation asks for a webhook rather than repeated Copy to clipboardstatus.json reads ("Zachęcamy do skonfigurowania webhooka, który poinformuje o zmianie statusu przetwarzania na końcowy. Ograniczy to ilość zbędnych zapytań."). Filing a B2B invoice has been mandatory since April 2026, which makes it the one step in this pipeline with a statutory deadline behind it, so the status should arrive when inFakt has it.

Copy to clipboardPOST /hooks/infakt/ksef is that endpoint. It is optional: leave Copy to clipboardwebhookSecret unset and the plugin behaves exactly as it did before this existed - the KSeF poll rides each document to a terminal state inside the run, and the cron sweeps up whatever it could not finish. Wiring the webhook does not replace either of them.

The webhook is a nudge, never a fact. Nothing is read out of the payload except which invoice to look at. The route then re-reads the status from Copy to clipboardGET /ksef2/documents/{uuid}/status.json through the same Copy to clipboardpoll-ksef step the cron runs, so the persisted columns advance through their normal code and remain the only source of truth. A forged or replayed delivery, if one ever got past the signature, can at worst cause a status read - it cannot write a KSeF number, park an invoice, or mark an unfiled document as filed.

What inFakt sends

Two events concern this plugin, out of the seven in inFakt's table: Copy to clipboardsend_to_ksef_success and Copy to clipboardsend_to_ksef_error. Everything else is answered 200 and ignored.

{
"event": {
"name": "send_to_ksef_error",
"uuid": "432cc5fc-f7ca-4afa-9420-7d6410fc0940",
"created_at": "2023-10-02T11:30:30.656+02:00",
"retry_counter": 0
},
"resource": {
"invoice_uuid": "ee2484ce-052c-495a-9ce1-5bd3ae8314aa",
"status": "error",
"ksef_number": null,
"status_description": "Wystąpił problem podczas otwarcia sesji."
}
}

A webhook configured with "bez poufnych informacji" reduces Copy to clipboardresource to Copy to clipboard{ "uuid": "..." }. That mode works here too, and is arguably the better one to pick: the identifier is all this endpoint uses.

Signing

Every delivery carries Copy to clipboardX-Infakt-Signature, the hex HMAC-SHA256 of the raw request body under the secret inFakt generates per webhook and shows in its details in the panel. The route verifies it in constant time against the preserved raw bytes - not against re-serialised JSON, which would be different bytes - and answers 401 to a missing, malformed or wrong one, which is what inFakt's documentation says a failed verification must answer.

There is no timestamp and no replay window in inFakt's scheme, so the signature proves authenticity and nothing more. That is survivable only because of the "nudge, never a fact" property above; do not weaken it.

With no Copy to clipboardwebhookSecret configured the route answers 401 to every request, including one carrying a valid signature. An unauthenticated endpoint that advances a legally significant document on anyone's say-so is worse than a webhook nobody has wired up yet.

Registering it

There is no API for this - webhooks are created by hand, per account, in the panel.

  1. Go to Ustawienia -> Inne opcje -> Webhooki (Copy to clipboardhttps://app.infakt.pl/app/webhooki), press Dodaj nowy webhook.
  2. Adres URL: the public URL of Copy to clipboardPOST /hooks/infakt/ksef on your deployment.
  3. Zdarzenia: tick Copy to clipboardsend_to_ksef_success and Copy to clipboardsend_to_ksef_error.
  4. Zawartość danych: either mode works.
  5. Copy the generated secret into the Copy to clipboardwebhookSecret option and restart, before the next step - the activation challenge is signed too, and an unconfigured route answers it 401.
  6. Press Zweryfikuj. inFakt POSTs a random Copy to clipboardverification_code, this route echoes it back, and the webhook moves from Do weryfikacji to Aktywny.

Two operational facts worth knowing. inFakt retries a delivery until it is answered 200 or 201, emails after six failures and switches the webhook off automatically after ten - which is why this route answers 200 to everything it cannot act on, rather than spending that budget on a condition no retry can change. And inFakt publishes the addresses its API and webhooks call from, so an ingress can allowlist them:

18.195.224.145 35.157.20.95 18.153.130.220 18.158.11.58 18.158.35.194
18.159.228.63 18.195.110.70 3.121.46.57 3.124.100.165 3.125.243.218
3.126.125.137 3.67.214.209 3.79.196.143 3.79.223.93 52.28.116.250

The feature is marked beta by inFakt, and their docs contradict themselves on the auto-disable threshold (ten failures in one place, eleven in another). Treat the poll as load-bearing, not as legacy.

Why Copy to clipboard/hooks and why it is not a hole in Copy to clipboard/admin

Copy to clipboard/hooks is a custom prefix Medusa applies no authentication to, and it is the prefix a Medusa deployment typically publishes when it publishes anything at all - so this route needs no ingress change where Copy to clipboard/hooks is already public, and Copy to clipboard/admin is untouched. This is an unauthenticated route of its own, with its own credential and no authority: it is not Copy to clipboardAUTHENTICATE = false on anything, and no admin matcher was widened. See the comment in Copy to clipboardsrc/api/middlewares.ts, which registers exactly one thing for it - Copy to clipboardbodyParser.preserveRawBody, without which the HMAC cannot be checked at all.

If your deployment does not publish Copy to clipboard/hooks, publish that one path, ideally locked to Copy to clipboardPOST and to the addresses above. Do not publish Copy to clipboard/admin to get it.

Operator runbook: needs_review

A Copy to clipboardneeds_review row raises a Medusa admin notification that deep-links to the order. Open that order - the Invoicing widget on its detail page carries the reason, PII-free, and usually names the fix, alongside the same operator actions listed below.

What it says What happened What to do a previous inFakt create attempt may have gone through... The process died between the create being sent and its reference being stored. Look for an invoice for that order in inFakt. Found one: Link invoice with its uuid. None: No invoice in inFakt, confirm. line total N does not match order total M The order has a discount, credit line or fee this plugin does not model. Decide what the invoice should say. Invoice it manually in inFakt and Link invoice, or Skip with a reason. buyer address is incomplete (missing: ...) The billing address lacks a field inFakt requires. Fix the order's billing address, then Retry. buyer tax id does not normalize to a 10-digit NIP (N digits...) The captured tax id is not a Polish NIP - often a foreign VAT id. Correct or remove the tax id on the order, then Retry. Removing it makes the order a consumer invoice, outside KSeF. ...could not be confirmed against VIES... VIES, or that member state's node, was unreachable. Not a rejection. Retry once VIES is back. If this recurs, validate at checkout and cache on the order, or set Copy to clipboardcrossBorder.viesFallback. the VAT id was issued by X but the billing country is Y The two pieces of evidence disagree

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?