Factuarea APIDevelopers

PHP

Install factuarea/factuarea-php with Composer, authenticate, and create your first invoice. PSR-4, Guzzle-based, PHP 8.2+.

The official PHP SDK is factuarea/factuarea-php on Packagist — PSR-4, Guzzle-based. Source: github.com/factuarea/factuarea-php. It wraps the v1 REST API with automatic retries, idempotency keys, cursor auto-pagination, a typed error hierarchy and webhook verification — covered in the SDK overview.

Install

composer require factuarea/factuarea-php

Requires PHP 8.2 or newer with the json and mbstring extensions (both bundled with standard PHP builds).

Authenticate

Pass your API key. The key prefix selects the environment — there is no separate flag: a fact_test_… key always runs against the isolated sandbox, a fact_live_… key against production.

<?php

require 'vendor/autoload.php';

use Factuarea\Sdk\Custom\FactuareaClient;

// The key prefix selects the environment:
//   fact_test_… → sandbox    fact_live_… → production
$factuarea = FactuareaClient::create(getenv('FACTUAREA_API_KEY'));

FactuareaClient::create() is the recommended entry point: it wires Bearer authentication and registers the automatic Idempotency-Key behaviour for you. For advanced configuration (custom Guzzle client, custom retry policy, staging base URL) the generated builder is still available:

use Factuarea\Sdk\Factuarea;
use Factuarea\Sdk\Models\Components\Security;

$factuarea = Factuarea::builder()
    ->setSecurity(new Security(bearerAuth: getenv('FACTUAREA_API_KEY')))
    ->setServerURL('https://api.factuarea.com/v1')
    ->build();

Server-side only. Your API key is a secret. Never ship the SDK with a live key to a public client — use it from your backend.

Quickstart

Create a customer contact and an invoice, then download its PDF using the released SDK methods below. For other operations, check your installed SDK or use the HTTP snippet in the API Reference.

The contact creation below uses HTTP with Guzzle, already required by the PHP SDK. It works without assuming that your installed PHP release has generated contact methods. The invoice SDK keeps clientId; pass the canonical contact id with an active customer role.

<?php

require 'vendor/autoload.php';

use Factuarea\Sdk\Custom\FactuareaClient;
use Factuarea\Sdk\Models\Components;
use Factuarea\Sdk\Models\Operations;
use Brick\DateTime\LocalDate;

$factuarea = FactuareaClient::create(getenv('FACTUAREA_API_KEY'));

// Company axis: every company call carries the company id (`data.scope[].id` of GET /v1/me).
$company = getenv('FACTUAREA_COMPANY_ID');

// 1. Create a customer contact.
$http = new \GuzzleHttp\Client();
$response = $http->post('https://api.factuarea.com/v1/companies/' . $company . '/contacts', [
    'headers' => [
        'Authorization' => 'Bearer ' . getenv('FACTUAREA_API_KEY'),
        'Idempotency-Key' => bin2hex(random_bytes(16)),
    ],
    'json' => [
        'name' => 'Cliente Demo SL',
        'kind' => 'company',
        'roles' => ['customer'],
        'tax_id' => 'B12345674',
    ],
]);
$contact = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR)['data'];

// 2. Create an invoice (the API computes the totals).
$created = $factuarea->invoices->publicApiV1InvoicesCreate(
    company: $company,
    body: new Components\CreateInvoiceRequest(
        clientId: $contact['id'],
        seriesId: '01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0e',
        issuedOn: LocalDate::parse('2026-06-05'),
        dueOn: LocalDate::parse('2026-07-05'),
        lines: [
            new Components\CreateInvoiceRequestLine(
                description: 'Consultoría — junio 2026',
                quantity: 10,
                unitPrice: 100,
                taxRateId: '01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0f',
            ),
        ],
    ),
);

// A new invoice comes in the 201 body; a 200 replays one that already exists.
$invoice = ($created->twoHundredAndOneApplicationJsonObject ?? $created->twoHundredApplicationJsonObject)->data;

// 3. Download the PDF. A 202 (still being generated) raises APIException:
//    see "Conditional requests, null fields and PDF statuses" below.
$pdf = $factuarea->invoices->publicApiV1InvoicesPdf(
    new Operations\PublicApiV1InvoicesPdfRequest(company: $company, invoice: $invoice->id),
);
file_put_contents('invoice.pdf', $pdf->bytes ?? '');

Run everything with a fact_test_ key first — sandbox effects (VeriFactu → AEAT, FACe, email, webhooks) are switched off. When your flow works end-to-end, swap the prefix to fact_live_. See Test mode & sandbox.

Account model

The account axis (/v1/accounts/{account}/…) is covered by $factuarea->companies (the NIF portfolio), $factuarea->account->nifTransfers, $factuarea->managedAccounts (with ->imports) and, for webhooks and request logs, $factuarea->account->webhooks and $factuarea->account->requestLogs. They arrive with 0.5.0, the target version of the account model; it is not published yet.

