codeboys/invoice365-php

Official PHP SDK for the invoice365 certified Portuguese invoicing REST API by Codeboys.

v0.2.0 2026-07-20 15:26 UTC

This package is auto-updated.

Last update: 2026-07-20 15:28:07 UTC


README

Latest Version License: MIT

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 at http://localhost:8091: Invoice365Client::make('ik_live_…', 'http://localhost:8091') or Invoice365Client::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;
}

PDF

$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:

  • Documentsissue, get, list/autoPaging, void, retry, pdf/savePdf, communications, waitForCommunication.
  • Seriescreate, get, list/autoPaging, register, void, communications, waitForRegistration.
  • Customerscreate, get, update, delete, list/autoPaging.
  • Items (catalog) — create, get, update, delete, list/autoPaging.
  • Issuerscreate, get, update, list/autoPaging, verify.
  • Credentials (WFA) — listForIssuer, create, update, setDefault, delete.
  • TaxeslistForIssuer. SAF-Tgenerate, list/autoPaging, download/save.
  • API keyscreate (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
}
ExceptionCodeHTTP
UnauthorizedExceptionunauthorized401
ForbiddenExceptionforbidden403
NotFoundExceptionnot_found404
ValidationExceptionvalidation_failed422
IdempotencyKeyRequiredExceptionidempotency_key_required422
IdempotencyConflictExceptionidempotency_conflict409
SeriesNotRegisteredExceptionseries_not_registered422
SeriesClosedExceptionseries_closed422
TaxCodeUnknownExceptiontax_code_unknown422
NifInvalidExceptionnif_invalid422
EnvironmentMismatchExceptionenvironment_mismatch422
ItemUnknownExceptionitem_unknown422
DocumentTypeNotSupportedExceptiondocument_type_not_supported422
ConflictExceptionseries_already_registered, series_already_annulled, series_has_documents, document_not_retryable, customer_has_documents, issuer_identity_conflict, item_identity_conflict, …409
RateLimitedExceptionrate_limited429

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.