codeboys / invoice365-php
Official PHP SDK for the invoice365 certified Portuguese invoicing REST API by Codeboys.
Requires
- php: ^8.3
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
- ramsey/uuid: ^4.7
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^4.0
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client, if your application does not already provide one.
- illuminate/support: Enables the optional Laravel service provider and Invoice365 facade (auto-discovered).
- symfony/http-client: An alternative PSR-18 HTTP client.
README
Official PHP client for the invoice365 REST API — issue certified Portuguese fiscal documents (faturas, faturas-recibo) under your own NIF, fully compliant with the AT (Autoridade Tributária). invoice365 is built by Codeboys, an AT-certified software producer (Declaração Modelo 24).
The SDK wraps the /api/v1 HTTP contract so you never hand-roll Bearer auth, Idempotency-Key
handling, the error envelope, or AT-communication polling.
- Framework-agnostic — PSR-18 / PSR-17 via
php-http/discovery; no hard HTTP-client dependency, so it slots into Laravel, Symfony, WooCommerce or plain PHP. - Typed everything — readonly DTOs, backed enums, and a typed exception per API error code.
- Safe issuance — auto-generated idempotency keys, reused across automatic retries, so a network blip never issues a duplicate document.
- No webhooks? No problem — built-in polling helpers for the asynchronous AT communication.
- Optional, auto-discovered Laravel service provider + facade.
Requirements
- PHP 8.3+
- A PSR-18 HTTP client and PSR-17 factories. If your app doesn't already ship one,
install Guzzle:
composer require guzzlehttp/guzzle.
Installation
composer require codeboys/invoice365-php
Quick start
use Codeboys\Invoice365\Invoice365Client;
use Codeboys\Invoice365\Enums\DocumentType;
use Codeboys\Invoice365\Enums\TaxCode;
use Codeboys\Invoice365\Requests\CreateSeriesRequest;
use Codeboys\Invoice365\Requests\IssueDocumentRequest;
use Codeboys\Invoice365\Requests\IssueDocumentLine;
$client = Invoice365Client::make('ik_live_…');
// 1. Confirm the key and discover its abilities.
$ping = $client->ping();
echo $ping->account->name; // "HELY Portugal"
// 2. Create a series and wait until the AT registers it.
$series = $client->series->create(new CreateSeriesRequest(
issuerId: 1,
seriesCode: 'FT2025',
documentType: DocumentType::Fatura,
year: 2025,
));
$series = $client->series->waitForRegistration($series->id);
// 3. Issue a certified document.
$document = $client->documents->issue(new IssueDocumentRequest(
issuerId: 1,
seriesId: $series->id,
documentType: DocumentType::Fatura,
documentDate: new DateTimeImmutable('today'),
lines: [
new IssueDocumentLine(
description: 'Serviço de consultoria — junho 2025',
quantity: '1', // string preserves decimal precision
unitPriceCents: 150000, // €1.500,00
taxCode: TaxCode::Normal, // 23 %
),
],
customerNif: '509123456',
customerName: 'Empresa Exemplo, Lda.',
customerCountry: 'PT',
));
echo $document->atcud; // "XKAB1234-487"
echo $document->totals->grossCents; // 184500
Base URL. Defaults to production (
https://invoice365.pt). For local development point athttp://localhost:8091:Invoice365Client::make('ik_live_…', 'http://localhost:8091')orInvoice365Client::sandbox('ik_live_…').
Idempotency
documents->issue() always sends an Idempotency-Key. If you don't pass one, a UUID is generated for
you. The same key is reused across automatic retries, so a dropped connection or a 5xx never issues
a second fiscal number — the server replays the original document instead.
Pass your own key (e.g. your order id) to make issuance idempotent across processes:
$document = $client->documents->issue($request, idempotencyKey: 'order-2025-00487');
Reusing the same key with a different body raises IdempotencyConflictException (HTTP 409).
Asynchronous AT communication (polling)
After issuance, the document's communication status starts as pending and is updated asynchronously
by invoice365. There are no webhooks, so poll for the terminal state:
use Codeboys\Invoice365\Enums\AtCommunicationStatus;
$document = $client->documents->waitForCommunication($document->id, timeout: 60.0, interval: 2.0);
if ($document->communication->status === AtCommunicationStatus::Acknowledged) {
// The AT accepted the document.
}
The wait resolves on a terminal status (acknowledged or failed); sent is still in flight. If the
deadline passes first, a TimeoutException is thrown. Series registration has the same shape:
$client->series->waitForRegistration($id).
Listing and pagination
use Codeboys\Invoice365\Requests\DocumentListFilters;
use Codeboys\Invoice365\Enums\DocumentStatus;
// One page (page size is fixed at 25 server-side).
$page = $client->documents->list(new DocumentListFilters(
seriesId: $series->id,
status: DocumentStatus::Issued,
));
foreach ($page->data as $document) { /* … */ }
// Or iterate every page lazily.
foreach ($client->documents->autoPaging() as $document) {
echo $document->documentNumber;
}
$bytes = $client->documents->pdf($document->id); // raw PDF bytes
$client->documents->savePdf($document->id, '/tmp/FT-2025-487.pdf');
Catalog, taxes, SAF-T & metadata
Bill from a reusable catalog item instead of inlining every line — the server fills the description, price and tax code from the item:
use Codeboys\Invoice365\Requests\CreateItemRequest;
use Codeboys\Invoice365\Requests\IssueDocumentLine;
use Codeboys\Invoice365\Enums\ProductType;
use Codeboys\Invoice365\Enums\TaxCode;
$item = $client->items->create(new CreateItemRequest(
issuerId: 1,
code: 'CONSULT',
name: 'Consultoria',
unitPriceCents: 60000,
taxCode: TaxCode::Normal,
productType: ProductType::Service,
));
$document = $client->documents->issue(new IssueDocumentRequest(
issuerId: 1,
seriesId: $series->id,
documentType: DocumentType::Fatura,
documentDate: new DateTimeImmutable('today'),
lines: [IssueDocumentLine::fromItem($item->id, quantity: '2')],
));
Discover the valid tax codes/rates for an issuer, request and download a SAF-T PT export, and fetch the enum reference catalog:
$taxes = $client->taxes->listForIssuer(1); // list<Tax> (not paginated)
use Codeboys\Invoice365\Requests\GenerateSaftRequest;
$export = $client->saft->generate(1, new GenerateSaftRequest(year: 2026, month: 6));
if ($export->isReady()) {
$client->saft->save($export->id, '/tmp/SAFT-2026-06.xml');
}
$meta = $client->meta(); // document types, tax codes/regions, abilities…
More operations
Every REST resource is covered:
- Documents —
issue,get,list/autoPaging,void,retry,pdf/savePdf,communications,waitForCommunication. - Series —
create,get,list/autoPaging,register,void,communications,waitForRegistration. - Customers —
create,get,update,delete,list/autoPaging. - Items (catalog) —
create,get,update,delete,list/autoPaging. - Issuers —
create,get,update,list/autoPaging,verify. - Credentials (WFA) —
listForIssuer,create,update,setDefault,delete. - Taxes —
listForIssuer. SAF-T —generate,list/autoPaging,download/save. - API keys —
create(one-time plaintext token),list/autoPaging,revoke. - Top-level —
ping(),meta().
Environments (test / live)
invoice365 keys are environment-scoped: an ik_test_… key only acts on sandbox series and documents,
an ik_live_… key only on production. Targeting a series in the other environment raises
EnvironmentMismatchException (422). The environment travels on every issued document
($document->environment).
Error handling
Every API error maps to a typed exception. Catch a specific one, or the base ApiException:
use Codeboys\Invoice365\Exceptions\ApiException;
use Codeboys\Invoice365\Exceptions\ValidationException;
use Codeboys\Invoice365\Exceptions\RateLimitedException;
try {
$client->documents->issue($request);
} catch (ValidationException $e) {
$e->errors(); // ['lines' => ['The lines field is required.'], …]
} catch (RateLimitedException $e) {
sleep($e->retryAfter ?? 1);
} catch (ApiException $e) {
$e->errorCode; // stable machine code, e.g. "series_not_registered"
$e->httpStatus; // 422
$e->requestId; // for support
}
| Exception | Code | HTTP |
|---|---|---|
UnauthorizedException | unauthorized | 401 |
ForbiddenException | forbidden | 403 |
NotFoundException | not_found | 404 |
ValidationException | validation_failed | 422 |
IdempotencyKeyRequiredException | idempotency_key_required | 422 |
IdempotencyConflictException | idempotency_conflict | 409 |
SeriesNotRegisteredException | series_not_registered | 422 |
SeriesClosedException | series_closed | 422 |
TaxCodeUnknownException | tax_code_unknown | 422 |
NifInvalidException | nif_invalid | 422 |
EnvironmentMismatchException | environment_mismatch | 422 |
ItemUnknownException | item_unknown | 422 |
DocumentTypeNotSupportedException | document_type_not_supported | 422 |
ConflictException | series_already_registered, series_already_annulled, series_has_documents, document_not_retryable, customer_has_documents, issuer_identity_conflict, item_identity_conflict, … | 409 |
RateLimitedException | rate_limited | 429 |
Connection-level failures (no HTTP response) throw TransportException; bad configuration throws
ConfigurationException.
Configuration & retries
use Codeboys\Invoice365\ClientConfig;
use Codeboys\Invoice365\Http\RetryPolicy;
$client = new Invoice365Client(new ClientConfig(
apiKey: 'ik_live_…',
baseUrl: ClientConfig::PRODUCTION_BASE_URL,
timeout: 30.0,
retryPolicy: new RetryPolicy(maxRetries: 3),
logger: $psrLogger, // optional PSR-3 logger
httpClient: $myPsr18Client, // optional; auto-discovered when omitted
));
Only idempotent-safe calls are retried (every GET, plus documents->issue()), on connection
failures, 5xx, and 429 (honouring Retry-After). Disable with RetryPolicy::none().
Laravel
The package self-registers via Laravel package discovery. Publish the config and set your key:
php artisan vendor:publish --tag=invoice365-config
INVOICE365_KEY=ik_live_…
INVOICE365_BASE_URL=https://invoice365.pt
use Codeboys\Invoice365\Invoice365Client;
$document = app(Invoice365Client::class)->documents->issue($request);
// or via the facade
Invoice365::ping();
Examples
A runnable end-to-end script (ping → series → customer → issue → poll → PDF) lives in
examples/end_to_end.php. Point it at a local invoice365 instance:
INVOICE365_KEY=ik_live_… INVOICE365_BASE_URL=http://localhost:8091 php examples/end_to_end.php
Contributing
See CONTRIBUTING.md. Run the checks with composer ci.
License
MIT — see LICENSE.