Developer notes
Technical reference for developers who customize Lexware Office Connector. Every call to Lexware Office is taken from the official Public API documentation (https://developers.lexware.io/docs/, read 2026-11-07). Items the documentation does not state are marked not documented and are not claimed anywhere in the product.
Add-on identity
Section titled “Add-on identity”| Item | Value |
|---|---|
| Add-on id | kv_lexware_office |
| Function prefix | fn_kv_lexware_office_ |
| Language variables | kv_lexware_office.* |
| CSS scope | .kv-lexware-office |
| PHP namespace | Tygh\Addons\KvLexwareOffice |
| Preset | accounting (generated by kv new --kind integration), then rewritten for the Lexware Office API (API key, no OAuth) |
Structure
Section titled “Structure”| Class | Role |
|---|---|
Client\BaseClient |
cURL with TLS verification and timeouts, JSON, error mapping (regular and legacy error bodies), masked logging |
Client\Api |
All Lexware Office calls (table below); rate limit: a database lock per API key keeps 520 ms between calls, a 429 is retried with back-off |
Client |
Client per company, “Test connection” (stores organizationId, profile and the countries list) |
Accounts, Secret |
API key of test and live mode sealed with AES-256-GCM; one account per store owner or per vendor |
Sync |
Pure builders (invoice, contact, credit note, tax types) and the queue jobs syncInvoice, syncCredit, syncReturn, syncPayment |
Docs |
Table kv_lexware_office_docs (invoices and credit notes), file download, permalink |
Webhook |
RSA-SHA512 signature check with the published public key; event ids |
Config, Access |
Settings, owner mode, who may see and who may act |
Queue, Jobs, JobException |
Job queue: back-off 5 min / 15 min / 1 h / 3 h / 12 h, per-job isolation, overlap lock |
Idempotency |
lock() per order (GET_LOCK), claim(), key() |
Log |
Event log with masking, filters, CSV, retention |
Lexware Office API: endpoints, fields and where the documentation says so
Section titled “Lexware Office API: endpoints, fields and where the documentation says so”| What the add-on does | Call | Documentation section |
|---|---|---|
| Base URL, version | https://api.lexware.io/v1/... (the old gateway URL was replaced; change log 27.05.2025: lexoffice became Lexware Office) |
Getting started, change log |
| Authentication | Authorization: Bearer {API key}; the key is created by the merchant at app.lexware.de/addons/public-api |
Authentication |
| Rate limit | 2 requests per second per client, over all endpoints; HTTP 429 and the call is not performed; token bucket and exponential back-off recommended | Rate Limits |
| Test connection | GET /v1/profile (organizationId, companyName, taxType, distanceSalesPrinciple, smallBusiness, businessFeatures) |
Profile Endpoint |
| Country classification | GET /v1/countries (taxClassification de, intraCommunity, thirdPartyCountry) |
Countries Endpoint |
| Find a contact | GET /v1/contacts?customer=true&email=... (pattern match: _, %, backslash escape; at least 3 characters; &, <, > HTML-encoded for names) |
Filtering Contacts, Search String Encoding |
| Create a contact | POST /v1/contacts with version: 0, roles.customer: {}, company (name, vatRegistrationId, one contactPersons entry with lastName) or person (lastName), addresses.billing[0] (countryCode required), emailAddresses, phoneNumbers, note |
Create a Contact |
| Update a contact | GET for version, then PUT /v1/contacts/{id}; a contact with more than one address / e-mail cannot be updated through the API |
Update a Contact, Optimistic Locking |
| Create an invoice | POST /v1/invoices (draft) or POST /v1/invoices?finalize=true (status open, number assigned). Required: voucherDate, address (contactId, or name + countryCode), lineItems, totalPrice.currency, taxConditions.taxType, shippingConditions.shippingType (shippingDate for delivery). The status cannot be changed through the API afterwards |
Create an Invoice |
| Line items | type: custom (no Lexware article needed): name, quantity (4 decimals), unitName, unitPrice {currency: EUR, netAmount or grossAmount (by taxType, 4 decimals), taxRatePercentage}, discountPercentage; text up to 255 / 2000 characters |
Line Items Details, Texts in Sales Vouchers |
| Tax types | net, gross, vatfree, intraCommunitySupply, constructionService13b, externalService13b, thirdPartyCountryService, thirdPartyCountryDelivery, photovoltaicEquipment; vat-free types other than vatfree need a referenced contact and rate 0; taxSubType distanceSales / electronicServices for EU consumers |
Tax Conditions Details |
| Tax rates | Domestic: 7 and 19 (0 for vat-free). EU destination rates only for distance sales when distanceSalesPrinciple is DESTINATION |
Valid Tax Rates |
| Discount | totalPrice.totalDiscountAbsolute |
Total Price Details |
| Read an invoice | GET /v1/invoices/{id}: voucherStatus (draft, open, paid, voided), voucherNumber, electronicDocumentProfile (NONE, EN16931 = ZUGFeRD, XRechnung), totalPrice.totalGrossAmount |
Invoices Properties |
| Credit note | POST /v1/credit-notes?precedingSalesVoucherId={invoice}&finalize=true. “When referenced to an invoice, a finalized credit note is immediately paidoff and the open amount of the invoice is reduced by the amount of the credit note.” An invoice in draft is refused with 406 |
Pursue to a Credit Note |
| Files | GET /v1/invoices/{id}/file and /v1/credit-notes/{id}/file with Accept: application/pdf (every profile) or application/xml (XRechnung only; 404 for ZUGFeRD and plain PDF); a draft has no file (409). The PDF of an XRechnung is only a preview |
Download an Invoice File |
| Payment state | GET /v1/payments/{voucherId} (read only): openAmount, paymentStatus (balanced, openRevenue), voucherStatus, paidDate, paymentItems. There is no call to book a payment through the API |
Payments Endpoint |
| Look up a lost document | `GET /v1/voucherlist?voucherType=invoice | creditnote&voucherStatus=any&createdDateFrom=…&size=250&page=N (voucherTypeandvoucherStatusare mandatory; up to 250 per page), thenGET /v1/invoices |
| Event subscriptions | POST /v1/event-subscriptions {eventType, callbackUrl} (409 when it exists; HTTPS with a valid certificate, Lexware sends a HEAD request first), GET, DELETE /v1/event-subscriptions/{id}. Events used: invoice.status.changed, payment.changed, credit-note.status.changed |
Event Subscriptions Endpoint |
| Webhook signature | RSA-SHA512 signature of the JSON body (without whitespace and line breaks), base64, header X-Lxo-Signature; verified with the public key published in the documentation (embedded in Webhook::PUBLIC_KEY). The sample payload and signature of the documentation are a unit test |
Verify Authenticity |
| Webhook delivery | Answer 200, 201, 202 or 204 within 5 seconds; 401, 403, 429 and 5xx are retried (5 retries at 10, 20, 40, 80, 160 s, then 20 retries every 2 h); 404 removes the subscription; payload organizationId, eventType, resourceId, eventDate (no event id: the add-on hashes type, resource and date) |
Delivery Failure Behavior, Webhook Callback Properties |
| Permalink | https://app.lexware.de/permalink/invoices/view/{id} |
Deeplink to an Invoice |
| Errors | Regular: message, details[{violation, field, message}]; legacy (contacts, files, vouchers): IssueList[{i18nKey, source, type}]; 401 {"message": "Unauthorized"}; 402 contract issue; 406 validation; 409 conflict (outdated version, draft file); 500 “Internal server error or rate limit exceeded”; 504 after 30 s |
Error Codes, HTTP Status Codes |
| Date format | yyyy-MM-ddTHH:mm:ss.SSSXXX (milliseconds exactly 3 digits, offset with a colon) |
Datetime Format |
Not documented, therefore not claimed
Section titled “Not documented, therefore not claimed”- A sandbox: Lexware Office has none; “test mode” of the add-on is a second (trial) account with its own API key. The documentation says a free trial account with all features can be created and that you can create as many as you need.
- That
vatRegistrationIdaccepts non-German VAT ids: the documentation says the id “has to follow the german rules”. The add-on sends the buyer’s VAT id and, when Lexware Office rejects it (406 namingvatRegistrationId), creates the contact without it and logs a warning. - An idempotency key: there is none. The add-on stores a pending document row before the POST and looks the document up in the voucher list after a timeout (see below).
- That
electronicDocumentProfilecan be chosen through the API: the XRechnung format is produced by Lexware Office for a contact with a Leitweg-ID (xRechnung.buyerReferenceandvendorNumberAtCustomeron the contact, set in Lexware Office). The add-on reads the profile back and offers the XML download; it does not create Leitweg-ID contacts. - ZUGFeRD / XRechnung “compliance”: the add-on makes no claim; Lexware Office produces the files.
Idempotency and races (recurring-defect checklist)
Section titled “Idempotency and races (recurring-defect checklist)”Sync::syncInvoice()andSync::syncCredit()takeIdempotency::lock('lxo-order-<id>')(MySQLGET_LOCK) around “look up, POST, store”.- The document row is written in state
P(pending) withattempt_atand the expected gross before the POST; the remote id is stored in the same request as the answer. - After a timeout or 5xx (
ApiException::retryableand status != 429) the row staysP; the next attempt callsSync::reconcile()(GET /v1/voucherlist, all pages of 250, type and created since the attempt day; each document not already ours is read with GET and compared by the referenceRef KV-<order>-<random>that every document carries in its closing text (remark); Lexware assigns the voucher number itself and has no external reference field). A match is adopted; none within 20 minutes waits; none after that sets the row to failed with a “check Lexware Office, then press Issue now” message: the add-on never POSTs again by itself, a finalized document cannot be deleted through the API. A scan cut by the page or read cap stops the job the same way. - A 429 means “not performed”:
Api::send()repeats the call (1, 2, 4 s orRetry-After) up to three times. - One credit note per invoice (Credit Notes Endpoint > Purpose: “There can only be one credit note related to an invoice.”): when a row
Cin stateOorPexists for the order, a further credit is set toXwith a warning in the log and never sent (the API would answer 406). The first credit is capped to the invoice; a surplus of at most 5 cents (rounding) is removed withtotalDiscountAbsolute, more is refused. - Vendor scope:
Access::canActOn()(root admin, or the vendor of the record when owner mode is off) guards every POST that creates or retries a document;run_now,backfillandcronare admin only invendor_multivendor.post.php; lists are filtered by company. - Dates are the shop calendar day (
date(), CS-Cart sets the PHP zone from Appearance > Time zone), nevergmdate. - Text is cut with
mb_substr(..., 'UTF-8'); request bodies are encoded withJSON_UNESCAPED_UNICODE.
Database tables
Section titled “Database tables”| Table | Purpose |
|---|---|
?:kv_lexware_office_accounts |
API key per mode (sealed), organizationId, profile and countries cache (extra), last connection check |
?:kv_lexware_office_docs |
Invoices (kind I) and credit notes (kind C, ref_key full or ret<return id>): remote id, number, state P/O/E/X, Lexware status, profile, tax type, gross, open amount, paid date, stored line detail |
?:kv_lexware_office_map |
Contact per customer (guests: one per order) |
?:kv_lexware_office_log |
Log |
?:kv_lexware_office_jobs |
Queue |
Hooks used
Section titled “Hooks used”| Hook | Purpose |
|---|---|
PHP change_order_status_post |
Queues the invoice at the configured status and the credit note at the refund status |
PHP rma_update_details_post |
Queues a credit note for a return completed as a refund (only when the Returns add-on is active) |
Template orders:tabs_extra (admin) |
The “Lexware Office” tab of the order |
Template orders:details_bullets (storefront) |
The invoice download link on the customer’s order page |
The storefront output is limited to that link on orders.details (a session page of a signed-in customer, never cached by full page cache)
and the endpoints kv_lexware_office.pdf, .cron, .webhook (no template, not cacheable).
Test the HTTP layer without Lexware Office
Section titled “Test the HTTP layer without Lexware Office”Development only, in config.local.php:
$config['kv_lexware_office_base_url'] = 'http://127.0.0.1:11830/v1'; // mock server$config['kv_lexware_office_webhook_key'] = "-----BEGIN PUBLIC KEY-----\n..."; // public key of the mock (GET /__ctl/pubkey)$config['kv_lexware_office_currency'] = 'EUR'; // dev stores whose primary currency is not EURnode tests/mock-server/server.js 11830 starts a stand-in that follows the documentation (Bearer key, 2 requests per second with 429, required
fields, vat-free rules, totals computed server-side, drafts, files by Accept, credit notes against the invoice, voucher list, subscriptions,
RSA-signed webhooks). node tests/e2e/e2e.mjs --store cs417 starts its own mock and edits config.local.php for the run;
php tests/unit.php runs the unit tests against the official samples in tests/fixtures.
Template overrides
Section titled “Template overrides”Admin templates live in design/backend/templates/addons/kv_lexware_office/. Do not edit them in a store; they are replaced on update.