facta-dte / api
PHP client for the Facta DTE API (El Salvador electronic invoicing). No required dependencies.
Requires
- php: >=7.4
Requires (Dev)
- dealerdirect/phpcodesniffer-composer-installer: ^1.0
- nyholm/psr7: ^1.8
- phpcompatibility/php-compatibility: ^10.0@alpha
- phpstan/phpstan: ^1.12 || ^2.0
- phpunit/phpunit: ^9.6
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- squizlabs/php_codesniffer: ^3.10
Suggests
- ext-curl: Preferred HTTP transport; without it the SDK falls back to PHP streams.
- ext-openssl: Needed for HTTPS without ext-curl, and for decrypting the local vault in later releases.
- psr/http-client: To inject your own PSR-18 client (with a PSR-17 factory) through Psr18Transport.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 22:26:34 UTC
README
PHP client for the Facta DTE API, El Salvador's electronic invoicing service. It talks to the API over HTTPS; it never signs a document and never computes tax. Signing, totals, IVA, correlatives and validation happen on the server.
Status: tier 1 (0.1.0). Everything that needs only HTTP is implemented: issuing, prepare/sign, queries, downloads, invalidation, returns, delivery, managed storage and the catalog (see Methods). What needs the unlock key or local storage is not there yet: local catalog snapshots, BYOS destinations and the restart-safe archive. Nothing is published to Packagist yet.
- Package:
facta-dte/api, namespaceFacta\Api, MIT licence. - PHP 7.4 or later, clean on 8.0 to 8.5 (no deprecation notices).
- No required dependency and no required extension beyond PHP core
(
json,hash).ext-curlis used when present; otherwise PHP streams (allow_url_fopen, plusext-opensslfor HTTPS). A PSR-18 client can be injected. - TLS verification is never disabled.
Install
With Composer
composer require facta-dte/api
require __DIR__ . '/vendor/autoload.php';
Without Composer (ZIP, FTP, cPanel)
Download facta-api-php-vX.Y.Z.zip from the GitHub Release, unzip it and upload the
facta-api-php/ folder next to your script (or anywhere PHP can read):
require_once __DIR__ . '/facta-api-php/autoload.php';
No Composer, no extension to install. If you only have Composer on another machine, run
composer require facta-dte/api there and upload the generated vendor/ folder.
Use
use Facta\Api\Facta; use Facta\Api\FactaError; $facta = new Facta([ 'apiKey' => getenv('FACTA_API_KEY'), // facta_test_... (environment 00) or facta_live_... (01) 'signKey' => getenv('FACTA_SIGN_KEY'), // factask_..., sent only on routes that sign // 'timeoutMs' => 60000, // 'maxRetries' => 3, ]); try { $result = $facta->issue( ['tipoDte' => '01', 'items' => [['descripcion' => 'Servicio', 'cantidad' => 1, 'precioUni' => 11.30]]], ['idempotencyKey' => 'order-1234'] // yours: reuse it to retry safely, even after a crash ); echo $result['estado'], ' ', $result['codigoGeneracion'], "\n"; // sellado ... } catch (FactaError $e) { echo $e->getErrorCode(), ' (HTTP ', $e->getStatus(), '): ', $e->getMessage(), "\n"; }
The server computes every amount, signs and transmits; the SDK only sends your request.
Answers come back as plain associative arrays with the API's own field names and estado
values. Requests are PHP arrays too: a list becomes a JSON array, an associative array a JSON
object, and new \stdClass() is an empty object {}.
Methods
| Method | Route | Notes |
|---|---|---|
status() |
GET /v1/status |
Health, environment, limits. |
region() / servedRegion() |
none | The pinned x-region; the region that served the last answer. No discovery call. |
diagnose($options) |
/v1/status, /v1/storage/status |
Readiness report. Checks that need local storage say "not available in this SDK version yet". |
issue($request, $options) |
POST /v1/dte |
idempotencyKey, deliver. Sends X-Facta-Request-Fingerprint. |
prepare($request) / sign($prepared) |
POST /v1/dte/prepare, /sign |
Two-step issuing. documento stays the decoded object tree (see below). |
getDocumentStatus($code) |
GET /v1/dte/{code} |
Also answers for rejected documents. |
listDocuments($filters) |
GET /v1/dte |
One page; pass siguiente back as cursor. |
downloadDocument($code, $kind, $options) |
GET /v1/dte/{code}/file |
json (Archivo DTE), pdf, ticket. Exact bytes, never re-serialized. |
invalidate($code, $request) |
POST .../invalidate |
Sends the sign key. |
registerReturn($code, $request) |
POST .../return |
Sends the sign key. |
deliverEmail() / deliverWhatsApp() / getDelivery() / waitForDelivery() |
.../entrega |
Token from issue(..., ['deliver' => ...]). |
getStorageStatus() / getDocumentCopies() / retryDocumentStorage() |
/v1/storage/... |
Facta Storage coverage and repair. |
listCustomers() ... searchProducts() |
/v1/customers, /v1/products |
Only when the key's catalog is readable or plain. |
createCustomer() ... deactivateProduct() |
same | Needs catalog:write and a plain-text catalog. |
listHolding() / getContract() |
/v1/dte/holding, /v1/openapi.json |
Per-call options accepted by every method: timeoutMs, headers; the writing ones also
idempotencyKey. An unknown option throws InvalidArgumentException, so a typo is not silently
ignored.
Idempotency and retries
issue() mints one Idempotency-Key per call (or uses yours) and every retry resends the same
bytes with the same key. It also sends X-Facta-Request-Fingerprint, the SHA-256 of the RFC 8785
(JCS) form of your request before any catalog id is resolved, so a retry still matches after a
catalog edit. Only POST /v1/dte and POST /v1/dte/prepare carry it.
prepare / sign
prepare() returns the plain members of the answer as an array, but documento is the decoded
\stdClass tree, on purpose: an empty object {} inside it must not turn into [] when it goes
back to sign(). Pass the array to sign() unchanged. If you store it between the two calls, use
json_encode and json_decode($text) (objects), never json_decode($text, true).
Catalog references
customerId / productId in a request are resolved by the server when the key's catalog is
readable or plain. For an encrypted catalog the TypeScript SDK resolves them from a local
snapshot opened with the unlock key; that is not in this version, so issue() and the catalog
reads throw FactaError unauthorized (details.reason) before anything is sent. Send the
customer and the items inline instead.
Options
| Option | Meaning |
|---|---|
apiKey |
Required. facta_test_... or facta_live_.... |
signKey |
factask_.... Sent only to the routes that sign (POST /v1/dte, /v1/dte/sign, .../invalidate, .../return). A factauk_ unlock key is refused here. |
unlockKey |
factauk_.... Reserved for the next tier (local catalog and destinations); never sent to the server. |
baseUrl |
API root (default: the public Facta API). |
timeoutMs |
Budget of one attempt, default 60000. |
maxRetries |
Extra attempts for retryable errors, default 3. |
region |
Functions region sent as x-region; false sends none. Defaults to FACTA_API_REGION, then the built-in region. |
transport |
A Facta\Api\Http\Transport, e.g. new Psr18Transport($client, $requestFactory, $streamFactory). |
caBundle |
Path to a CA bundle, for servers with an outdated certificate store. |
Errors and retries
Every failure is a Facta\Api\FactaError. Branch on getErrorCode() (the strings are the
same as in the TypeScript SDK: mh_rejected, validation_failed, idempotency_key_reuse,
network_error, ...), never on the message. The client retries exactly the
codes the TypeScript SDK retries: idempotency_in_flight, mh_unreachable, service_unavailable,
correlative_unavailable, storage_unavailable and network_error (a connection failure or
timeout), up to maxRetries extra attempts, waiting for Retry-After when the server sends it.
Every attempt resends the same bytes with the same Idempotency-Key, so a retry cannot issue twice.
When the attempts run out the last error is thrown; any other code is final.
Secrets (API key, sign key, unlock key) are removed from messages and details.
Old servers and TLS
If HTTPS fails because the server's certificate store is outdated, pass the path of a current
CA bundle (for example the one published by curl) in caBundle. Verification is never turned off.
Fingerprint and JSON helpers
use Facta\Api\Json\Fingerprint; // fingerprint-v1: lowercase hex SHA-256 of the RFC 8785 (JCS) form of the request. $fingerprint = Fingerprint::v1($jsonRequestText);
Facta\Api\Json\Jcs is a dependency-free RFC 8785 implementation (numbers follow ECMAScript
Number::toString, independent of serialize_precision; keys sort by UTF-16 code units).
It is validated against the conformance vectors shared with the TypeScript SDK.
Live test against staging
.github/workflows/sdk-live-integration.yml ("PHP SDK live integration (staging API, test
environment)") runs php scripts/live-integration.php against the staging API in
environment 00 on every pull request that is ready for review, and by hand
(workflow_dispatch: emit-test-fe, or emit-enabled-dte-fixtures). It issues one FE of
$0.01 (more with the fixture matrix), replays it, queries and lists it, downloads the Archivo DTE,
PDF and ticket, checks the managed copies byte for byte and, if an inbox is configured, the
e-mail delivery. The result is a sticky comment on the pull request ("PHP SDK staging live
test"). Steps that need local storage are listed as "Not in this SDK version".
Configuration, in the repository's staging-live-test environment (or as organization
secrets with the same names):
| Secret | Meaning |
|---|---|
STAGING_FACTA_API_KEY |
A facta_test_... key of the CI company. |
STAGING_FACTA_SIGN_KEY |
Its factask_... signing key. |
STAGING_FACTA_UNLOCK_KEY |
Its factauk_... key. Validated, unused until tier 2. |
STAGING_FACTA_DTE_FIXTURES_JSON |
Optional: fixtures for 03, 05, 06, 11, 14 (same format as the TypeScript SDK). |
STAGING_FACTA_DELIVERY_EMAIL |
Optional test inbox for the e-mail delivery check. |
The API URL is fixed in the workflow. If a secret is missing the run fails with "Required integration input is unavailable: NAME"; it never passes silently. The guards (test key, exact staging URL, known mode, run younger than the 22 h idempotency window) and the report renderer are covered by offline unit tests, and the whole script is exercised against a fake API.
Development
composer install composer test # PHPUnit; deprecations, notices and warnings fail the run composer phpstan composer compat # PHPCompatibility, PHP 7.4 and later composer build-zip # dist/facta-api-php-vX.Y.Z.zip
See CLAUDE.md for the rules of this repository.
En español
Cliente PHP para la API de Facta DTE (facturación electrónica de El Salvador). No firma ni calcula impuestos: eso lo hace el servidor.
- Requisitos: PHP 7.4 o superior. No necesita extensiones especiales ni dependencias;
usa
curlsi está disponible y, si no, los flujos nativos de PHP. - Con Composer:
composer require facta-dte/apiyrequire 'vendor/autoload.php';. - Sin Composer (hosting compartido, cPanel, FTP): descargue
facta-api-php-vX.Y.Z.zipde la página de versiones, descomprímalo, suba la carpetafacta-api-php/y userequire_once 'facta-api-php/autoload.php';. - Estado: nivel 1. Todo lo que solo necesita HTTP está implementado: emitir (
issue(),prepare()/sign()), consultar, descargar el Archivo DTE y el PDF, invalidar, eventos de retorno, envío por correo y WhatsApp, almacenamiento de Facta y catálogo. Aún no están el catálogo local cifrado, los destinos propios (BYOS) ni el archivo local: necesitan la clave de desbloqueo y llegan en una versión posterior. - Prueba en vivo: el flujo de GitHub Actions
sdk-live-integration.ymlemite una factura de prueba de $0.01 en el ambiente 00 del API de staging en cada pull request listo para revisión. Usa los secretosSTAGING_FACTA_API_KEY,STAGING_FACTA_SIGN_KEYySTAGING_FACTA_UNLOCK_KEY(opcionales:STAGING_FACTA_DTE_FIXTURES_JSONySTAGING_FACTA_DELIVERY_EMAIL) del entornostaging-live-test; si falta uno, la ejecución falla en lugar de pasar en silencio. - Si el servidor tiene certificados desactualizados, indique la ruta de un paquete de
certificados en la opción
caBundle. La verificación TLS nunca se desactiva.
Licence
MIT. See LICENSE.