# pennylane-sdk-php **The unofficial PHP SDK for the [Pennylane](https://www.pennylane.com) API**, the French accounting and invoicing platform used by tens of thousands of companies and accounting firms. !!! note "Not affiliated" This is a community project, not affiliated with Pennylane SAS. A [Python sibling SDK](https://github.com/GoatAndCow7/pennylane-sdk) covers the same API with the same design contracts, if that fits your stack better. ## Why this SDK - **Complete**: all 213 operations of the Company API v2 and the Firm API v1 are implemented. Coverage is measured against the official OpenAPI specs by an audit script that runs in CI. - **Typed**: every response is a readonly PHP object, hydrated defensively so unknown or newly added API fields never break your code. - **Money as strings**: monetary amounts and quantities travel as numeric strings, never floats, matching the API contract and avoiding rounding errors on accounting data. - **Safe for accounting data**: the Pennylane API has no server-side idempotency, so the SDK never retries a POST after a server error. You will not create duplicate invoices because of a network hiccup. - **Rate-limit aware**: requests are paced client-side to the official limits (25 requests per 5 seconds for companies, 5 per second for firms), and 429 responses are retried honoring `retry-after`. Bulk exports just work. - **Pagination that disappears**: iterate over a list call and the SDK fetches the next pages for you, under the rate limit, re-sending your filters as the API requires. ## Install ```bash composer require goatandcow7/pennylane-sdk ``` Requires PHP 8.2 or newer. The SDK talks to the API through [PSR-18](https://www.php-fig.org/psr/psr-18/), so it needs an HTTP client. [Guzzle](https://github.com/guzzle/guzzle) is suggested and auto-detected through [php-http/discovery](https://github.com/php-http/discovery): ```bash composer require guzzlehttp/guzzle ``` `php-http/discovery` runs as a Composer plugin; allow it once in `composer.json`: ```json "config": { "allow-plugins": { "php-http/discovery": true } } ``` ## A taste ```php customerInvoices->list( filter: [Filter::gte('date', '2026-01-01')], sort: '-date', ) as $invoice) { echo $invoice->invoiceNumber . ' ' . $invoice->currencyAmount . PHP_EOL; } // Create and finalize an invoice $invoice = $client->customerInvoices->create( date: '2026-07-08', deadline: '2026-08-07', customerId: 123, invoiceLines: [['product_id' => 45, 'quantity' => '2']], ); $client->customerInvoices->finalize($invoice->id); $client->customerInvoices->sendByEmail($invoice->id); ``` Accounting firms can operate across their whole portfolio: ```php companies->list() as $company) { $balance = $firm->trialBalance->list( $company->id, periodStart: '2026-01-01', periodEnd: '2026-06-30', ); } ``` ## Where to go next - [Getting started](getting-started.md): tokens, first call, error handling basics. - [Guides](guides/authentication.md): authentication, pagination, error handling and retries. --- # Getting started From zero to your first Pennylane API calls in a few minutes. ## 1. Install the package ```bash composer require goatandcow7/pennylane-sdk ``` PHP 8.2 or newer is required. The SDK talks HTTP through [PSR-18](https://www.php-fig.org/psr/psr-18/); if no PSR-18 client and PSR-17 factories are already in your project, add Guzzle: ```bash composer require guzzlehttp/guzzle ``` and allow the discovery plugin once in `composer.json`: ```json "config": { "allow-plugins": { "php-http/discovery": true } } ``` ## 2. Get an API token The SDK talks to two different Pennylane APIs, each with its own token: **Company API** (you manage one company's accounting and invoicing): 1. Log into Pennylane with an admin account. 2. Go to **Settings > Connectivity > Developers**. 3. Create a token: give it a name, pick the permissions (read only, or read and write) and an expiration date. 4. Copy it immediately: Pennylane shows it only once. **Firm API** (you are an accounting firm working across client companies): generate a Firm token from your firm account settings. ## 3. Configure the token The recommended way is an environment variable, so the token never lands in your code: ```bash export PENNYLANE_API_TOKEN="your-token" # Company API export PENNYLANE_FIRM_API_TOKEN="your-token" # Firm API ``` ```php me->retrieve(); print_r($me); ``` If the token is valid you get your company profile back. If not, the SDK throws `Pennylane\Sdk\Exception\AuthenticationException`. List invoices: ```php foreach ($client->customerInvoices->list(limit: 20) as $invoice) { echo $invoice->invoiceNumber . ' ' . $invoice->currencyAmount . PHP_EOL; } ``` Create a product: ```php use Pennylane\Sdk\Exception\ValidationException; try { $product = $client->products->create( label: 'Consulting day', priceBeforeTax: '450.00', vatRate: 'FR_200', ); echo $product->id . PHP_EOL; } catch (ValidationException $e) { echo $e->getMessage() . PHP_EOL; } ``` Amounts are always passed as numeric strings (`'450.00'`), never as PHP floats: floats would lose decimal precision on accounting data, and the SDK rejects them. ## 5. Error handling basics Every exception the SDK throws derives from `Pennylane\Sdk\Exception\PennylaneException`, so catching it is enough to handle any SDK failure in one place: ```php use Pennylane\Sdk\Exception\PennylaneException; try { $client->customerInvoices->finalize($invoiceId); } catch (PennylaneException $e) { echo $e->getMessage() . PHP_EOL; } ``` For finer grained handling, catch the specific subclass instead (`AuthenticationException`, `PermissionDeniedException`, `ValidationException`, `RateLimitException`...). See the [errors and retries guide](guides/errors-and-retries.md) for the full hierarchy and the retry policy. ## 6. Explore the resources The client exposes one property per API resource, in camelCase. A few examples: | Property | What it manages | |---|---| | `$client->customerInvoices` | Sales invoices and credit notes: create, finalize, send, import PDFs and e-invoices | | `$client->quotes` | Quotes, and turning them into invoices | | `$client->customers` | Customers, with `->companies` (B2B) and `->individuals` (B2C) | | `$client->products` | Product catalog | | `$client->supplierInvoices` | Purchase invoices, imports, payment status | | `$client->transactions` | Bank transactions, matching with invoices | | `$client->ledgerEntries` | Accounting entries | | `$client->ledgerEntryLines` | Entry lines, lettering (lettrage) | | `$client->trialBalance` | Trial balance (balance générale) | | `$client->exports` | FEC, general ledger and analytical exports | | `$client->changelogs` | Change feeds to sync data incrementally | The full list is in the [design contract](design/resource-map.md). ## 7. Client options ```php $client = new Pennylane( apiToken: null, // default: PENNYLANE_API_TOKEN env var baseUrl: null, // override for proxies httpClient: null, // bring your own PSR-18 client requestFactory: null, // bring your own PSR-17 request factory streamFactory: null, // bring your own PSR-17 stream factory autoThrottle: true, // pace requests under the API rate limit maxRetries: 3, // automatic retries (429, transient network errors) timeout: 60.0, // seconds, needs Guzzle or an injected client ); ``` ## Where to go next - [Authentication](guides/authentication.md): token scopes, OAuth, good practices. - [Pagination and filtering](guides/pagination.md): cursors, filters, sorting. - [Errors, retries and rate limits](guides/errors-and-retries.md): exception hierarchy, retry policy, throttling. --- # Accounting operations Beyond invoicing, the API exposes the accounting core: journals, chart of accounts, entries, lettering, trial balance and legal exports. ## Chart of accounts and journals ```php ledgerAccounts->list() as $account) { echo $account->number . ' ' . $account->label . PHP_EOL; } $client->ledgerAccounts->create(number: '706100', label: 'Prestations de services'); // Journals foreach ($client->journals->list() as $journal) { echo $journal->code . ' ' . $journal->label . PHP_EOL; } ``` ## Ledger entries (écritures) An entry is a balanced set of debit and credit lines: ```php $entry = $client->ledgerEntries->create( date: '2026-07-08', label: 'Vente prestation', journalId: 12, ledgerEntryLines: [ ['ledger_account_id' => 411, 'debit' => '120.00', 'credit' => '0.00'], ['ledger_account_id' => 706, 'debit' => '0.00', 'credit' => '100.00'], ['ledger_account_id' => 445, 'debit' => '0.00', 'credit' => '20.00'], ], ); ``` !!! warning "Debit and credit are both required" Every ledger entry line must carry both a `debit` and a `credit` key, even though only one side is actually used: set the unused side to `"0.00"`. The lines must be balanced, meaning total debits equal total credits across the whole entry. If they do not, the API answers 422 and the SDK raises `ValidationException` with the totals in `$exception->details`. !!! warning "No idempotency on creations" Posting the same ledger entry twice creates a duplicate: the API does not deduplicate. The SDK never auto-retries a POST after a server error for this reason. Deduplicate on your side if your pipeline can replay requests. ## Lettering (lettrage) Lettering links entry lines that settle each other, typically an invoice line with its payment line: ```php $client->ledgerEntryLines->letter( ledgerEntryLineIds: [111, 222], unbalancedLetteringStrategy: 'none', ); $client->ledgerEntryLines->unletter( ledgerEntryLineIds: [111, 222], unbalancedLetteringStrategy: 'none', ); $client->ledgerEntryLines->listLetteredLines(111); ``` `unbalancedLetteringStrategy` is `"none"` to reject an unbalanced lettering with an error, or `"partial"` to allow it. ## Trial balance (balance générale) ```php foreach ($client->trialBalance->list( periodStart: '2026-01-01', periodEnd: '2026-06-30', ) as $row) { echo $row->number . ' ' . $row->debits . ' ' . $row->credits . PHP_EOL; } ``` !!! note "Period bounds are required" Unlike most list endpoints, `periodStart` and `periodEnd` are required arguments here, not optional filters: the trial balance always needs an explicit period to compute. ## Analytical categories Pennylane's analytics let you tag almost everything (invoices, transactions, entry lines) with categories organized in groups (axes): ```php $groups = $client->categoryGroups->list(); $client->categories->create(label: 'Agence Lyon', categoryGroupId: 3); $client->customerInvoices->categorize( $invoiceId, categories: [['id' => 42, 'weight' => '1.0']], ); ``` Ledger entry lines carry their own analytical categories: ```php $client->ledgerEntryLines->categorize( $ledgerEntryLineId, categories: [['id' => 42, 'weight' => '1.0']], ); ``` ## Legal exports: FEC, general ledger Exports run asynchronously: create the export, then poll until the file is ready. ```php $export = $client->exports->fecs->create(periodStart: '2026-01-01', periodEnd: '2026-12-31'); while (!in_array($export->status, ['ready', 'failed'], true)) { sleep(5); $export = $client->exports->fecs->get($export->id); } echo $export->fileUrl; // download link ``` Same pattern for `$client->exports->generalLedgers` and `$client->exports->analyticalGeneralLedgers`. ## Bank data ```php use Pennylane\Sdk\Filter; foreach ($client->bankAccounts->list() as $account) { // ... } foreach ($client->transactions->list(filter: [Filter::gte('date', '2026-01-01')]) as $tx) { // ... } // Reconcile: which invoices does this transaction settle? $client->transactions->listMatchedInvoices($tx->id); ``` --- # Authentication Pennylane supports three authentication modes. All of them end up as a `Bearer` token in the `Authorization` header, which the SDK sets for you. ## Company API token For integrations that manage a single company (the most common case). Created in **Settings > Connectivity > Developers** by a company admin. Tokens carry: - a set of **scopes** (permissions): read only, or read and write, per resource domain; - an **expiration date**: plan the renewal, an expired token throws `Pennylane\Sdk\Exception\AuthenticationException`. ```php use Pennylane\Sdk\Pennylane; $client = new Pennylane(); // PENNYLANE_API_TOKEN env var $client = new Pennylane(apiToken: 'tok...'); // explicit ``` ## Firm API token For accounting firms operating across their client portfolio. Generated from the firm account. Use the dedicated client: ```php use Pennylane\Sdk\PennylaneFirm; $firm = new PennylaneFirm(); // PENNYLANE_FIRM_API_TOKEN env var ``` The Firm and Company tokens are not interchangeable: they authenticate against different base URLs with different scopes. ## OAuth 2.0 (partner apps) If you build an app that other Pennylane companies install, use the authorization-code flow. The SDK ships `Pennylane\Sdk\OAuth\OAuthApp` helpers for the whole dance: `authorizationUrl()` to redirect the user, `exchangeCode()` to trade the returned code for a token pair, and `refresh()` to renew it. Once you hold an access token, use it like a company token: ```php $client = new Pennylane(apiToken: $tokens->accessToken); ``` !!! warning "Refresh token rotation" Pennylane rotates refresh tokens: every call to `refresh()` invalidates both previous tokens. Never run two refreshes concurrently, and persist the returned pair immediately. ## Scopes Each endpoint requires a scope, documented on the `Scope:` line of every SDK method docblock (for example `customer_invoices:all` to create invoices, `customer_invoices:readonly` to list them). When the token lacks the scope, the API answers 403 and the SDK throws `Pennylane\Sdk\Exception\PermissionDeniedException`: ```php use Pennylane\Sdk\Exception\PermissionDeniedException; try { $client->customerInvoices->create(/* ... */); } catch (PermissionDeniedException $e) { echo $e->getMessage(); // the API's own message, naming the missing scope echo $e->statusCode; // 403 } ``` Main v2 scopes: `customers`, `products`, `customer_invoices`, `quotes`, `customer_mandates`, `billing_subscriptions`, `commercial_documents`, `suppliers`, `supplier_invoices`, `purchase_requests`, `journals`, `ledger_accounts`, `ledger_entries`, `categories`, `transactions`, `bank_accounts`, `file_attachments` (each as `:readonly` or `:all`), plus `trial_balance:readonly`, `fiscal_years:readonly`, `bank_establishments:readonly` and `exports:fec`, `exports:agl`, `exports:gl`. ## HTTPS enforcement The SDK refuses to send a bearer token over a plain `http` connection: constructing a client with an insecure `baseUrl` throws `Pennylane\Sdk\Exception\ConfigurationException` before any request is made. The only exception is `http` on `localhost`, `127.0.0.1` or `::1`, for local development. ```php new Pennylane(baseUrl: 'http://example.com'); // throws ConfigurationException ``` ## Good practices - Never commit a token. Use environment variables or a secret manager. - Prefer read-only tokens for reporting integrations. - Give each integration its own token, so you can revoke one without breaking the others, and so the rate limit budget (which is per token) is not shared. --- # French e-invoicing (2026 reform) France mandates electronic invoicing for domestic B2B transactions: - **September 2026**: every company must be able to **receive** e-invoices; large companies and mid-caps must also **emit** them. - **September 2027**: emission becomes mandatory for SMEs and micro-companies. B2B invoices must transit through a **Plateforme Agréée (PA)**, a government-accredited platform, in a structured format: **Factur-X** (PDF with embedded XML), **UBL** or **CII**. Pennylane is itself an accredited PA, so Pennylane users need no third-party platform. The API exposes the whole flow, and this SDK covers all of it. ## Check your PA registrations ```php paRegistrations->list() as $registration) { echo $registration->siret . ' ' . $registration->status . PHP_EOL; } ``` ## Send an invoice through the PA network Finalize the invoice, then hand it to the platform: ```php $invoice = $client->customerInvoices->finalize($draft->id); $client->customerInvoices->sendToPa($invoice->id); ``` Track the delivery status either on the invoice's e-invoicing field (`$invoice->eInvoicing->status`), via the change feed, or with the webhook event `customer_invoice.e_invoicing_status_updated` (see the [webhooks guide](webhooks.md)). ## Receive supplier e-invoices Invoices arriving through the network land in Pennylane automatically. If you receive structured invoices through another channel, import them: ```php use Pennylane\Sdk\FileUpload; $client->supplierInvoices->importEInvoice(FileUpload::fromPath('invoice.xml')); ``` The Factur-X or XML data is parsed structurally (no OCR), so supplier, amounts and lines are reliable. ## Import sales e-invoices from another tool If you invoice from an external ERP but keep Pennylane as your accounting system and PA, push your sales invoices as e-invoices: ```php $client->customerInvoices->importEInvoice( FileUpload::fromPath('factur-x.pdf'), invoiceOptions: [ // pre-fills the customer and matches invoice lines by e_invoice_line_id // (the Factur-X BT-126 LineID), see the method docblock 'customer_id' => 123, ], ); ``` ## Update e-invoice status (advanced) Purchase-side workflows can update the lifecycle status of a received supplier e-invoice: ```php $client->supplierInvoices->updateEInvoiceStatus( $invoiceId, status: 'approved', ); // Disputing a line requires a reason code $client->supplierInvoices->updateEInvoiceStatus( $invoiceId, status: 'disputed', reason: 'incorrect_vat_rate', ); ``` `status` is one of `disputed`, `refused` or `approved`. `reason` is required with `disputed` (for example `incorrect_vat_rate`, `incorrect_unit_prices`, `incorrect_billed_quantity`, `defective_delivered_item`, `bank_details_error`) or with `refused` (for example `incorrect_vat_rate`, `contract_completed`, `duplicate_invoice`, `non_compliant_invoice`); it is omitted when `status` is `approved`. !!! tip "Deadlines are business-critical" If you integrate invoicing for September 2026, test the PA flow early in a sandbox. The SDK raises `ValidationException` with the API's details when a document does not meet the format requirements. --- # Errors, retries and rate limits ## Exception hierarchy Everything the SDK throws derives from `Pennylane\Sdk\Exception\PennylaneException`: ```text PennylaneException ├── ConfigurationException the SDK is not usable as configured (token, baseUrl, HTTP client) ├── InvalidRequestBodyException a request body or filter value is unsafe to send (e.g. a float) ├── InvalidResponseException a success status but the body is not usable JSON ├── ApiConnectionException the request never got an HTTP response │ └── ApiTimeoutException └── ApiStatusException the API answered 4xx/5xx ├── BadRequestException 400 malformed JSON, wrong types ├── AuthenticationException 401 missing/invalid/expired token ├── PermissionDeniedException 403 missing scope ├── NotFoundException 404 ├── ConflictException 409 e.g. duplicate document ├── ValidationException 422 business validation failed ├── RateLimitException 429 adds ->retryAfter └── ServerException 5xx ``` Every `ApiStatusException` carries: - `statusCode`: the HTTP status; - `errorCode`: the machine-readable code when the API sends one; - `getMessage()`: the best human-readable message available; - `details`: structured details when present (e.g. which totals do not balance); - `rawBody`: the raw response body, truncated to 2000 characters for safety; - `body`: the decoded JSON error body as an array, or `null` when the body is not a JSON object. ```php use Pennylane\Sdk\Exception\ValidationException; try { $client->ledgerEntries->create(/* ... */); } catch (ValidationException $e) { echo $e->getMessage(); // "Entry lines are not balanced" print_r($e->details); // ['debit_total' => '100.00', 'credit_total' => '80.00'] } ``` `ApiStatusException` deliberately does not keep a reference to the PSR-7 request or response, so it can never end up logging the `Authorization` header by accident. Log `statusCode`, `errorCode` and `getMessage()` instead. !!! info "Why every field is nullable" The API's error body format varies by endpoint (sometimes `{code, message, details}`, sometimes `{error, status}`, sometimes `{message, field}`). Every property on `ApiStatusException` is therefore optional and parsed defensively, with `getMessage()` falling back from the body's message to its error code to the raw response text, so you always get the most information available. ## Automatic retries The SDK retries transparently, with exponential backoff and jitter, up to `maxRetries` times (3 by default, starting at half a second and capped at 8 seconds between attempts). The policy is deliberately conservative because **the Pennylane API has no server-side idempotency**: re-sending a create request that the server may have already processed could produce a duplicate invoice or ledger entry. | Situation | GET / PUT / DELETE | POST | |---|---|---| | 429 rate limited | retried (honors `retry-after`) | retried (the request was rejected unprocessed, safe) | | 500 / 502 / 503 / 504 | retried | **not retried** (duplicate risk) | | Connection failed before sending | retried | retried | | Timeout / connection lost after sending | retried | **not retried** | The "connection failed before sending" row only applies when the transport failure is provably safe (DNS resolution, connection refused, TLS handshake failure and similar); any other transport failure is treated as possibly sent and is retried only for idempotent methods. If you need at-least-once semantics on creations, deduplicate on your side (for instance with `external_reference` fields) as Pennylane officially recommends. ## Rate limits | API | Limit | |---|---| | Company API v2 | 25 requests per 5 seconds, per token | | Firm API v1 | 5 requests per second | Two layers keep you safe: 1. **Client-side throttle** (`autoThrottle: true` by default): requests are paced so bursts, auto-pagination and mass imports stay under the limit. Disable with `new Pennylane(autoThrottle: false)` if you orchestrate limits yourself. 2. **429 handling**: if a 429 still happens (e.g. several processes sharing one token), the SDK waits for `retry-after` (capped at 60 seconds) and retries, for every HTTP method. The API reports the remaining budget on every response; the SDK exposes the latest values: ```php $client->me->retrieve(); $info = $client->lastRateLimit(); echo $info->limit . ' ' . $info->remaining . ' ' . $info->reset; // 25 24 1751980800 ``` `lastRateLimit()` returns `null` until the client has made at least one request, or if the API response carried no `ratelimit-*` headers. !!! tip "One token per integration" Limits apply per token. Give each integration its own token and they will not starve each other. --- # Firm API (accounting firms) Accounting firms (cabinets d'expertise comptable) get a dedicated API to work across their whole client portfolio: list the companies they manage, read and post accounting entries, upload documents, run exports, all per client company. ## Clients and token ```php companies->list() as $company) { echo $company->id . ' ' . $company->name . ' ' . $company->siren . PHP_EOL; } ``` Every other resource takes the company id as its first argument: ```php $companyId = 4217; // Fiscal years foreach ($firm->fiscalYears->list($companyId) as $fy) { echo $fy->start->format('Y-m-d') . ' ' . $fy->finish->format('Y-m-d') . PHP_EOL; } // Trial balance over a period foreach ($firm->trialBalance->list( $companyId, periodStart: '2026-01-01', periodEnd: '2026-06-30', ) as $row) { echo $row->number . ' ' . $row->debits . ' ' . $row->credits . PHP_EOL; } ``` !!! info "Three endpoints paginate by page number" Most list endpoints paginate with a cursor, but three Firm API endpoints instead paginate with `page` and `perPage`: `companies`, `fiscalYears` and `trialBalance`. They return a `NumberedPage` with `totalPages` and `currentPage` properties instead of the usual `CursorPage`. Iterating with `foreach` walks every page transparently either way. ## Post accounting entries ```php $firm->ledgerEntries->create( $companyId, date: '2026-07-08', label: 'OD de régularisation', journalId: 12, ledgerEntryLines: [ ['ledger_account_id' => 411, 'debit' => '120.00', 'credit' => '0.00'], ['ledger_account_id' => 706, 'debit' => '0.00', 'credit' => '120.00'], ], ); ``` As on the Company API, every ledger entry line needs both a `debit` and a `credit` key (`"0.00"` on the unused side), and the lines must be balanced. ## Documents (DMS) The firm-only DMS resource manages the document library of each client company: ```php use Pennylane\Sdk\FileUpload; $firm->dms->folders->list($companyId); $firm->dms->files->upload($companyId, FileUpload::fromPath('path/to/piece.pdf')); ``` ## Exports ```php $export = $firm->exports->fecs->create($companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31'); $export = $firm->exports->fecs->get($companyId, $export->id); // poll until status is "done" ``` ## Read-only beta invoicing endpoints The Firm API can read a client company's customer and supplier invoices, but this surface is in beta and read-only: `list()` and `get()` only, no creation or state changes. ```php foreach ($firm->customerInvoices->list($companyId) as $invoice) { // ... } $firm->supplierInvoices->get($companyId, $supplierInvoiceId); ``` Invoice creation and lifecycle actions (finalize, send, mark as paid, and so on) happen through each company's own Company API, with its own `PENNYLANE_API_TOKEN`. ## Mass synchronization Changelogs let you sync many client companies incrementally without re-reading everything: ```php foreach ($firm->companies->list() as $company) { foreach ($firm->changelogs->ledgerEntryLines($company->id) as $event) { // ... } } ``` !!! note "Company vs Firm scopes" The Firm API is read-write on accounting (entries, accounts, journals, banking) but read-only on invoicing (customer and supplier invoices, in beta). Invoice creation happens through each company's own Company API. --- # Invoicing lifecycle This guide walks the full life of a customer invoice: draft, finalize, send, get paid, reconcile. ## Create a draft ```php customerInvoices->create( date: '2026-07-08', deadline: '2026-08-07', customerId: 123, invoiceLines: [ // With a product: unit price, VAT and label come from the catalog ['product_id' => 45, 'quantity' => '2'], // Or a standard line [ 'label' => 'On-site setup', 'quantity' => '1', 'raw_currency_unit_price' => '500.00', 'unit' => 'day', 'vat_rate' => 'FR_200', ], ], ); echo $invoice->status; // draft ``` Drafts can be edited (`update()`) and deleted (`delete()`) freely. !!! info "Invoice line variants" An invoice line is either **product based** (only `product_id` and `quantity` are required, everything else is filled in from the product) or **standard** (`label`, `quantity`, `raw_currency_unit_price`, `unit` and `vat_rate` are all required). `unit` is mandatory on standard lines: there is no fallback for it. !!! info "Amounts are numeric strings" The API transports amounts as strings to avoid float rounding. Pass `"500.00"`, never a float. Response money fields (`currencyAmount`, `tax`, `debit`, `credit`, and so on) are typed as nullable strings for the same reason. !!! warning "No idempotency on creations" Posting the same invoice twice creates a duplicate: the API does not deduplicate. For this reason the SDK never automatically retries a POST after a server error or a lost connection. Never blind retry a create call yourself either. If your pipeline can replay requests, deduplicate on your side, for instance by tracking your own `externalReference` and checking for it before you retry. ## Finalize Finalizing gives the invoice its final legal number. There is no undo (accounting rules), only credit notes. ```php $invoice = $client->customerInvoices->finalize($invoice->id); echo $invoice->invoiceNumber; // e.g. F-2026-0042 ``` ## Send ```php $client->customerInvoices->sendByEmail($invoice->id); ``` Or through the French e-invoicing network for B2B, see the [e-invoicing guide](e-invoicing.md): ```php $client->customerInvoices->sendToPa($invoice->id); ``` ## From a quote ```php $quote = $client->quotes->create( date: '2026-07-08', deadline: '2026-08-07', customerId: 123, invoiceLines: [/* ... */], ); $client->quotes->sendByEmail($quote->id); // once accepted: $invoice = $client->customerInvoices->createFromQuote(quoteId: $quote->id, draft: true); ``` ## Get paid and reconcile ```php // Mark as paid manually $client->customerInvoices->markAsPaid($invoice->id); // Or match a real bank transaction against it $client->customerInvoices->matchTransaction($invoice->id, transactionId: 987); $client->customerInvoices->listMatchedTransactions($invoice->id); // Payments recorded on the invoice $client->customerInvoices->listPayments($invoice->id); ``` ## Credit notes A credit note (avoir) is a customer invoice with negative amounts. Link it to the invoice it corrects: ```php $creditNote = $client->customerInvoices->create( date: '2026-07-08', deadline: '2026-08-07', customerId: 123, invoiceLines: [['product_id' => 45, 'quantity' => '-2']], ); $client->customerInvoices->finalize($creditNote->id); $client->customerInvoices->linkCreditNote($invoice->id, creditNoteId: $creditNote->id); ``` ## Import existing invoices Invoices produced outside Pennylane (another tool, a marketplace) can be imported. For a plain PDF, upload the file first, then reference it: ```php use Pennylane\Sdk\FileUpload; // 1. Upload the PDF as a file attachment $attachment = $client->fileAttachments->create(FileUpload::fromPath('path/to/invoice.pdf')); // 2. Import the invoice referencing it $imported = $client->customerInvoices->importFromFile( fileAttachmentId: $attachment->id, date: '2026-07-01', deadline: '2026-07-31', customerId: 123, currencyAmountBeforeTax: '100.00', currencyAmount: '120.00', currencyTax: '20.00', invoiceLines: [/* ... */], ); // Structured e-invoices (Factur-X, UBL, CII) upload directly $imported = $client->customerInvoices->importEInvoice(FileUpload::fromPath('path/to/factur-x.pdf')); ``` ## Recurring invoices `$client->billingSubscriptions` creates subscriptions that generate invoices on a schedule (weekly, monthly, yearly, and so on). See the API reference for the recurrence options. ## Sync changes incrementally Instead of re-listing everything, poll the change feed: ```php foreach ($client->changelogs->customerInvoices() as $event) { // ... } ``` --- # OAuth 2.0 apps Building an app that other Pennylane companies install? Pennylane supports the standard authorization-code flow. Ask Pennylane for partner credentials (`clientId`, `clientSecret`) first. ## The flow ```php authorizationUrl( scopes: ['customer_invoices:all', 'customers:all'], state: $antiCsrfToken, ); // 2. Pennylane redirects back with ?code=...; exchange it $tokens = $app->exchangeCode($code); // 3. Use the access token like a regular API token use Pennylane\Sdk\Pennylane; $client = new Pennylane(apiToken: $tokens->accessToken); ``` ## Token lifetime and refresh Access tokens live **24 hours** (`$tokens->expiresIn === 86400`). Refresh before expiry: ```php $newTokens = $app->refresh($storedRefreshToken); save($newTokens->accessToken, $newTokens->refreshToken); // persist IMMEDIATELY ``` !!! danger "Refresh Token Rotation" Pennylane rotates refresh tokens: **every refresh invalidates both previous tokens**. Two rules follow: 1. Never run two refreshes concurrently for the same connection. `OAuthApp` does not serialize calls for you: if two requests race to refresh the same stored token, one of them will present an already-rotated refresh token and fail. Guard the refresh with your own lock (a database row lock, a distributed mutex, and so on), whether within one process or across several. 2. Persist the new pair before using it. If you crash after refreshing but before saving, the stored refresh token is dead and the user must re-authorize. When a refresh token is invalid (revoked, rotated away, expired), the token endpoint answers 401 `invalid_grant` and the SDK raises `AuthenticationException`: send the user through the authorization flow again. ## The 2026 scopes migration In January 2026, Pennylane replaced the legacy `ledger` scope with granular scopes and auto-added the new scopes to existing OAuth apps. If your app was created before that, re-authenticating your users once ensures their consents match the new scope model. --- # Pagination and filtering ## Cursor pagination (almost everywhere) List endpoints return a `CursorPage`. The simplest way to use it: iterate with `foreach`, and let the SDK fetch the following pages lazily, under the client throttle. ```php foreach ($client->customerInvoices->list(limit: 100) as $invoice) { // ... walks EVERY page transparently } ``` To control pages yourself: ```php $page = $client->customerInvoices->list(limit: 100); $page->items; // items of the current page only $page->hasMore; // whether a next page exists $page->nextCursor; // opaque cursor for the next page $nextPage = $page->nextPage(); // or null on the last page foreach ($page->iterPages() as $page) { // page by page echo count($page->items) . PHP_EOL; } ``` `limit` accepts 1 to 100 (the API default is 20). Fewer requests with `limit: 100` means faster bulk reads under the rate limit. !!! warning "Filters are not encoded in the cursor" When you paginate a filtered query, the API requires the same `filter` and `sort` on every page request. The SDK does this for you in `nextPage()` and during iteration. ## Page-number pagination (a few Firm endpoints) Three Firm API endpoints (`companies`, `fiscalYears`, `trialBalance`) paginate with `page` and `perPage` instead of a cursor. Same experience: ```php foreach ($firm->companies->list(perPage: 50) as $company) { // ... } ``` These return a `NumberedPage` with `totalPages` and `currentPage` properties, and a `hasMore()` method. ## Filtering The `filter` parameter is a list of conditions. Build it with the typed static helpers on `Filter`: ```php use Pennylane\Sdk\Filter; $client->customerInvoices->list(filter: [ Filter::gte('date', '2026-01-01'), Filter::lt('date', '2026-07-01'), Filter::eq('status', 'upcoming'), Filter::in('customer_id', [42, 43]), ]); ``` Available helpers: `eq`, `notEq`, `lt`, `lte`, `gt`, `gte`, `in`, `notIn`, and `where(field, operator, value)` for anything else. Values can be strings, ints, booleans, `\DateTimeInterface` or `null`; amounts must be numeric strings, never floats. !!! note "Wire operators" `lte()` and `gte()` are convenience names: on the wire the API expects `lteq` and `gteq`. The SDK encodes them for you, so you never write the wire operator yourself. Which fields and operators each endpoint supports is listed in the [official reference](https://pennylane.readme.io/reference) per endpoint. You can also pass a raw JSON string, or a plain array shaped like `['field' => ..., 'operator' => ..., 'value' => ...]`, if you already have one; the SDK sends it as-is. ## Sorting ```php $client->customerInvoices->list(sort: '-date'); // descending by date $client->customerInvoices->list(sort: 'id'); // ascending by id ``` Since the 2026 API changes, the default sort is `-id` (newest first) on all endpoints. ## Side-loading with include Some list endpoints support an experimental `include` parameter to embed related records in one call: ```php $page = $client->customerInvoices->list(include: 'invoice_lines'); $page->included; // raw list of side-loaded records ``` --- # Webhooks !!! warning "Beta" Pennylane webhooks are in beta. Pennylane itself recommends keeping the [changelog endpoints](#fallback-changelogs) as a fallback while the feature stabilizes. Known event types so far: `customer_invoice.e_invoicing_status_updated` and `dms_file.created`. ## Subscribe ```php webhookSubscriptions->create( callbackUrl: 'https://example.com/webhooks/pennylane', // must be HTTPS events: ['customer_invoice.e_invoicing_status_updated'], ); echo $subscription->secret; ``` !!! danger "Store the secret now" The `secret` used to verify deliveries is returned **only once**, at creation. Store it in your secret manager immediately; you cannot fetch it again. Manage subscriptions with `list()`, `get($id)`, `update($id, ...)` and `delete($id)`. ## Receive and verify deliveries Pennylane signs deliveries with an HMAC of the raw body using your secret. Verify before trusting: ```php event === 'customer_invoice.e_invoicing_status_updated') { // ... } http_response_code(200); echo json_encode(['ok' => true]); ``` !!! note "Signature header" As of mid-2026 Pennylane does not document the exact signature header name or encoding. `Webhooks::verifySignature()` is deliberately tolerant: it accepts hex and base64 HMAC-SHA256 encodings, with or without a `sha256=` or `hmac-sha256=` prefix, compared in constant time. Inspect your first real deliveries to confirm the header (commonly `X-Pennylane-Signature`) and pin your integration accordingly. You can also verify manually: ```php $isValid = Webhooks::verifySignature($rawBody, $signatureHeader, $secret); ``` ## Fallback: changelogs For guaranteed-delivery synchronization, poll the changelog endpoints. They return change events per resource, cursor-paginated, so an incremental sync loop is trivial: ```php foreach ($client->changelogs->customerInvoices() as $event) { // each event references the changed invoice } ``` Changelogs exist for customer invoices, supplier invoices, customers, suppliers, products, ledger entry lines, transactions and quotes. --- # Clients The SDK has two entry points. Both are final classes with readonly resource properties, so every resource is available right after construction and cannot be reassigned. ## Pennylane `Pennylane\Sdk\Pennylane` talks to the Company API v2, scoped to a single company. Base URL: `https://app.pennylane.com/api/external/v2`. Requests are throttled to 25 requests per 5 seconds. ```php $client = new Pennylane\Sdk\Pennylane(); ``` ### Constructor options | Option | Type | Default | Description | |---|---|---|---| | `apiToken` | `?string` | `null` | The bearer token. Falls back to the `PENNYLANE_API_TOKEN` environment variable (checked in `$_SERVER`, `$_ENV`, then `getenv()`). Throws `ConfigurationException` when neither is set. | | `baseUrl` | `?string` | `null` (uses `Pennylane::DEFAULT_BASE_URL`) | Must be https, except `http://localhost`, `http://127.0.0.1` or `http://[::1]` for local development. Any other scheme throws `ConfigurationException` so the token is never sent in cleartext. | | `httpClient` | `?ClientInterface` | `null` | Injected PSR-18 HTTP client. When omitted, the SDK uses Guzzle if installed (with redirects disabled and `http_errors` off), otherwise discovers any PSR-18 client via `php-http/discovery`. Disable automatic redirects on any client you inject yourself, since a redirect could leak the Authorization header. | | `requestFactory` | `?RequestFactoryInterface` | `null` | Injected PSR-17 request factory. Discovered automatically when omitted. | | `streamFactory` | `?StreamFactoryInterface` | `null` | Injected PSR-17 stream factory. Discovered automatically when omitted. | | `autoThrottle` | `bool` | `true` | Keeps outgoing requests under the API rate limit on the client side, so a loop over thousands of records never trips a 429. Only paces the current process. | | `maxRetries` | `int` | `3` | Retry budget for retries that are safe, meaning GET requests and connection level failures. The API has no idempotency mechanism, so a POST is never retried after a server error. | | `timeout` | `?float` | `null` | Request timeout in seconds. Only has an effect on the SDK's default Guzzle client; passing it together with an injected `httpClient` throws `ConfigurationException`, since the SDK cannot enforce a timeout on a client it does not own. | ### Resource properties | Property | Class | Endpoint prefix | |---|---|---| | `customers` (with `->companies`, `->individuals`) | `Company\Customers`, `Company\CompanyCustomers`, `Company\IndividualCustomers` | `/customers`, `/company_customers`, `/individual_customers` | | `customerInvoices` | `Company\CustomerInvoices` | `/customer_invoices` | | `customerInvoiceTemplates` | `Company\CustomerInvoiceTemplates` | `/customer_invoice_templates` | | `quotes` | `Company\Quotes` | `/quotes` | | `commercialDocuments` | `Company\CommercialDocuments` | `/commercial_documents` | | `products` | `Company\Products` | `/products` | | `billingSubscriptions` | `Company\BillingSubscriptions` | `/billing_subscriptions` | | `suppliers` | `Company\Suppliers` | `/suppliers` | | `supplierInvoices` | `Company\SupplierInvoices` | `/supplier_invoices` | | `purchaseRequests` | `Company\PurchaseRequests` | `/purchase_requests` | | `transactions` | `Company\Transactions` | `/transactions` | | `bankAccounts` | `Company\BankAccounts` | `/bank_accounts` | | `bankEstablishments` | `Company\BankEstablishments` | `/bank_establishments` | | `journals` | `Company\Journals` | `/journals` | | `ledgerAccounts` | `Company\LedgerAccounts` | `/ledger_accounts` | | `ledgerEntries` | `Company\LedgerEntries` | `/ledger_entries` | | `ledgerAttachments` | `Company\LedgerAttachments` | `/ledger_attachments` (deprecated) | | `ledgerEntryLines` | `Company\LedgerEntryLines` | `/ledger_entry_lines` | | `trialBalance` | `Company\TrialBalance` | `/trial_balance` | | `fiscalYears` | `Company\FiscalYears` | `/fiscal_years` | | `categories` | `Company\Categories` | `/categories` | | `categoryGroups` | `Company\CategoryGroups` | `/category_groups` | | `exports` (with `->fecs`, `->generalLedgers`, `->analyticalGeneralLedgers`) | `Company\Exports`, `Company\FecExports`, `Company\GeneralLedgerExports`, `Company\AnalyticalGeneralLedgerExports` | `/exports/*` | | `sepaMandates` | `Company\SepaMandates` | `/sepa_mandates` | | `gocardlessMandates` | `Company\GocardlessMandates` | `/gocardless_mandates` | | `proAccount` | `Company\ProAccount` | `/pro_account/*` | | `eInvoices` | `Company\EInvoices` | `/e-invoices/imports` (deprecated beta) | | `paRegistrations` | `Company\PaRegistrations` | `/pa_registrations` | | `fileAttachments` | `Company\FileAttachments` | `/file_attachments` | | `changelogs` | `Company\Changelogs` | `/changelogs/{resource}` | | `webhookSubscriptions` | `Company\WebhookSubscriptions` | `/webhook_subscriptions` (beta) | | `me` | `Company\Me` | `/me` | ## PennylaneFirm `Pennylane\Sdk\PennylaneFirm` talks to the Firm API v1, for accounting firms managing several client companies. Base URL: `https://app.pennylane.com/api/external/firm/v1`. Requests are throttled to 5 requests per second. ```php $firm = new Pennylane\Sdk\PennylaneFirm(); ``` Every resource method except `companies` takes the client company id as its first argument: `$firm->ledgerEntries->list($companyId)`. ### Constructor options `PennylaneFirm` accepts the exact same options as `Pennylane`, with one difference: `apiToken` falls back to the `PENNYLANE_FIRM_API_TOKEN` environment variable instead of `PENNYLANE_API_TOKEN`. | Option | Type | Default | Description | |---|---|---|---| | `apiToken` | `?string` | `null` | Falls back to `PENNYLANE_FIRM_API_TOKEN`. Throws `ConfigurationException` when neither is set. | | `baseUrl` | `?string` | `null` (uses `PennylaneFirm::DEFAULT_BASE_URL`) | Same https rule as `Pennylane`. | | `httpClient` | `?ClientInterface` | `null` | Same as `Pennylane`. | | `requestFactory` | `?RequestFactoryInterface` | `null` | Same as `Pennylane`. | | `streamFactory` | `?StreamFactoryInterface` | `null` | Same as `Pennylane`. | | `autoThrottle` | `bool` | `true` | Paces requests to the Firm API limit (5 req/s) instead of the Company limit. | | `maxRetries` | `int` | `3` | Same retry policy as `Pennylane`. | | `timeout` | `?float` | `null` | Same rule as `Pennylane`: incompatible with an injected `httpClient`. | ### Resource properties | Property | Class | Endpoint prefix | |---|---|---| | `companies` | `Firm\FirmCompanies` | `/companies` | | `fiscalYears` | `Firm\FirmFiscalYears` | `/companies/{company_id}/fiscal_years` | | `trialBalance` | `Firm\FirmTrialBalance` | `/companies/{company_id}/trial_balance` | | `journals` | `Firm\FirmJournals` | `/companies/{company_id}/journals` | | `ledgerAccounts` | `Firm\FirmLedgerAccounts` | `/companies/{company_id}/ledger_accounts` | | `ledgerEntries` | `Firm\FirmLedgerEntries` | `/companies/{company_id}/ledger_entries` | | `ledgerEntryLines` | `Firm\FirmLedgerEntryLines` | `/companies/{company_id}/ledger_entry_lines` | | `exports` (with `->fecs`, `->analyticalGeneralLedgers`) | `Firm\FirmExports`, `Firm\FirmFecExports`, `Firm\FirmAnalyticalGeneralLedgerExports` | `/companies/{company_id}/exports/*` | | `dms` (with `->files`, `->folders`) | `Firm\FirmDms`, `Firm\FirmDmsFiles`, `Firm\FirmDmsFolders` | `/companies/{company_id}/dms/*` | | `fileAttachments` | `Firm\FirmFileAttachments` | `/file_attachments` | | `customerInvoices` | `Firm\FirmCustomerInvoices` | `/companies/{company_id}/customer_invoices` | | `supplierInvoices` | `Firm\FirmSupplierInvoices` | `/companies/{company_id}/supplier_invoices` | | `customers` | `Firm\FirmCustomers` | `/companies/{company_id}/customers` (read only, beta) | | `suppliers` | `Firm\FirmSuppliers` | `/companies/{company_id}/suppliers` (read only, beta) | | `bankAccounts` | `Firm\FirmBankAccounts` | `/companies/{company_id}/bank_accounts` | | `transactions` | `Firm\FirmTransactions` | `/companies/{company_id}/transactions` | | `categories` | `Firm\FirmCategories` | `/companies/{company_id}/categories` | | `categoryGroups` | `Firm\FirmCategoryGroups` | `/companies/{company_id}/category_groups` | | `changelogs` | `Firm\FirmChangelogs` | `/companies/{company_id}/changelogs/*` | ## lastRateLimit() Both clients expose the same method: ```php public function lastRateLimit(): ?RateLimitInfo ``` Returns the rate limit state read from the `ratelimit-*` response headers of the most recent API call, or `null` if no such header was present (for example, before any request has been made). This is purely informational: the SDK throttles proactively on the client side regardless of what this method returns. `RateLimitInfo` (`Pennylane\Sdk\RateLimitInfo`) exposes three nullable properties: | Property | Type | Description | |---|---|---| | `limit` | `?int` | Request budget of the current window (`ratelimit-limit` header). | | `remaining` | `?int` | Requests left in the current window (`ratelimit-remaining` header). | | `reset` | `?int` | Unix timestamp at which the window resets (`ratelimit-reset` header). | ```php $client->customerInvoices->list(); $rateLimit = $client->lastRateLimit(); if ($rateLimit !== null && $rateLimit->remaining !== null) { echo "Requests left in the current window: {$rateLimit->remaining}\n"; } ``` --- # Accounting These resources cover the company's general ledger: journals, the chart of accounts, ledger entries and their lines, the trial balance, and fiscal years. ## Journals (`$client->journals`) Accounting journals used to classify ledger entries. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /journals` | `journals:readonly` | List journals. | | `create($code, $label)` | `POST /journals` | `journals:all` | Create a journal. | | `get($journalId)` | `GET /journals/{journalId}` | `journals:readonly` | Retrieve a journal by its identifier. | ## LedgerAccounts (`$client->ledgerAccounts`) The chart of accounts. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /ledger_accounts` | `ledger_accounts:readonly` | List ledger accounts. | | `create($number, $label, $vatRate = null, $countryAlpha2 = null)` | `POST /ledger_accounts` | `ledger_accounts:all` | Create a ledger account. If the number starts with 401 or 411, the API may also create a matching auxiliary account. | | `get($ledgerAccountId)` | `GET /ledger_accounts/{ledgerAccountId}` | `ledger_accounts:readonly` | Retrieve a ledger account by its identifier. | | `update($ledgerAccountId, $label = null, $letterable = null)` | `PUT /ledger_accounts/{ledgerAccountId}` | `ledger_accounts:all` | Update a ledger account. | ## LedgerAttachments (`$client->ledgerAttachments`) Deprecated resource. `/ledger_attachments` is marked deprecated by the API; upload the file through `$client->fileAttachments` instead and pass its id as `file_attachment_id` when creating or updating a ledger entry. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($file, $filename = null)` | `POST /ledger_attachments` | `ledger` | Upload a file as a ledger attachment (deprecated). | ## LedgerEntries (`$client->ledgerEntries`) Ledger entries: the accounting entries made of balanced debit and credit lines. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /ledger_entries` | `ledger_entries:readonly` | List ledger entries. | | `create($date, $label, $journalId, $ledgerEntryLines, $dueDate = null, $fileAttachmentId = null, $currency = null, $pieceNumber = null, $ledgerAttachmentId = null)` | `POST /ledger_entries` | `ledger_entries:all` | Create a ledger entry. Every line needs both a debit and a credit key ("0.00" on the unused side), and the lines must balance. | | `get($ledgerEntryId)` | `GET /ledger_entries/{ledgerEntryId}` | `ledger_entries:readonly` | Retrieve a ledger entry by its identifier. | | `update($ledgerEntryId, $date = null, $label = null, $journalId = null, $ledgerEntryLines = null, $fileAttachmentId = null, $currency = null, $pieceNumber = null, $ledgerAttachmentId = null)` | `PUT /ledger_entries/{ledgerEntryId}` | `ledger_entries:all` | Update a ledger entry, including creating, updating or deleting individual entry lines. | | `listLedgerEntryLines($ledgerEntryId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /ledger_entries/{ledgerEntryId}/ledger_entry_lines` | `ledger_entries:readonly` | List the entry lines of a ledger entry. | Note: `fileAttachmentId` replaces the deprecated `ledgerAttachmentId` parameter on both `create()` and `update()`. ## LedgerEntryLines (`$client->ledgerEntryLines`) Individual debit or credit lines, with lettering and analytical categorization. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /ledger_entry_lines` | `ledger_entries:readonly` | List ledger entry lines. | | `get($ledgerEntryLineId)` | `GET /ledger_entry_lines/{ledgerEntryLineId}` | `ledger_entries:readonly` | Retrieve a ledger entry line by its identifier. | | `letter($ledgerEntryLineIds, $unbalancedLetteringStrategy)` | `POST /ledger_entry_lines/lettering` | `ledger_entries:all` | Letter ledger entry lines together; returns the ids of every line in the resulting lettering. | | `unletter($ledgerEntryLineIds, $unbalancedLetteringStrategy)` | `DELETE /ledger_entry_lines/lettering` | `ledger_entries:all` | Unletter ledger entry lines. | | `listLetteredLines($ledgerEntryLineId, $cursor = null, $limit = null, $sort = null)` | `GET /ledger_entry_lines/{ledgerEntryLineId}/lettered_ledger_entry_lines` | `ledger_entries:readonly` | List the ledger entry lines lettered together with a given entry line. | | `listCategories($ledgerEntryLineId, $cursor = null, $limit = null, $sort = null)` | `GET /ledger_entry_lines/{ledgerEntryLineId}/categories` | `ledger_entries:readonly` | List the analytical categories linked to a ledger entry line. | | `categorize($ledgerEntryLineId, $categories)` | `PUT /ledger_entry_lines/{ledgerEntryLineId}/categories` | `ledger_entries:all` | Link analytical categories to a ledger entry line, each with a weight between 0 and 1. | Note: `unbalancedLetteringStrategy` accepts `"none"` (reject an unbalanced result with an error) or `"partial"` (allow it). `unletter()` sends a JSON body on a DELETE request, which is unusual but required by the API. ## TrialBalance (`$client->trialBalance`) The trial balance for a given period. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($periodStart, $periodEnd, $isAuxiliary = null, $cursor = null, $limit = null)` | `GET /trial_balance` | `trial_balance:readonly` | Get the trial balance for a period. | Note: `list()` returns a `CursorPage`, so it paginates like the other list methods. `TrialBalanceItem` is the one DTO in the SDK without an `id` property, since the API does not return an identifier for these rows; use `number` or `formattedNumber` instead. ## FiscalYears (`$client->fiscalYears`) The company's fiscal years. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $sort = null)` | `GET /fiscal_years` | `fiscal_years:readonly` | List the company's fiscal years. | ## Example ```php journals->create('OD', 'Miscellaneous operations'); $entry = $client->ledgerEntries->create( date: '2026-07-01', label: 'Office supplies', journalId: $journal->id, ledgerEntryLines: [ ['ledger_account_id' => 606100, 'debit' => '120.00', 'credit' => '0.00'], ['ledger_account_id' => 512000, 'debit' => '0.00', 'credit' => '120.00'], ], ); foreach ($client->trialBalance->list('2026-01-01', '2026-06-30') as $item) { echo "{$item->number} {$item->label}: debit {$item->debits}, credit {$item->credits}\n"; } ``` --- # Analytics (categories) These resources cover the categories used to tag treasury and analytical records, and the groups that organize them. ## Categories (`$client->categories`) Categories used to tag treasury and analytical records. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /categories` | `categories:readonly` | List categories. | | `create($label, $categoryGroupId, $direction = null, $analyticalCode = null)` | `POST /categories` | `categories:all` | Create a category. `direction` only applies to treasury categories ("cash_in" or "cash_out", defaults to "cash_out"). | | `get($categoryId)` | `GET /categories/{categoryId}` | `categories:readonly` | Retrieve a category by its identifier. | | `update($categoryId, $label = null, $direction = null, $analyticalCode = null)` | `PUT /categories/{categoryId}` | `categories:all` | Update a category. | ## CategoryGroups (`$client->categoryGroups`) Groups that organize categories. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null)` | `GET /category_groups` | `categories:readonly` | List category groups. | | `get($categoryGroupId)` | `GET /category_groups/{categoryGroupId}` | `categories:readonly` | Retrieve a category group by its identifier. | | `listCategories($categoryGroupId, $cursor = null, $limit = null)` | `GET /category_groups/{categoryGroupId}/categories` | `categories:readonly` | List the categories that belong to a category group. | ## Example ```php categoryGroups->get(42); $category = $client->categories->create( label: 'Travel expenses', categoryGroupId: $group->id, direction: 'cash_out', ); foreach ($client->categoryGroups->listCategories($group->id) as $groupCategory) { echo "{$groupCategory->id}: {$groupCategory->label}\n"; } ``` --- # Banking This page covers the treasury side of the Company API: bank accounts, the reference list of bank establishments used when creating one, and bank transactions along with their categorization and reconciliation with invoices. ## BankAccounts (`$client->bankAccounts`) Manages the company's bank accounts. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $sort = null)` | `GET /bank_accounts` | `bank_accounts:all or bank_accounts:readonly` | List bank accounts. | | `create($name, $bankEstablishmentId = null, $iban = null, ...)` | `POST /bank_accounts` | `bank_accounts:all` | Create a bank account. | | `get($bankAccountId)` | `GET /bank_accounts/{bankAccountId}` | `bank_accounts:all or bank_accounts:readonly` | Retrieve a bank account by its id. | Note: `create()` falls back to the "Other" bank establishment when `bankEstablishmentId` is omitted. ## BankEstablishments (`$client->bankEstablishments`) Read only lookup of bank establishments (banks) to reference when creating a bank account. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /bank_establishments` | `bank_establishments:readonly` | List bank establishments. | ## Transactions (`$client->transactions`) Manages bank transactions: creation, categorization, and matching with customer or supplier invoices. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /transactions` | `transactions:readonly or transactions:all` | List transactions. | | `create($bankAccountId, $label, $date, $amount, $fee = null)` | `POST /transactions` | `transactions:all` | Create a transaction. | | `get($transactionId)` | `GET /transactions/{transactionId}` | `transactions:readonly or transactions:all` | Retrieve a transaction by its id. | | `update($transactionId, $customerId = null, $supplierId = null)` | `PUT /transactions/{transactionId}` | `transactions:all` | Update a transaction, linking it to a customer or a supplier. | | `listCategories($transactionId, $cursor = null, $limit = null)` | `GET /transactions/{transactionId}/categories` | `transactions:readonly or transactions:all` | List the categories assigned to a bank transaction. | | `categorize($transactionId, $categories)` | `PUT /transactions/{transactionId}/categories` | `transactions:all` | Replace the categories assigned to a bank transaction. | | `listMatchedInvoices($transactionId, $cursor = null, $limit = null)` | `GET /transactions/{transactionId}/matched_invoices` | `transactions:readonly or transactions:all` | List the customer or supplier invoices matched to a bank transaction. | Note: `update()` accepts either `customerId` or `supplierId`, never both, to link the transaction; pass `null` to leave a side unset. ## Example ```php bankEstablishments->list(filter: Filter::eq('name', 'Societe Generale')) as $establishment) { $bankEstablishment = $establishment; break; } $account = $client->bankAccounts->create( name: 'Main checking account', bankEstablishmentId: $bankEstablishment?->id, iban: 'FR7630006000011234567890189', ); foreach ($client->transactions->list(filter: Filter::eq('bank_account_id', $account->id)) as $transaction) { echo "Transaction #{$transaction->id}\n"; } ``` --- # E-invoicing France's e-invoicing reform routes invoices through registered platforms (PDP/PA). This page covers the legacy generic e-invoice import endpoint and read access to PA (Plateforme Agreee) registrations. ## EInvoices (`$client->eInvoices`) Import a legacy e-invoice: a Factur-X PDF, a UBL XML invoice, or a CII XML invoice. **Deprecated and beta.** The class docblock marks this endpoint both deprecated ("superseded by the per resource e_invoices/imports endpoints", meaning the `importEInvoice` methods on `customerInvoices` and `supplierInvoices`) and beta ("subject to change"). Its path is also the only kebab-case path in the whole API: `/e-invoices/imports`, versus the snake_case used everywhere else (`/customer_invoices`, `/pa_registrations`, and so on). New integrations should prefer the per-resource import methods instead of this endpoint. | Method | HTTP | Scope | Description | |---|---|---|---| | `importEInvoice($file, $type)` | `POST /e-invoices/imports` | `e_invoices:all` | Import a Factur-X PDF, a UBL XML invoice or a CII XML invoice. `type` is one of `customer`, `supplier`. (deprecated, beta) | ## PaRegistrations (`$client->paRegistrations`) Read PA (Plateforme Agreee) registrations. | Method | HTTP | Scope | Description | |---|---|---|---| | `list()` | `GET /pa_registrations` | `pa_registrations:readonly` | List PA registrations. | ## Example ```php paRegistrations->list() as $registration) { echo "PA registration {$registration->id}\n"; } // Legacy import path, kept for existing integrations only. $ref = $client->eInvoices->importEInvoice( FileUpload::fromPath('/path/to/invoice.pdf'), 'supplier', ); echo "Imported as {$ref->id}\n"; ``` --- # Exports Accounting exports are asynchronous jobs: you create a job for a period, then poll it until its download URL is ready. This page covers the three export kinds available on the Company API: FEC, general ledger and analytical general ledger. ## Exports (`$client->exports`) This class exposes no endpoint of its own; it wires the fecs, generalLedgers and analyticalGeneralLedgers sub resources below. ## FecExports (`$client->exports->fecs`) Export the FEC (Fichier des Ecritures Comptables) for a given period. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($periodStart, $periodEnd)` | `POST /exports/fecs` | `exports:fec` | Create a FEC export job for a period. | | `get($exportId)` | `GET /exports/fecs/{exportId}` | `exports:fec` | Retrieve a FEC export, including its download URL once ready. | ## GeneralLedgerExports (`$client->exports->generalLedgers`) Export the general ledger for a given period. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($periodStart, $periodEnd)` | `POST /exports/general_ledgers` | `exports:gl` | Create a general ledger export job for a period. | | `get($exportId)` | `GET /exports/general_ledgers/{exportId}` | `exports:gl` | Retrieve a general ledger export, including its download URL once ready. | ## AnalyticalGeneralLedgerExports (`$client->exports->analyticalGeneralLedgers`) Export the analytical general ledger for a given period, either in line or in column mode. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($periodStart, $periodEnd, $mode = null)` | `POST /exports/analytical_general_ledgers` | `exports:agl` | Create an analytical general ledger export job for a period. `mode` is `in_line` or `in_column` (API default `in_line`). | | `get($exportId)` | `GET /exports/analytical_general_ledgers/{exportId}` | `exports:agl` | Retrieve an analytical general ledger export, including its download URL once ready. | All three `get()` methods return the same `Export` DTO: `id`, `status`, `fileUrl`, `createdAt`, `updatedAt`. The export is generated in the background, so `fileUrl` is `null` until `status` becomes `ready`. The download URL expires 30 minutes after it is issued. ## Example ```php exports->fecs->create('2026-01-01', '2026-06-30'); // The file is built asynchronously: poll until it leaves the pending state. // Status is one of pending, ready, error. while ($export->status === 'pending') { sleep(5); $export = $client->exports->fecs->get($export->id); } if ($export->status === 'ready') { echo "FEC ready: {$export->fileUrl}\n"; } else { echo "FEC export failed\n"; } $glExport = $client->exports->generalLedgers->create('2026-01-01', '2026-06-30'); $aglExport = $client->exports->analyticalGeneralLedgers->create('2026-01-01', '2026-06-30', 'in_column'); ``` --- # Invoicing (sales) This page covers the customer-facing side of the Company API: customers, quotes, customer invoices and credit notes, recurring billing subscriptions, read only commercial documents, invoice templates, and the product catalog used to fill invoice and quote lines. ## Customers (`$client->customers`) Reads customers regardless of kind (company or individual) and manages their categories and contacts. This resource wires two sub resources, `->companies` and `->individuals`, which you use to create or update a customer of a specific kind. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /customers` | `customers:all, customers:readonly` | List customers, company and individual alike. | | `get($customerId)` | `GET /customers/{customerId}` | `customers:all, customers:readonly` | Retrieve a customer by its identifier, company or individual alike. | | `listCategories($customerId, $cursor = null, $limit = null)` | `GET /customers/{customerId}/categories` | `customers:readonly, customers:all` | List the categories assigned to a customer. | | `categorize($customerId, $categories)` | `PUT /customers/{customerId}/categories` | `customers:all` | Replace the categories assigned to a customer with the given list. | | `listContacts($customerId, $cursor = null, $limit = null, $sort = null)` | `GET /customers/{customerId}/contacts` | `customers:all, customers:readonly` | List the contacts of a customer. | ## CompanyCustomers (`$client->customers->companies`) Creates and manages customers that are companies (as opposed to individuals). | Method | HTTP | Scope | Description | |---|---|---|---| | `create($name, $billingAddress, $vatNumber = null, ...)` | `POST /company_customers` | `customers:all` | Create a company customer. | | `get($companyCustomerId)` | `GET /company_customers/{companyCustomerId}` | `customers:all, customers:readonly` | Retrieve a company customer by its identifier. | | `update($companyCustomerId, $name = null, ...)` | `PUT /company_customers/{companyCustomerId}` | `customers:all` | Update a company customer. | ## IndividualCustomers (`$client->customers->individuals`) Creates and manages customers that are individuals (as opposed to companies). | Method | HTTP | Scope | Description | |---|---|---|---| | `create($firstName, $lastName, $billingAddress, $phone = null, ...)` | `POST /individual_customers` | `customers:all` | Create an individual customer. | | `get($individualCustomerId)` | `GET /individual_customers/{individualCustomerId}` | `customers:all, customers:readonly` | Retrieve an individual customer by its identifier. | | `update($individualCustomerId, $firstName = null, ...)` | `PUT /individual_customers/{individualCustomerId}` | `customers:all` | Update an individual customer. | ## CustomerInvoices (`$client->customerInvoices`) Manages customer invoices end to end: draft and finalized invoices, credit notes, e-invoicing imports, categorization, appendices, and reconciliation with bank transactions and payments. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /customer_invoices` | `customer_invoices:all or customer_invoices:readonly` | List customer invoices. | | `create($date, $deadline, $customerId, $invoiceLines, $draft = true, ...)` | `POST /customer_invoices` | `customer_invoices:all` | Create a customer invoice, as a draft or finalized directly. | | `get($customerInvoiceId)` | `GET /customer_invoices/{customerInvoiceId}` | `customer_invoices:all or customer_invoices:readonly` | Retrieve a customer invoice by its identifier. | | `update($customerInvoiceId, $date = null, ...)` | `PUT /customer_invoices/{customerInvoiceId}` | `customer_invoices:all` | Update a customer invoice (draft invoices accept more fields than finalized ones). | | `delete($customerInvoiceId)` | `DELETE /customer_invoices/{customerInvoiceId}` | `customer_invoices:all` | Delete a draft customer invoice (finalized invoices cannot be deleted). | | `importFromFile($fileAttachmentId, $date, $deadline, $customerId, $currencyAmountBeforeTax, $currencyAmount, $currencyTax, $invoiceLines, $importAsIncomplete = null, ...)` | `POST /customer_invoices/import` | `customer_invoices:all` | Import a customer invoice from a file already uploaded through file attachments. | | `importEInvoice($file, $invoiceOptions = null)` | `POST /customer_invoices/e_invoices/imports` | `customer_invoices:all` | Import a Factur-X, UBL or CII e-invoice file; the invoice is built asynchronously. | | `createFromQuote($quoteId, $draft, $externalReference = null, $customerInvoiceTemplateId = null)` | `POST /customer_invoices/create_from_quote` | `customer_invoices:all` | Create a customer invoice from an existing quote. | | `sendToPa($customerInvoiceId)` | `POST /customer_invoices/{customerInvoiceId}/send_to_pa` | `customer_invoices:all` | Send a customer e-invoice to the Partner Agreed (PA) platform. | | `finalize($customerInvoiceId)` | `PUT /customer_invoices/{customerInvoiceId}/finalize` | `customer_invoices:all` | Turn a draft invoice into a finalized invoice. | | `markAsPaid($customerInvoiceId)` | `PUT /customer_invoices/{customerInvoiceId}/mark_as_paid` | `customer_invoices:all` | Mark a customer invoice as paid. | | `sendByEmail($customerInvoiceId, $recipients = null)` | `POST /customer_invoices/{customerInvoiceId}/send_by_email` | `customer_invoices:all` | Send a customer invoice by email. | | `linkCreditNote($customerInvoiceId, $creditNoteId)` | `POST /customer_invoices/{customerInvoiceId}/link_credit_note` | `customer_invoices:all` | Link a credit note to a customer invoice. | | `updateImported($customerInvoiceId, $date = null, ...)` | `PUT /customer_invoices/{customerInvoiceId}/update_imported` | `customer_invoices:all` | Update an imported customer invoice. | | `listInvoiceLines($customerInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /customer_invoices/{customerInvoiceId}/invoice_lines` | `customer_invoices:all or customer_invoices:readonly` | List invoice lines for a customer invoice. | | `listInvoiceLineSections($customerInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /customer_invoices/{customerInvoiceId}/invoice_line_sections` | `customer_invoices:all or customer_invoices:readonly` | List invoice line sections for a customer invoice. | | `listPayments($customerInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /customer_invoices/{customerInvoiceId}/payments` | `customer_invoices:all or customer_invoices:readonly` | List payments recorded against a customer invoice. | | `listMatchedTransactions($customerInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /customer_invoices/{customerInvoiceId}/matched_transactions` | `customer_invoices:all or customer_invoices:readonly` | List bank transactions matched to a customer invoice. | | `matchTransaction($customerInvoiceId, $transactionId)` | `POST /customer_invoices/{customerInvoiceId}/matched_transactions` | `customer_invoices:all` | Match a bank transaction to a customer invoice. | | `unmatchTransaction($customerInvoiceId, $transactionId)` | `DELETE /customer_invoices/{customerInvoiceId}/matched_transactions/{transactionId}` | `customer_invoices:all` | Unmatch a bank transaction from a customer invoice. | | `listAppendices($customerInvoiceId, $cursor = null, $limit = null)` | `GET /customer_invoices/{customerInvoiceId}/appendices` | `customer_invoices:all or customer_invoices:readonly` | List appendices attached to a customer invoice. | | `addAppendix($customerInvoiceId, $file)` | `POST /customer_invoices/{customerInvoiceId}/appendices` | `customer_invoices:all` | Upload an appendix file (a supporting document) for a customer invoice. | | `listCategories($customerInvoiceId, $cursor = null, $limit = null)` | `GET /customer_invoices/{customerInvoiceId}/categories` | `customer_invoices:all or customer_invoices:readonly` | List categories assigned to a customer invoice. | | `categorize($customerInvoiceId, $categories)` | `PUT /customer_invoices/{customerInvoiceId}/categories` | `customer_invoices:all` | Replace the categories assigned to a customer invoice. | | `listCustomHeaderFields($customerInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /customer_invoices/{customerInvoiceId}/custom_header_fields` | `customer_invoices:all or customer_invoices:readonly` | List custom header fields for a customer invoice. | ## CustomerInvoiceTemplates (`$client->customerInvoiceTemplates`) Read only access to the customer invoice templates configured on the company (used to brand and structure generated invoice PDFs). | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $sort = null)` | `GET /customer_invoice_templates` | `customer_invoice_templates:readonly` | List customer invoice templates configured on the company. | ## Quotes (`$client->quotes`) Manages quotes sent to customers, including their lines, sections, appendices and status transitions. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /quotes` | `quotes:all, quotes:readonly` | List quotes. | | `create($date, $deadline, $customerId, $invoiceLines, $quoteTemplateId = null, ...)` | `POST /quotes` | `quotes:all` | Create a quote. | | `get($quoteId)` | `GET /quotes/{quoteId}` | `quotes:all, quotes:readonly` | Retrieve a quote by its identifier. | | `update($quoteId, $date = null, ...)` | `PUT /quotes/{quoteId}` | `quotes:all` | Update a quote. | | `listAppendices($quoteId, $cursor = null, $limit = null)` | `GET /quotes/{quoteId}/appendices` | `quotes:all, quotes:readonly` | List the appendices (attached files) of a quote. | | `addAppendix($quoteId, $file)` | `POST /quotes/{quoteId}/appendices` | `quotes:all` | Upload an appendix for a quote. | | `listInvoiceLines($quoteId, $cursor = null, $limit = null, $sort = null)` | `GET /quotes/{quoteId}/invoice_lines` | `quotes:all, quotes:readonly` | List the invoice lines of a quote. | | `listInvoiceLineSections($quoteId, $cursor = null, $limit = null, $sort = null)` | `GET /quotes/{quoteId}/invoice_line_sections` | `quotes:all, quotes:readonly` | List the invoice line sections of a quote. | | `sendByEmail($quoteId, $recipients = null)` | `POST /quotes/{quoteId}/send_by_email` | `quotes:all` | Send a quote by email. | | `updateStatus($quoteId, $status)` | `PUT /quotes/{quoteId}/update_status` | `quotes:all` | Update the status of a quote (pending, accepted, denied, invoiced, or expired). | ## CommercialDocuments (`$client->commercialDocuments`) Read only access to commercial documents: proformas, shipping orders and purchasing orders. These are generated from quotes or created directly in the Pennylane app; there is no create, update or delete operation. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /commercial_documents` | `commercial_documents:all, commercial_documents:readonly` | List commercial documents. | | `get($commercialDocumentId)` | `GET /commercial_documents/{commercialDocumentId}` | `commercial_documents:all, commercial_documents:readonly` | Retrieve a commercial document by its identifier. | | `listAppendices($commercialDocumentId, $cursor = null, $limit = null)` | `GET /commercial_documents/{commercialDocumentId}/appendices` | `commercial_documents:all, commercial_documents:readonly` | List the appendices (attached files) of a commercial document. | | `addAppendix($commercialDocumentId, $file)` | `POST /commercial_documents/{commercialDocumentId}/appendices` | `commercial_documents:all` | Upload an appendix for a commercial document. | | `listInvoiceLines($commercialDocumentId, $cursor = null, $limit = null, $sort = null)` | `GET /commercial_documents/{commercialDocumentId}/invoice_lines` | `commercial_documents:all, commercial_documents:readonly` | List the invoice lines of a commercial document. | | `listInvoiceLineSections($commercialDocumentId, $cursor = null, $limit = null, $sort = null)` | `GET /commercial_documents/{commercialDocumentId}/invoice_line_sections` | `commercial_documents:all, commercial_documents:readonly` | List the invoice line sections of a commercial document. | ## BillingSubscriptions (`$client->billingSubscriptions`) Creates and manages recurring billing subscriptions that automatically generate customer invoices on a schedule (yearly, monthly or weekly). | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /billing_subscriptions` | `billing_subscriptions:all, billing_subscriptions:readonly` | List billing subscriptions. | | `create($start, $mode, $paymentConditions, $paymentMethod, $recurringRule, $customerId, $customerInvoiceData, $label = null)` | `POST /billing_subscriptions` | `billing_subscriptions:all` | Create a billing subscription that generates customer invoices on a recurring schedule. | | `get($billingSubscriptionId)` | `GET /billing_subscriptions/{billingSubscriptionId}` | `billing_subscriptions:all, billing_subscriptions:readonly` | Retrieve a billing subscription by its identifier. | | `update($billingSubscriptionId, $stop = null, ...)` | `PUT /billing_subscriptions/{billingSubscriptionId}` | `billing_subscriptions:all` | Update a billing subscription, including pausing or resuming it. | | `listInvoiceLineSections($billingSubscriptionId, $cursor = null, $limit = null, $sort = null)` | `GET /billing_subscriptions/{billingSubscriptionId}/invoice_line_sections` | `billing_subscriptions:all, billing_subscriptions:readonly` | List the invoice line sections of a billing subscription. | | `listInvoiceLines($billingSubscriptionId, $cursor = null, $limit = null, $sort = null)` | `GET /billing_subscriptions/{billingSubscriptionId}/invoice_lines` | `billing_subscriptions:all, billing_subscriptions:readonly` | List the invoice lines of a billing subscription. | ## Products (`$client->products`) Manages the product catalog used to fill invoice, quote and billing subscription lines. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /products` | `products:readonly` | List products. | | `create($label, $priceBeforeTax, $vatRate, $description = null, ...)` | `POST /products` | `products:all` | Create a product. | | `get($productId)` | `GET /products/{productId}` | `products:readonly` | Retrieve a product by its identifier. | | `update($productId, $label = null, ...)` | `PUT /products/{productId}` | `products:all` | Update a product. | ## Example ```php customers->companies->create( name: 'Acme Corp', billingAddress: [ 'address' => '12 rue de Rivoli', 'postal_code' => '75004', 'city' => 'Paris', 'country_alpha2' => 'FR', ], ); // Add a product to the catalog. $product = $client->products->create( label: 'Consulting (half day)', priceBeforeTax: '450.00', vatRate: 'FR_200', ); // Draft an invoice for that customer using the product, then finalize it. $invoice = $client->customerInvoices->create( date: '2026-07-09', deadline: '2026-08-08', customerId: $customer->id, invoiceLines: [ ['product_id' => $product->id, 'quantity' => '2'], ], ); $finalized = $client->customerInvoices->finalize($invoice->id); // List invoices created this month. foreach ($client->customerInvoices->list(filter: Filter::gte('date', '2026-07-01')) as $inv) { echo $inv->id, "\n"; } ``` --- # Payment mandates Payment mandates authorize direct debit collection from a customer. The Company API supports three mandate families: classic SEPA mandates, GoCardless mandates, and the newer Pennylane Pro Account mandates, along with a migration path from the first two into Pro Account. ## SepaMandates (`$client->sepaMandates`) Manage SEPA direct debit mandates. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /sepa_mandates` | `customer_mandates:all or customer_mandates:readonly` | List SEPA mandates. | | `create($bic, $iban, $signedAt, $identifier, $customerId, $bank = null, $sequenceType = null)` | `POST /sepa_mandates` | `customer_mandates:all` | Create a SEPA mandate for a customer. | | `get($sepaMandateId)` | `GET /sepa_mandates/{sepaMandateId}` | `customer_mandates:all or customer_mandates:readonly` | Retrieve a SEPA mandate by its identifier. | | `update($sepaMandateId, $bank = null, $bic = null, $iban = null, $sequenceType = null, $signedAt = null, $identifier = null, $customerId = null)` | `PUT /sepa_mandates/{sepaMandateId}` | `customer_mandates:all` | Update a SEPA mandate. | | `delete($sepaMandateId)` | `DELETE /sepa_mandates/{sepaMandateId}` | `customer_mandates:all` | Delete a SEPA mandate. | ## GocardlessMandates (`$client->gocardlessMandates`) Manage GoCardless direct debit mandates. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /gocardless_mandates` | `customer_mandates:all or customer_mandates:readonly` | List GoCardless mandates. | | `get($gocardlessMandateId)` | `GET /gocardless_mandates/{gocardlessMandateId}` | `customer_mandates:all or customer_mandates:readonly` | Retrieve a GoCardless mandate by its identifier. | | `requestByEmail($customerId, $recipients, $subject = null, $body = null)` | `POST /gocardless_mandates/mail_requests` | `customer_mandates:all` | Email a customer a link to sign a GoCardless mandate. | | `cancel($gocardlessMandateId)` | `POST /gocardless_mandates/{gocardlessMandateId}/cancellations` | `customer_mandates:all` | Cancel a GoCardless mandate. | | `associate($gocardlessMandateId, $customerId)` | `POST /gocardless_mandates/{gocardlessMandateId}/associations` | `customer_mandates:all` | Associate a GoCardless mandate with a customer. | ## ProAccount (`$client->proAccount`) Manage Pennylane Pro Account payment mandates and their migration from SEPA or GoCardless mandates. | Method | HTTP | Scope | Description | |---|---|---|---| | `requestMandate($customerId)` | `POST /pro_account/mandate_requests` | `customer_mandates:all` | Send a customer a request to sign a Pro Account SEPA mandate. | | `listMandateMigrations($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /pro_account/mandate_migrations` | `customer_mandates:readonly or customer_mandates:all` | List mandates that are candidates for migration to Pro Account, or already migrated. | | `migrateMandates($mandateType, $mandateId, $earlyExecutionDatePermitted = null)` | `POST /pro_account/mandate_migrations` | `customer_mandates:all` | Migrate a SEPA or GoCardless mandate to Pro Account. `mandateType` is one of `SepaMandate`, `Mandate` (GoCardless). | | `listMandates($cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /pro_account/mandates` | `customer_mandates:readonly or customer_mandates:all` | List Pro Account payment mandates. | ## Example ```php sepaMandates->create( bic: 'BNPAFRPPXXX', iban: 'FR7630004000031234567890143', signedAt: '2026-07-01', identifier: 'MANDATE-0001', customerId: 42, ); foreach ($client->gocardlessMandates->list(filter: [Filter::eq('customer_id', 42)]) as $gcMandate) { echo "GoCardless mandate {$gcMandate->id}\n"; } $migration = $client->proAccount->migrateMandates( mandateType: 'SepaMandate', mandateId: $mandate->id, ); ``` --- # Webhooks and misc This page rounds up the remaining Company API resources: standalone file uploads, change event feeds for keeping an external system in sync, webhook subscriptions, and the identity endpoint for the current access token. ## FileAttachments (`$client->fileAttachments`) Upload standalone files, for example to attach to other records later. Uploads are multipart, built from a `Pennylane\Sdk\FileUpload` instance. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($file, $filename = null)` | `POST /file_attachments` | `file_attachments:all` | Upload a file attachment. `filename` defaults to the uploaded file's own name. | ## Changelogs (`$client->changelogs`) Change event feeds: insert, update and delete events for a resource, useful for keeping an external system in sync without repolling full collections. One method per watched resource type, all sharing the same signature (`cursor`, `limit`, `startDate`). | Method | HTTP | Scope | Description | |---|---|---|---| | `customerInvoices($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/customer_invoices` | `customer_invoices:readonly` | Get customer invoice change events. | | `supplierInvoices($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/supplier_invoices` | `supplier_invoices:readonly` | Get supplier invoice change events. | | `customers($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/customers` | `customers:readonly` | Get customer change events. | | `suppliers($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/suppliers` | `suppliers:readonly` | Get supplier change events. | | `products($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/products` | `products:readonly` | Get product change events. | | `ledgerEntryLines($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/ledger_entry_lines` | `ledger_entries:readonly` | Get ledger entry line change events. | | `transactions($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/transactions` | `transactions:readonly` | Get transaction change events. | | `quotes($cursor = null, $limit = null, $startDate = null)` | `GET /changelogs/quotes` | `quotes:readonly` | Get quote change events. | `startDate` accepts a `\DateTimeInterface` or a string, and only returns events processed after that date. ## WebhookSubscriptions (`$client->webhookSubscriptions`) **Beta: subject to change.** Register a callback URL and the event types Pennylane should push to it, instead of polling the changelog endpoints above. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null)` | `GET /webhook_subscriptions` | (undocumented) | List webhook subscriptions. (beta) | | `create($callbackUrl, $events, $enabled = null)` | `POST /webhook_subscriptions` | (undocumented) | Create a webhook subscription. The response carries a secret used to verify the HMAC signature of deliveries; it is returned only this once, so store it right away. (beta) | | `get($webhookSubscriptionId)` | `GET /webhook_subscriptions/{webhookSubscriptionId}` | (undocumented) | Retrieve a webhook subscription by its identifier. (beta) | | `update($webhookSubscriptionId, $callbackUrl = null, $events = null, $enabled = null)` | `PUT /webhook_subscriptions/{webhookSubscriptionId}` | (undocumented) | Update a webhook subscription. (beta) | | `delete($webhookSubscriptionId)` | `DELETE /webhook_subscriptions/{webhookSubscriptionId}` | (undocumented) | Delete a webhook subscription. (beta) | See the webhooks reference page for signature verification and payload parsing with `Pennylane\Sdk\Webhooks\Webhooks`. ## Me (`$client->me`) The identity tied to the current access token. | Method | HTTP | Scope | Description | |---|---|---|---| | `retrieve()` | `GET /me` | (undocumented) | Retrieve the authenticated user, their company, and the OAuth scopes granted to the current access token. | ## Example ```php fileAttachments->create( FileUpload::fromPath('/path/to/receipt.pdf'), ); foreach ($client->changelogs->customerInvoices(startDate: '2026-07-01') as $event) { echo "Customer invoice change: {$event->id}\n"; } $subscription = $client->webhookSubscriptions->create( callbackUrl: 'https://example.com/webhooks/pennylane', events: ['customer_invoice.e_invoicing_status_updated'], ); echo "Store this secret now: {$subscription->secret}\n"; $me = $client->me->retrieve(); echo "Connected as company {$me->company?->id}\n"; ``` --- # Purchases This page covers the purchasing side of the Company API: the supplier directory, supplier invoices (purchase bills) from import through payment and accounting validation, and purchase requests (purchase orders) raised ahead of an invoice. ## Suppliers (`$client->suppliers`) Manages the supplier directory and the categories a supplier is normally booked against. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /suppliers` | `suppliers:readonly or suppliers:all` | List suppliers. | | `create($name, $establishmentNo = null, $regNo = null, ...)` | `POST /suppliers` | `suppliers:all` | Create a supplier. | | `get($supplierId)` | `GET /suppliers/{supplierId}` | `suppliers:readonly or suppliers:all` | Retrieve a supplier by its id. | | `update($supplierId, $name = null, $establishmentNo = null, ...)` | `PUT /suppliers/{supplierId}` | `suppliers:all` | Update a supplier. | | `listCategories($supplierId, $cursor = null, $limit = null)` | `GET /suppliers/{supplierId}/categories` | `suppliers:readonly or suppliers:all` | List the categories a supplier is booked against. | | `categorize($supplierId, $categories)` | `PUT /suppliers/{supplierId}/categories` | `suppliers:all` | Replace the categories a supplier is booked against. | ## SupplierInvoices (`$client->supplierInvoices`) Manages supplier invoices end to end: creation by import, updates to lines and categories, payment tracking, transaction matching, and e-invoice status. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /supplier_invoices` | `supplier_invoices:readonly or supplier_invoices:all` | List supplier invoices. | | `get($supplierInvoiceId)` | `GET /supplier_invoices/{supplierInvoiceId}` | `supplier_invoices:readonly or supplier_invoices:all` | Retrieve a supplier invoice by its id. | | `update($supplierInvoiceId, $supplierId = null, $date = null, ...)` | `PUT /supplier_invoices/{supplierInvoiceId}` | `supplier_invoices:all` | Update a supplier invoice, optionally adding, editing, or removing invoice lines. | | `listInvoiceLines($supplierInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /supplier_invoices/{supplierInvoiceId}/invoice_lines` | `supplier_invoices:readonly or supplier_invoices:all` | List the lines of a supplier invoice. | | `listCategories($supplierInvoiceId, $cursor = null, $limit = null)` | `GET /supplier_invoices/{supplierInvoiceId}/categories` | `supplier_invoices:readonly or supplier_invoices:all` | List the categories a supplier invoice is booked against. | | `categorize($supplierInvoiceId, $categories)` | `PUT /supplier_invoices/{supplierInvoiceId}/categories` | `supplier_invoices:all` | Replace the categories a supplier invoice is booked against. | | `listPayments($supplierInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /supplier_invoices/{supplierInvoiceId}/payments` | `supplier_invoices:readonly or supplier_invoices:all` | List the payments recorded against a supplier invoice. | | `updatePaymentStatus($supplierInvoiceId, $paymentStatus)` | `PUT /supplier_invoices/{supplierInvoiceId}/payment_status` | `supplier_invoices:all` | Update the payment status of a supplier invoice (paid or to_be_paid). | | `listMatchedTransactions($supplierInvoiceId, $cursor = null, $limit = null, $sort = null)` | `GET /supplier_invoices/{supplierInvoiceId}/matched_transactions` | `supplier_invoices:readonly or supplier_invoices:all` | List the bank transactions matched to a supplier invoice. | | `matchTransaction($supplierInvoiceId, $transactionId)` | `POST /supplier_invoices/{supplierInvoiceId}/matched_transactions` | `supplier_invoices:all` | Match a bank transaction to a supplier invoice. | | `unmatchTransaction($supplierInvoiceId, $transactionId)` | `DELETE /supplier_invoices/{supplierInvoiceId}/matched_transactions/{transactionId}` | `supplier_invoices:all` | Unmatch a bank transaction from a supplier invoice. | | `linkPurchaseRequests($supplierInvoiceId, $purchaseRequestId)` | `POST /supplier_invoices/{supplierInvoiceId}/linked_purchase_requests` | `supplier_invoices:all` | Link a purchase request to a supplier invoice. | | `importFromFile($fileAttachmentId, $supplierId, $date, $deadline, ...)` | `POST /supplier_invoices/import` | `supplier_invoices:all` | Import a supplier invoice from a file already uploaded through file attachments. | | `importEInvoice($file, $invoiceOptions = null)` | `POST /supplier_invoices/e_invoices/imports` | `supplier_invoices:all` | Import a supplier e-invoice file (Factur-X, UBL, or CII); beta, subject to change. | | `validateAccounting($supplierInvoiceId)` | `PUT /supplier_invoices/{supplierInvoiceId}/validate_accounting` | `supplier_invoices:all` | Validate the accounting of a supplier invoice. | | `updateEInvoiceStatus($supplierInvoiceId, $status, $reason = null)` | `PUT /supplier_invoices/{supplierInvoiceId}/e_invoice_status` | `supplier_invoices:all` | Update the e-invoice status of a supplier invoice (approved, disputed, or refused). | Note: `importFromFile()` takes a `fileAttachmentId` referencing a file already uploaded through `$client->fileAttachments`, it does not accept a raw upload. `importEInvoice()` is the one method here that takes a `FileUpload` directly, built with `FileUpload::fromPath()`. ## PurchaseRequests (`$client->purchaseRequests`) Manages purchase requests (purchase orders raised for approval ahead of the corresponding supplier invoice). | Method | HTTP | Scope | Description | |---|---|---|---| | `list($cursor = null, $limit = null, ...)` | `GET /purchase_requests` | `purchase_requests:readonly or purchase_requests:all` | List purchase requests. | | `get($purchaseRequestId)` | `GET /purchase_requests/{purchaseRequestId}` | `purchase_requests:readonly or purchase_requests:all` | Retrieve a purchase request by its id. | | `importFromFile($fileAttachmentId, $reason, $supplierId, $purchaseOrderNumber, ...)` | `POST /purchase_requests/imports` | `purchase_requests:all` | Import a purchase order from a file already uploaded through file attachments. | Like supplier invoice imports, `importFromFile()` here takes a `fileAttachmentId` from a file uploaded beforehand through `$client->fileAttachments`, not a direct file upload. ## Example ```php suppliers->create( name: 'Office Supplies Co', establishmentNo: '12345678900012', ); foreach ($client->supplierInvoices->list(filter: Filter::eq('supplier_id', $supplier->id)) as $invoice) { $client->supplierInvoices->validateAccounting($invoice->id); } foreach ($client->purchaseRequests->list(filter: Filter::eq('supplier_id', $supplier->id)) as $request) { echo "Purchase request #{$request->id}\n"; } ``` --- # Errors Every exception raised by the SDK derives from `Pennylane\Sdk\Exception\PennylaneException`, itself a `RuntimeException`. Catch that base type to handle any SDK failure in one place, or catch a specific subclass to react to one situation. ## Exception hierarchy ``` PennylaneException ├── ApiConnectionException │ └── ApiTimeoutException ├── ApiStatusException │ ├── BadRequestException (400) │ ├── AuthenticationException (401) │ ├── PermissionDeniedException (403) │ ├── NotFoundException (404) │ ├── ConflictException (409) │ ├── ValidationException (422) │ ├── RateLimitException (429) │ └── ServerException (5xx) ├── ConfigurationException ├── InvalidRequestBodyException ├── InvalidResponseException └── WebhookSignatureException ``` ## PennylaneException Base class for every exception thrown by the SDK. Carries no properties of its own beyond the standard `\RuntimeException` message and previous exception. ## ApiConnectionException The request failed at the transport level and no HTTP response was received (DNS failure, connection refused, TLS error, and so on). ### ApiTimeoutException `final class ApiTimeoutException extends ApiConnectionException`. The request timed out before a response was received. ## ApiStatusException The API answered with an error status code (an unexpected 3xx, or a 4xx/5xx). Error bodies are not consistent across Pennylane endpoints, so every property is parsed defensively and may be `null`. Never log the request that produced this exception verbatim: it carries the Authorization header. Log `statusCode`, `errorCode` and `getMessage()` instead. This exception deliberately does not keep a reference to the PSR-7 request or response. | Property | Type | Description | |---|---|---| | `statusCode` | `int` | HTTP status code of the response. | | `errorCode` | `?string` | Machine readable code, when the body provides one. | | `details` | `mixed` | Extra context from the error body; the shape varies per endpoint. | | `rawBody` | `string` | Response body, truncated for safety. | | `body` | `?array` | Decoded JSON error body, or `null` when the body is not a JSON array or object. | ### BadRequestException HTTP 400: the request is malformed or contains invalid parameters. ### AuthenticationException HTTP 401: the API token is missing, invalid or revoked. ### PermissionDeniedException HTTP 403: the token lacks the OAuth scope required by this endpoint. Every resource method documents its required scope in a `Scope:` line of its docblock. ### NotFoundException HTTP 404: the record does not exist, or the feature is not enabled on this company. ### ConflictException HTTP 409: the request conflicts with the current state of the record. ### ValidationException HTTP 422: the payload was understood but rejected by validation rules. For unbalanced ledger entries, `details` may carry the debit and credit totals reported by the API. ### RateLimitException HTTP 429: the per token rate limit was exceeded. The SDK throttles proactively and retries automatically, so this surfaces only after the retry budget is exhausted. Adds one property on top of `ApiStatusException`: | Property | Type | Description | |---|---|---| | `retryAfter` | `?float` | Seconds to wait, parsed from the `retry-after` header when it is present and numeric. `null` otherwise. | ### ServerException HTTP 5xx: Pennylane failed to process the request. The API has no idempotency mechanism, so the SDK never retries a POST after a 5xx: the first attempt may have been processed, and a blind retry could create a duplicate invoice or ledger entry. ## ConfigurationException The SDK is not usable as configured. Raised for a missing API token, an insecure base URL (anything other than https, or http outside localhost), or when no PSR-18 HTTP client can be found or built. ## InvalidRequestBodyException A request body or filter value cannot be sent safely. The most common cause is a PHP float in a payload: Pennylane expects monetary amounts and quantities as strings, and floats would lose decimal precision on accounting data. ## InvalidResponseException The API answered with a success status but the body is not usable JSON. Typical causes are captive portals and misconfigured proxies returning an HTML page with a 200 status. ## WebhookSignatureException A webhook payload failed signature verification. Treat the payload as untrusted and discard it. ## Example ```php customerInvoices->get(999999999); } catch (NotFoundException $e) { echo "Invoice not found (HTTP {$e->statusCode}): {$e->getMessage()}\n"; } catch (ValidationException $e) { echo "Validation failed: {$e->getMessage()}\n"; var_dump($e->details); } catch (RateLimitException $e) { echo 'Rate limited, retry after ' . ($e->retryAfter ?? 'unknown') . " seconds\n"; } ``` --- # Filters `Pennylane\Sdk\Filter` builds one filter condition for the `filter` query parameter accepted by list methods. Build conditions with the static helpers and pass them as an array to `filter:`. ```php $client->customerInvoices->list(filter: [ Filter::gte('date', '2026-01-01'), Filter::eq('status', 'upcoming'), ]); ``` Operator availability varies per field and endpoint; each list method documents its reference URL where the filterable fields are described. ## Builders and wire operators | Builder | Value type | Wire operator | Notes | |---|---|---|---| | `Filter::eq($field, $value)` | `string\|int\|bool\|\DateTimeInterface\|null` | `eq` | Equality. | | `Filter::notEq($field, $value)` | `string\|int\|bool\|\DateTimeInterface\|null` | `not_eq` | Inequality. | | `Filter::lt($field, $value)` | `string\|int\|\DateTimeInterface` | `lt` | Strictly less than. | | `Filter::lte($field, $value)` | `string\|int\|\DateTimeInterface` | `lteq` | Less than or equal. The method name is `lte`, but it encodes to the wire operator `lteq`. | | `Filter::gt($field, $value)` | `string\|int\|\DateTimeInterface` | `gt` | Strictly greater than. | | `Filter::gte($field, $value)` | `string\|int\|\DateTimeInterface` | `gteq` | Greater than or equal. The method name is `gte`, but it encodes to the wire operator `gteq`. | | `Filter::in($field, $values)` | `list` | `in` | Value is one of the list. | | `Filter::notIn($field, $values)` | `list` | `not_in` | Value is none of the list. | | `Filter::where($field, $operator, $value)` | `mixed` | verbatim | Escape hatch for operators the API may add later; the operator string is sent exactly as given. | Every builder is a static factory on `Filter`; the constructor itself is private. A built `Filter` exposes three readonly properties: `field`, `operator` and `value`. `Filter::toArray()` returns `['field' => ..., 'operator' => ..., 'value' => ...]`, which is the shape also accepted directly instead of a `Filter` instance. `\DateTimeInterface` values are encoded to ATOM format (`Y-m-d\TH:i:sP`) on the wire. ## FiltersInput: accepted forms List methods that accept a `filter` parameter type it as `Filter|array|string|null`. `Pennylane\Sdk\Internal\FilterEncoder` accepts, and normalizes to the same wire JSON, all of the following: | Form | Example | |---|---| | A single `Filter` | `Filter::eq('status', 'paid')` | | A single condition array | `['field' => 'status', 'operator' => 'eq', 'value' => 'paid']` | | A list mixing `Filter` objects and condition arrays | `[Filter::eq('status', 'paid'), ['field' => 'date', 'operator' => 'gteq', 'value' => '2026-01-01']]` | | An already encoded JSON string | passed through unchanged | Anything else in a list (not a `Filter` and not an array) throws `InvalidRequestBodyException`. ## Float rejection Filter values are encoded defensively: a PHP `float` anywhere in a filter condition, including nested inside an array value, throws `InvalidRequestBodyException` before any request is sent. Pass monetary amounts and quantities as numeric strings instead, for example `"12.50"`, never `12.50` as a float. This matches the API's own string based representation of money and avoids floating point rounding on accounting data. ```php use Pennylane\Sdk\Filter; use Pennylane\Sdk\Exception\InvalidRequestBodyException; try { $client->customerInvoices->list(filter: [ Filter::gte('amount', 100.50), // float: rejected ]); } catch (InvalidRequestBodyException $e) { echo $e->getMessage() . "\n"; } // Correct: numeric string $client->customerInvoices->list(filter: [ Filter::gte('amount', '100.50'), ]); ``` --- # Firm resources Resources of `PennylaneFirm`. Every method takes `$companyId` as its first argument, except `FirmCompanies`. `FirmCompanies`, `FirmFiscalYears` and `FirmTrialBalance` use page-number pagination (`NumberedPage`); every other list method on this page uses cursor pagination (`CursorPage`), where a `foreach` walks all pages automatically. ## FirmCompanies (`$firm->companies`) Manages the client companies of the firm itself. Unlike every other class on this page, its methods do not take a `companyId` first argument, and `list()` returns a `NumberedPage` (page/per_page), not a `CursorPage`. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($page = null, $perPage = null, $filter = null)` | `GET /companies` | `companies:readonly` | List client companies. | | `get($companyId)` | `GET /companies/{companyId}` | `companies:readonly` | Retrieve a client company by its identifier. | ## FirmFiscalYears (`$firm->fiscalYears`) Manages the fiscal years of a client company. `list()` returns a `NumberedPage` (page/per_page pagination), one of only three exceptions to cursor pagination on this page. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $page = null, $perPage = null)` | `GET /companies/{companyId}/fiscal_years` | `fiscal_years:all or fiscal_years:readonly` | List the fiscal years of a company. | | `create($companyId, $start, $finish)` | `POST /companies/{companyId}/fiscal_years` | `fiscal_years:all` | Create a fiscal year for a company (fiscal years must be consecutive and not overlap). | ## FirmTrialBalance (`$firm->trialBalance`) Reads the trial balance of a client company. `list()` returns a `NumberedPage` (page/per_page pagination), one of only three exceptions to cursor pagination on this page. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $periodStart, $periodEnd, $isAuxiliary = null, $page = null, $perPage = null)` | `GET /companies/{companyId}/trial_balance` | `trial_balance:readonly` | List the trial balance lines of a company over a period. | ## FirmJournals (`$firm->journals`) Manages the accounting journals of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null)` | `GET /companies/{companyId}/journals` | `journals:all or journals:readonly` | List the journals of a company. | | `create($companyId, $code, $label)` | `POST /companies/{companyId}/journals` | `journals:all` | Create a journal for a company. | | `get($companyId, $journalId)` | `GET /companies/{companyId}/journals/{journalId}` | `journals:all or journals:readonly` | Retrieve a journal by its identifier. | ## FirmLedgerAccounts (`$firm->ledgerAccounts`) Manages the chart of accounts of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null)` | `GET /companies/{companyId}/ledger_accounts` | `ledger_accounts:all or ledger_accounts:readonly` | List the ledger accounts of a company. | | `create($companyId, $number, $label, $vatRate = null, $countryAlpha2 = null)` | `POST /companies/{companyId}/ledger_accounts` | `ledger_accounts:all` | Create a ledger account for a company. | | `get($companyId, $ledgerAccountId)` | `GET /companies/{companyId}/ledger_accounts/{ledgerAccountId}` | `ledger_accounts:all or ledger_accounts:readonly` | Retrieve a ledger account by its identifier. | | `update($companyId, $ledgerAccountId, $label = null, $letterable = null)` | `PUT /companies/{companyId}/ledger_accounts/{ledgerAccountId}` | `ledger_accounts:all` | Update a ledger account's label or letterable flag. | ## FirmLedgerEntries (`$firm->ledgerEntries`) Manages the ledger entries (accounting pieces) of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/ledger_entries` | `ledger_entries:all or ledger_entries:readonly` | List the ledger entries of a company. | | `create($companyId, $date, $label, $journalId, $ledgerEntryLines, $fileAttachmentId = null, $currency = null)` | `POST /companies/{companyId}/ledger_entries` | `ledger_entries:all` | Create a ledger entry; every line needs a debit and a credit key ("0.00" on the unused side) and the lines must balance. | | `get($companyId, $ledgerEntryId)` | `GET /companies/{companyId}/ledger_entries/{ledgerEntryId}` | `ledger_entries:all or ledger_entries:readonly` | Retrieve a ledger entry by its identifier. | | `update($companyId, $ledgerEntryId, $date = null, $label = null, $journalId = null, $fileAttachmentId = null, $currency = null, $ledgerEntryLines = null)` | `PUT /companies/{companyId}/ledger_entries/{ledgerEntryId}` | `ledger_entries:all` | Update a ledger entry, including creating, updating or deleting its lines, staying balanced. | ## FirmLedgerEntryLines (`$firm->ledgerEntryLines`) Reads the entry lines (debit or credit movements) of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/ledger_entry_lines` | `ledger_entries:all or ledger_entries:readonly` | List the ledger entry lines of a company. | | `get($companyId, $ledgerEntryLineId)` | `GET /companies/{companyId}/ledger_entry_lines/{ledgerEntryLineId}` | `ledger_entries:all or ledger_entries:readonly` | Retrieve a ledger entry line by its identifier. | | `listLetteredLines($companyId, $ledgerEntryLineId, $cursor = null, $limit = null, $sort = null)` | `GET /companies/{companyId}/ledger_entry_lines/{ledgerEntryLineId}/lettered_ledger_entry_lines` | `ledger_entries:all or ledger_entries:readonly` | List the ledger entry lines lettered together with a given entry line. | ## FirmExports (`$firm->exports`) Entry point for the accounting exports of a client company. It carries no methods of its own: it only wires the `fecs` (`FirmFecExports`) and `analyticalGeneralLedgers` (`FirmAnalyticalGeneralLedgerExports`) sub resources documented next. ## FirmFecExports (`$firm->exports->fecs`) Exports the FEC (fichier des ecritures comptables) of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($companyId, $periodStart, $periodEnd)` | `POST /companies/{companyId}/exports/fecs` | `exports:fec` | Start a FEC export for a period (generated asynchronously). | | `get($companyId, $exportId)` | `GET /companies/{companyId}/exports/fecs/{exportId}` | `exports:fec` | Retrieve the state of a FEC export; poll until status is done, then read fileUrl. | ## FirmAnalyticalGeneralLedgerExports (`$firm->exports->analyticalGeneralLedgers`) Exports the analytical general ledger of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($companyId, $periodStart, $periodEnd, $mode = null)` | `POST /companies/{companyId}/exports/analytical_general_ledgers` | `exports:agl` | Start an analytical general ledger export for a period (generated asynchronously). | | `get($companyId, $exportId)` | `GET /companies/{companyId}/exports/analytical_general_ledgers/{exportId}` | `exports:agl` | Retrieve the state of an analytical general ledger export; poll until status is done, then read fileUrl. | ## FirmDms (`$firm->dms`) Entry point for a client company's document management system (DMS). It carries no methods of its own: it only wires the `files` (`FirmDmsFiles`) and `folders` (`FirmDmsFolders`) sub resources documented next. ## FirmDmsFiles (`$firm->dms->files`) Manages the files stored in a client company's DMS. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null)` | `GET /companies/{companyId}/dms/files` | `dms_files:readonly or dms_files:all` | List the files of a company's DMS. | | `upload($companyId, $file, $name = null, $parentFolderId = null)` | `POST /companies/{companyId}/dms/files` | `dms_files:all` | Upload a file to a company's DMS. | ## FirmDmsFolders (`$firm->dms->folders`) Manages the folders of a client company's DMS. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null)` | `GET /companies/{companyId}/dms/folders` | `dms_files:readonly or dms_files:all` | List the folders of a company's DMS. | | `create($companyId, $name, $parentFolderId = null)` | `POST /companies/{companyId}/dms/folders` | `dms_files:all` | Create a DMS folder for a company. | ## FirmFileAttachments (`$firm->fileAttachments`) Uploads standalone file attachments for a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `create($companyId, $file, $filename = null)` | `POST /companies/{companyId}/file_attachments` | `file_attachments:all` | Upload a file attachment. | ## FirmCustomerInvoices (`$firm->customerInvoices`) Reads the customer invoices of a client company (read only, beta). | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/customer_invoices` | `customer_invoices:readonly` | List customer invoices (beta, subject to change). | | `get($companyId, $customerInvoiceId)` | `GET /companies/{companyId}/customer_invoices/{customerInvoiceId}` | `customer_invoices:readonly` | Retrieve a customer invoice by its identifier (beta, subject to change). | ## FirmSupplierInvoices (`$firm->supplierInvoices`) Reads the supplier invoices of a client company (read only, beta). | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/supplier_invoices` | `supplier_invoices:readonly` | List supplier invoices (beta, subject to change). | | `get($companyId, $supplierInvoiceId)` | `GET /companies/{companyId}/supplier_invoices/{supplierInvoiceId}` | `supplier_invoices:readonly` | Retrieve a supplier invoice by its identifier (beta, subject to change). | ## FirmCustomers (`$firm->customers`) Reads the customers of a client company (read only, beta). No `get()` method: `list()` is the only entry point. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/customers` | `customers:readonly` | List customers (read only, beta). | ## FirmSuppliers (`$firm->suppliers`) Reads the suppliers of a client company (read only, beta). No `get()` method: `list()` is the only entry point. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/suppliers` | `suppliers:readonly` | List suppliers (read only, beta). | ## FirmBankAccounts (`$firm->bankAccounts`) Manages the bank accounts of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $sort = null)` | `GET /companies/{companyId}/bank_accounts` | `bank_accounts:readonly or bank_accounts:all` | List bank accounts. | | `create($companyId, $name, $bankEstablishmentId = null, $iban = null, $bic = null, $currency = null, $accountType = null)` | `POST /companies/{companyId}/bank_accounts` | `bank_accounts:all` | Create a bank account (uses the "Other" establishment if none is given). | ## FirmTransactions (`$firm->transactions`) Manages the bank transactions of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/transactions` | `transactions:readonly or transactions:all` | List transactions. | | `create($companyId, $bankAccountId, $label, $date, $amount, $fee = null)` | `POST /companies/{companyId}/transactions` | `transactions:all` | Create a transaction. | | `update($companyId, $transactionId, $customerId = null, $supplierId = null)` | `PUT /companies/{companyId}/transactions/{transactionId}` | `transactions:all` | Reconcile a transaction with a customer or a supplier (never both). | ## FirmCategories (`$firm->categories`) Reads the analytical categories of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null, $filter = null, $sort = null)` | `GET /companies/{companyId}/categories` | `categories:readonly` | List categories. | | `get($companyId, $categoryId)` | `GET /companies/{companyId}/categories/{categoryId}` | `categories:readonly` | Retrieve a category by its identifier. | ## FirmCategoryGroups (`$firm->categoryGroups`) Reads the category groups of a client company. | Method | HTTP | Scope | Description | |---|---|---|---| | `list($companyId, $cursor = null, $limit = null)` | `GET /companies/{companyId}/category_groups` | `categories:readonly` | List category groups. | | `get($companyId, $categoryGroupId)` | `GET /companies/{companyId}/category_groups/{categoryGroupId}` | `categories:readonly` | Retrieve a category group by its identifier. | | `listCategories($companyId, $categoryGroupId, $cursor = null, $limit = null)` | `GET /companies/{companyId}/category_groups/{categoryGroupId}/categories` | `categories:readonly` | List the categories that belong to a category group. | ## FirmChangelogs (`$firm->changelogs`) Reads the change events of a client company's data, one method per watched resource type. | Method | HTTP | Scope | Description | |---|---|---|---| | `dmsFiles($companyId, $cursor = null, $limit = null, $startDate = null)` | `GET /companies/{companyId}/changelogs/dms_files` | `dms_files:readonly or dms_files:all` | Get DMS files change events. | | `ledgerEntryLines($companyId, $cursor = null, $limit = null, $startDate = null)` | `GET /companies/{companyId}/changelogs/ledger_entry_lines` | `ledger_entries:readonly or ledger_entries:all` | Get ledger entry line change events. | | `supplierInvoices($companyId, $cursor = null, $limit = null, $startDate = null)` | `GET /companies/{companyId}/changelogs/supplier_invoices` | `supplier_invoices:readonly` | Get supplier invoices change events (beta, subject to change). | | `customerInvoices($companyId, $cursor = null, $limit = null, $startDate = null)` | `GET /companies/{companyId}/changelogs/customer_invoices` | `customer_invoices:readonly` | Get customer invoices change events (beta, subject to change). | `startDate` is mutually exclusive with `cursor` on every `FirmChangelogs` method: pass one or the other, not both. ## Example ```php companies->list() as $company) { echo $company->id . ' ' . $company->name . PHP_EOL; } $companyId = 4217; // Every other resource takes $companyId as its first argument, and lists here // use cursor pagination. foreach ($firm->ledgerAccounts->list($companyId, limit: 50) as $ledgerAccount) { echo $ledgerAccount->id . ' ' . $ledgerAccount->number . ' ' . $ledgerAccount->label . PHP_EOL; } $fiscalYears = $firm->fiscalYears->list($companyId); foreach ($fiscalYears as $fiscalYear) { echo $fiscalYear->id . PHP_EOL; } ``` --- # OAuth `Pennylane\Sdk\OAuth\OAuthApp` implements the OAuth 2.0 authorization code flow for multi company partner integrations. 1. Send the user to `authorizationUrl()`. 2. Exchange the received code with `exchangeCode()`. 3. Use `new Pennylane(apiToken: $tokens->accessToken)`; access tokens expire after 24 hours. 4. Refresh with `refresh()`. Pennylane applies refresh token rotation: every refresh invalidates BOTH previous tokens. Never run two refreshes concurrently, including across processes, and persist the returned pair immediately after every refresh. ## OAuthApp ```php public function __construct( public readonly string $clientId, string $clientSecret, public readonly string $redirectUri, string $authorizeUrl = self::DEFAULT_AUTHORIZE_URL, string $tokenUrl = self::DEFAULT_TOKEN_URL, ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null, ) ``` `clientSecret` is marked `#[\SensitiveParameter]` and kept private; it is never exposed as a public property. `authorizeUrl` and `tokenUrl` default to `OAuthApp::DEFAULT_AUTHORIZE_URL` (`https://app.pennylane.com/oauth/authorize`) and `OAuthApp::DEFAULT_TOKEN_URL` (`https://app.pennylane.com/oauth/oauth/token`), and are validated with the same https rule as the main clients (http allowed on localhost only). `httpClient`, `requestFactory` and `streamFactory` follow the same discovery rules as `Pennylane` when omitted. ### Methods | Method | Description | |---|---| | `authorizationUrl(array\|string $scopes, ?string $state = null): string` | Builds the URL where the user grants your app access. `$scopes` is a list of scope strings (for example `['customer_invoices:all']`) or an already space separated string. `$state` is an opaque anti CSRF value returned to your redirect URI. | | `exchangeCode(string $code): OAuthTokens` | Exchanges the authorization code for the initial token pair. | | `refresh(string $refreshToken): OAuthTokens` | Gets a new token pair. Persist the returned pair immediately: because of refresh token rotation, the pair you passed in is now invalid. `$refreshToken` is marked `#[\SensitiveParameter]`. | Both `exchangeCode()` and `refresh()` throw `ApiConnectionException` / `ApiTimeoutException` on a transport failure, an `ApiStatusException` subclass when the token endpoint answers with an error status, and `InvalidResponseException` when the response is not JSON or is missing `access_token`. ## OAuthTokens `Pennylane\Sdk\OAuth\OAuthTokens` extends `PennylaneObject`. Both token values are redacted from `var_dump()` output (through `__debugInfo()`); access them explicitly through the properties below. | Property | Type | Description | |---|---|---| | `accessToken` | `string` | The access token to pass as `apiToken` to `Pennylane` or `PennylaneFirm`. `#[\SensitiveParameter]`. | | `refreshToken` | `?string` | The refresh token, if the response included one. `#[\SensitiveParameter]`. | | `tokenType` | `?string` | Token type, typically `"bearer"`. | | `expiresIn` | `?int` | Seconds until `accessToken` expires. | | `scope` | `?string` | Space separated granted scopes. | | `createdAt` | `?int` | Unix timestamp of token creation. | ## Example ```php authorizationUrl(['customer_invoices:all', 'suppliers:readonly'], state: bin2hex(random_bytes(16))); // Step 2: in your callback route, exchange the code query parameter. $tokens = $app->exchangeCode($_GET['code']); // Persist $tokens->accessToken, $tokens->refreshToken and $tokens->expiresIn. // Step 3: use the access token as any other Pennylane client. $client = new Pennylane(apiToken: $tokens->accessToken); // Step 4: refresh before expiry, and persist the new pair immediately. $refreshed = $app->refresh($tokens->refreshToken); ``` --- # Pagination List methods return a page object rather than a plain array. Both page types implement `\Countable` and `\IteratorAggregate`, so a `foreach` over the result walks every page automatically, fetching further pages lazily under the client throttle. ## CursorPage `Pennylane\Sdk\Pagination\CursorPage` (generic over the item type `T`). Used by every list endpoint of the Company API, and most of the Firm API. The API cursor does not encode filter or sort parameters, so `nextPage()` re-sends the original parameters together with the new cursor. ### Public surface | Member | Type | Description | |---|---|---| | `items` | `list` | Items on this page only. | | `hasMore` | `bool` | Whether a further page exists. | | `nextCursor` | `?string` | Cursor to request the next page, or `null` on the last page. | | `included` | `?list` | Side loaded records from the experimental `include` parameter, when the response carries an `included` key. `null` otherwise. | | `nextPage()` | `?CursorPage` | Fetches the next page, or `null` on the last page (when `hasMore` is `false` or `nextCursor` is `null`). | | `iterPages()` | `\Generator>` | Iterates over the pages themselves, starting with this one. | | `getIterator()` | `\Generator` | Iterates over the items of every page, fetching pages lazily. This is what a `foreach` on the page calls. | | `count()` | `int` | Number of items on THIS page only, not the total across all pages. | ### Iteration semantics ```php $page = $client->customerInvoices->list(); // Walks every page: fetches page 2, 3, ... lazily as the loop reaches the end of each page. foreach ($page as $invoice) { echo $invoice->id . "\n"; } // A second foreach on the SAME $page object walks the items again from the // first page: getIterator() is a fresh generator each time it is called, and // $page->items always holds the first page's items, so nothing is skipped or // re-fetched from the network for that first page. Further pages ARE // re-fetched over the network on each new iteration, since pages are not cached. foreach ($page as $invoice) { echo $invoice->id . "\n"; } ``` For page level control, use `items`, `nextPage()` or `iterPages()` instead of the flattening `foreach`: ```php $page = $client->customerInvoices->list(); foreach ($page->iterPages() as $onePage) { echo 'Page with ' . $onePage->count() . " items, hasMore = " . var_export($onePage->hasMore, true) . "\n"; foreach ($onePage->items as $invoice) { echo $invoice->id . "\n"; } } ``` ## NumberedPage `Pennylane\Sdk\Pagination\NumberedPage` (generic over the item type `T`). Only three Firm API endpoints use this scheme: `companies`, `fiscalYears` and `trialBalance`. A response missing `total_pages` or `current_page` is treated as a single page. ### Public surface | Member | Type | Description | |---|---|---| | `items` | `list` | Items on this page only. | | `totalPages` | `?int` | Total number of pages, or `null` if the response omitted it. | | `currentPage` | `?int` | Current page number, or `null` if the response omitted it. | | `hasMore()` | `bool` | `true` when both `totalPages` and `currentPage` are known and `currentPage < totalPages`. | | `nextPage()` | `?NumberedPage` | Fetches the next page, or `null` when `hasMore()` is `false`. | | `iterPages()` | `\Generator>` | Iterates over the pages themselves, starting with this one. | | `getIterator()` | `\Generator` | Iterates over the items of every page, fetching pages lazily. | | `count()` | `int` | Number of items on THIS page only. | Note the asymmetry with `CursorPage`: `hasMore` is a property there, `hasMore()` is a method here, because it is derived from `totalPages` and `currentPage` rather than read directly off the response. ```php $firm = new Pennylane\Sdk\PennylaneFirm(); $page = $firm->companies->list(); foreach ($page as $company) { echo $company->id . "\n"; } ``` --- # Webhook helpers `Pennylane\Sdk\Webhooks\Webhooks` verifies webhook delivery signatures and parses payloads. Pennylane webhooks are in beta: they POST a JSON payload to your HTTPS endpoint, and the response to the subscription creation call carries a secret returned only once, used to sign deliveries with an HMAC. Pennylane documents the HMAC secret but not the exact signature header name or encoding, so verification is deliberately tolerant: it accepts hex in both cases and base64, with or without a `sha256=` or `hmac-sha256=` prefix. Every comparison is constant time. Check your webhook deliveries to find the signature header, and keep the changelog endpoints as a fallback while the feature is in beta. ## Webhooks Both methods are static; the class cannot be instantiated. ### verifySignature() ```php public static function verifySignature( string $payload, string $signature, string $secret, ): bool ``` | Parameter | Description | |---|---| | `$payload` | The RAW request body, exactly as received. Re-serializing the JSON would change the bytes and the HMAC. | | `$signature` | Value of the signature header of the delivery. | | `$secret` | The subscription secret returned at creation time. `#[\SensitiveParameter]`. | Returns `false` (rather than throwing) when `$signature` or `$secret` is empty, or when none of the accepted encodings match. ### parseEvent() ```php public static function parseEvent( string $payload, ?string $signature = null, ?string $secret = null, ): WebhookEvent ``` Parses a webhook delivery, optionally verifying its signature first. When `$secret` is given, `$signature` must also be given and must verify, or `WebhookSignatureException` is thrown. `$secret` is marked `#[\SensitiveParameter]`. Throws `PennylaneException` (not a more specific subclass) when the payload is not valid JSON, or is valid JSON but not a JSON object. ## WebhookEvent `Pennylane\Sdk\Webhooks\WebhookEvent` extends `PennylaneObject`. Unknown fields are preserved: inspect `toArray()` for event specific data. Known event types include `customer_invoice.e_invoicing_status_updated` and `dms_file.created`. | Property | Type | Description | |---|---|---| | `event` | `?string` | Event type identifier. | | `data` | `?array` | Event specific payload data. | ## Example ```php event === 'customer_invoice.e_invoicing_status_updated') { // handle $event->data } http_response_code(200); ```