Account-scoped methods take the account id as the account parameter. The example lists the NIF of the account, transfers one NIF to another account, applies a bulk import of managed accounts with Components\AccountsFile (accountsFile is required, customersFile is optional) and creates an account webhook. Methods that write irreversibly require an idempotencyKey:

<?php

require 'vendor/autoload.php';

use Factuarea\Sdk\Custom\FactuareaClient;
use Factuarea\Sdk\Models\Components;
use Factuarea\Sdk\Models\Operations;

$factuarea = FactuareaClient::create(getenv('FACTUAREA_API_KEY'));

// The account id is `data->account->id` of `publicApiV1AccountShow()` (GET /v1/me).
$account = $factuarea->account->publicApiV1AccountShow()->object->data->account->id;

// 1. List the NIF of the account.
$nifs = $factuarea->companies->publicApiV1CompaniesList(
    new Operations\PublicApiV1CompaniesListRequest(account: $account, limit: 50),
);
foreach ($nifs->companyList->data as $nif) {
    echo $nif->id, ' ', $nif->taxId, PHP_EOL;
}

// 2. Hand one NIF over to another account (NIF transfer).
$transfer = $factuarea->account->nifTransfers->publicApiV1AccountNifTransfersCreate(
    new Components\CreateNifTransferV1Request(
        companyId: $companyId,
        recipientAccountId: $recipientAccountId,
    ),
    account: $account,
    idempotencyKey: bin2hex(random_bytes(16)),
);
// The secret travels only in this response: give it to the receiving account.
$secret = $transfer->object->data->secret;

// 3. Apply a bulk import of managed accounts (multipart/form-data).
$import = $factuarea->managedAccounts->imports->publicApiV1ManagedAccountsImportsCreate(
    new Components\ManagedAccountImportV1Request(
        accountsFile: new Components\AccountsFile(
            fileName: 'accounts.csv',
            content: file_get_contents('accounts.csv'),
        ),
        options: new Components\ManagedAccountImportV1RequestOptions(sendEmail: false),
    ),
    idempotencyKey: bin2hex(random_bytes(16)),
);

// 4. Create an account webhook (needs a fact_live_ key).
$endpoint = $factuarea->account->webhooks->publicApiV1AccountWebhooksCreate(
    new Components\CreateAccountWebhookV1Request(
        url: 'https://example.com/webhooks/factuarea',
        enabledEvents: ['account.nif_added', 'invoice.paid'],
        accountRelationship: Components\CreateAccountWebhookV1RequestAccountRelationship::All,
    ),
    account: $account,
);
// The `whsec_…` secret is returned only now: store it to verify deliveries.
$webhookSecret = $endpoint->object->data->secret;

A delivery of an account webhook is verified with WebhookVerifier, exactly like a company one; see Verifying webhooks.

Conditional requests, null fields and PDF statuses

The SDK ships hand-written helpers in Factuarea\Sdk\Custom for what the generated methods cannot express yet:

You needUse
A header the generated method has no parameter for (If-Match, If-None-Match), or a field sent as an explicit nullRequestOverrides::run()
The status, ETag, Retry-After or JSON body of a response, including a status the generated method raises as APIException (the 202 of a PDF that is still being generated)ResponseMeta::fromResponse() and ResponseMeta::fromException()
A call that must not be repeated on 429 or 5xx, such as FACe submission and cancellationoptions: NoRetry::options()
  • RequestOverrides::run($call, clear: [...], headers: [...]) applies to every request issued inside the callback, retries included, so put a single SDK call in it. clear lists the JSON paths sent as null (dot-separated for nested objects, such as identity.logo_id); nothing else is inferred. headers adds headers or replaces those with the same name. It works on clients built with FactuareaClient::create(), which wires it in.
  • ResponseMeta exposes statusCode, etag, retryAfter (seconds, from a number or an HTTP date, or null), body and json(). Read it from a typed response with ResponseMeta::fromResponse($response->rawResponse), or from the exception with ResponseMeta::fromException($e).
  • NoRetry::options() stops the generated retries on 429 and 5xx for one call. A transport error (a reset connection, a timeout) is still repeated with the same Idempotency-Key, which the API answers by replaying the first outcome.
<?php

require 'vendor/autoload.php';

use Factuarea\Sdk\Custom\FactuareaClient;
use Factuarea\Sdk\Custom\Request\NoRetry;
use Factuarea\Sdk\Custom\Request\RequestOverrides;
use Factuarea\Sdk\Custom\Response\ResponseMeta;
use Factuarea\Sdk\Models\Errors\APIException;
use Factuarea\Sdk\Models\Operations\PublicApiV1InvoicesPdfRequest;

$factuarea = FactuareaClient::create(getenv('FACTUAREA_API_KEY'));
$company = getenv('FACTUAREA_COMPANY_ID'); // `data.scope[].id` of GET /v1/me

// 1. Download the invoice PDF, or get a 304 when the copy you hold is current.
try {
    $pdf = RequestOverrides::run(
        fn () => $factuarea->invoices->publicApiV1InvoicesPdf(
            new PublicApiV1InvoicesPdfRequest(company: $company, invoice: $invoiceId),
        ),
        headers: ['If-None-Match' => $cachedEtag],
    );
    if ($pdf->statusCode === 200) {
        file_put_contents('invoice.pdf', $pdf->bytes ?? '');
        $cachedEtag = ResponseMeta::fromResponse($pdf->rawResponse)->etag;
    }
    // 304: no bytes, keep your copy.
} catch (APIException $e) {
    $meta = ResponseMeta::fromException($e);
    if ($meta->statusCode !== 202) {
        throw $e;
    }
    // 202: the PDF is still being generated. Ask again after $meta->retryAfter seconds.
}

// 2. Submit to FACe once: no automatic retry on 429 or 5xx.
$factuarea->invoices->faceSubmissions->publicApiV1InvoicesFaceSubmissionsSubmit(
    company: $company,
    invoice: $invoiceId,
    idempotencyKey: $submissionKey, // reuse it if you ever repeat this submission
    options: NoRetry::options(),
);

After a FACe failure the outcome can be uncertain: read the submissions before sending again. See How downloads answer.

PDF templates

The typed methods of the PDF template library, the previews, the PDF artifacts, the presentation of each document and the template of a series (/v1/companies/{company}/pdf-templates, /v1/companies/{company}/pdf-artifacts, /v1/companies/{company}/invoices/{invoice}/presentation…) are not generated in this SDK yet; they will arrive with the next version of the SDK. Until then, call those operations with RawRequest::send(), which goes through the same authenticated client:

  • It reuses the Bearer key, the automatic Idempotency-Key on mutations, the pinned Factuarea-Version and any active RequestOverrides.
  • The path is relative to the base URL, which already ends in /v1, so it starts with /companies/{company}/…. Each {name} placeholder takes its value from the last argument, URL-encoded.
  • It applies no retries, never throws on an HTTP error status and returns the PSR-7 response, which you read with ResponseMeta::fromResponse().
<?php

require 'vendor/autoload.php';

use Factuarea\Sdk\Custom\FactuareaClient;
use Factuarea\Sdk\Custom\Request\RawRequest;
use Factuarea\Sdk\Custom\Response\ResponseMeta;

$factuarea = FactuareaClient::create(getenv('FACTUAREA_API_KEY'));
$company = getenv('FACTUAREA_COMPANY_ID');

// 1. List the library, one page per call. A list returns `items` and `meta`.
$list = ResponseMeta::fromResponse(RawRequest::send(
    $factuarea,
    'GET',
    '/companies/{company}/pdf-templates',
    ['query' => ['page' => 1, 'per_page' => 20]],
    ['company' => $company],
));
foreach ($list->json()['items'] ?? [] as $template) {
    echo $template['id'], ' ', $template['name'], PHP_EOL;
}

// 2. Read one template and keep its ETag.
$read = ResponseMeta::fromResponse(RawRequest::send(
    $factuarea,
    'GET',
    '/companies/{company}/pdf-templates/{template}',
    [],
    ['company' => $company, 'template' => $templateId],
));

// 3. Rename it with the literal ETag as If-Match. `null` clears the description.
$update = ResponseMeta::fromResponse(RawRequest::send(
    $factuarea,
    'PATCH',
    '/companies/{company}/pdf-templates/{template}',
    [
        'headers' => ['If-Match' => $read->etag],
        'json' => ['name' => 'Factura corporativa', 'description' => null],
    ],
    ['company' => $company, 'template' => $templateId],
));
if ($update->statusCode !== 200) {
    // 412: the ETag is stale. 428: no precondition. Read it again and retry.
    throw new RuntimeException('HTTP '.$update->statusCode.': '.$update->body);
}

// 4. Download an exact PDF artifact: it answers 503 with Retry-After until it is ready.
for ($attempt = 0; $attempt < 12; $attempt++) {
    $response = RawRequest::send(
        $factuarea,
        'GET',
        '/companies/{company}/pdf-artifacts/{artifact}/download',
        ['headers' => ['Accept' => 'application/pdf']],
        ['company' => $company, 'artifact' => $artifactId],
    );
    if ($response->getStatusCode() === 200) {
        file_put_contents('invoice.pdf', (string) $response->getBody());
        break;
    }
    if ($response->getStatusCode() !== 503) {
        throw new RuntimeException('HTTP '.$response->getStatusCode());
    }
    sleep(max(1, ResponseMeta::fromResponse($response)->retryAfter ?? 1));
}

See the PDF template library, Concurrency and PDF previews and artifacts for the model.

Next steps

The runtime behaviour — retries, idempotency, cursor auto-pagination, the typed error hierarchy and webhook verification — is shared across both SDKs and documented once in the SDK overview:

Release coverage

The September 2026 baseline is 0.2.0 for both SDKs. Their main-branch specs contain 413 entries, with 39 REST endpoints still absent. A package upgrade is needed before newly generated SDK methods become available. The API Reference provides complete TypeScript fetch, PHP cURL and shell HTTP examples for the documented contract; these examples do not assume an SDK method exists.

On this page

Need a hand?Contact support