factadesarrolladores API 0.1.0-esqueleto

facta-dte/api — PHP SDK

Versión en español

A PHP client for the Facta API. It issues, queries, invalidates and downloads without you assembling headers, repeating requests by hand or writing the retry code. It never signs and never computes tax: the correlative, totals, VAT, the signature and the transmission to Hacienda stay on the server, exactly as with the TypeScript client.

This first version covers everything that needs only HTTP: issuing, prepare and sign as two steps, queries, listing, downloading the Archivo DTE and the PDF, invalidation, return events, delivery by e-mail and WhatsApp, Facta Storage and reading and writing the catalog. What needs the unlock key and local storage is listed in What this version does not include yet. The Spanish page is the fuller reference; both are checked against the same list of methods and error codes.

Requirements

  • PHP 7.4 or later, with no deprecation notices through PHP 8.5. Ordinary shared hosting is enough.
  • No required extension beyond PHP core (json and hash), and no Composer dependency.
  • HTTP: it uses ext-curl when present. Without it, it falls back to PHP streams (allow_url_fopen, plus ext-openssl for HTTPS). If you already have a PSR-18 client, hand it over with the transport option.
  • ext-openssl will also be used to open the local vault in future versions; today it is only needed for HTTPS without cURL.
  • TLS verification is never disabled. There is no option to skip it. If the server has an outdated certificate store, the way out is caBundle: the path of a current certificate bundle.
  • An API key (facta_test_… or facta_live_…) and, to issue and invalidate, the signing key (factask_…). Both are minted in the app, under Cuenta → API.

Installation

There are two ways, and they give you the same client.

With Composer (Packagist)

This is the main path. The facta-dte/api package is on Packagist:

sh
composer require facta-dte/api
phpyour script
require __DIR__ . '/vendor/autoload.php';

Without Composer: the ZIP, by FTP

For shared hosting, cPanel or any server without a shell. Download facta-api-php-vX.Y.Z.zip from the repository's releases page, unzip it and upload the facta-api-php/ folder by FTP next to your script (or anywhere PHP can read). Then:

phpyour script
require_once __DIR__ . '/facta-api-php/autoload.php';

Nothing else to install. If you only have Composer on another machine, run composer require facta-dte/api there and upload the vendor/ folder it generates.

Credentials and options

phpCreating the client
use Facta\Api\Facta;

$facta = new Facta([
    'apiKey'  => getenv('FACTA_API_KEY'),   // facta_test_… (environment 00) or facta_live_… (01)
    'signKey' => getenv('FACTA_SIGN_KEY'),  // factask_…, sent only to the routes that sign
]);
OptionWhat it does
apiKeyRequired. facta_test_… issues in tests against Hacienda's environment 00; facta_live_… issues in production. Any other prefix makes the constructor throw invalid_api_key.
signKeyfactask_…. Sent only to the routes that sign: issue, sign, invalidate and register a return. If you pass a factauk_ key by mistake, the constructor refuses it before it can travel.
unlockKeyfactauk_…. Reserved for the next stage (local catalog and own destinations). It is never sent to the server; today it only changes the message when an encrypted catalog cannot be read.
baseUrlThe API root. The public URL by default. A facta_test_ key still issues in tests on that same URL.
timeoutMsBudget of one attempt, 60000 by default. Hacienda can take about forty seconds to answer.
maxRetriesExtra attempts for the errors where retrying is safe, 3 by default.
regionThe functions region, sent as x-region; false sends none. By default the FACTA_API_REGION environment variable, else the one built into the client.
transportA Facta\Api\Http\Transport, for example new Psr18Transport($client, $requestFactory, $streamFactory).
caBundlePath to a certificate bundle, for servers with an outdated store. TLS verification is never turned off.

Every method also accepts per-call options as its last argument: timeoutMs and headers; the writing ones also idempotencyKey. An option the method does not know throws InvalidArgumentException, so a typo is not silently ignored. The same goes for an unknown constructor option.

Answers come back as associative arrays with the API's own field names (estado, codigoGeneracion, numeroControl…). Requests are arrays too: a list becomes a JSON array, an associative array a JSON object, and new \stdClass() is the empty object {}.

Your first invoice

phpissue.php
require_once __DIR__ . '/facta-api-php/autoload.php';

use Facta\Api\Facta;
use Facta\Api\FactaError;

$facta = new Facta([
    'apiKey'  => getenv('FACTA_API_KEY'),
    'signKey' => getenv('FACTA_SIGN_KEY'),
]);

try {
    $result = $facta->issue(
        [
            'tipoDte' => '01',
            'items'   => [['descripcion' => 'Service', 'cantidad' => 1, 'precioUni' => 11.30]],
        ],
        ['idempotencyKey' => 'order-00417']   // yours: the same sale, the same key
    );

    if ($result['estado'] === 'sellado') {
        echo $result['numeroControl'], ' ', $result['selloRecibido'], "\n";
    } else {
        echo "Contingency: accepted, Hacienda's seal is still missing.\n";
    }
} catch (FactaError $e) {
    echo $e->getErrorCode(), ' (HTTP ', $e->getStatus(), '): ', $e->getMessage(), "\n";
    if ($e->isRejection()) {
        print_r($e->mhObservations());
    }
}

The idempotencyKey belongs to your system (the order number, the till ticket): if the process dies in the middle of the call and you repeat it, the server recognises the same operation and does not issue twice. Read estado before reading the seal, because a contingency document does not have one yet. And decide on the failure by its code, not by its message.

Client methods

Each method says which key scope it requires, what it returns and which errors are its own. The errors common to all of them (key, scope, limits, network) are under FactaError. A method that spends a correlative carries the client's Idempotency-Key; see Idempotency and retries.

Configuration and status

environment()

Makes no request. Returns "00" for a facta_test_… key and "01" for a facta_live_… key.

region()

Makes no request. Returns the region sent as x-region, or null if you disabled it with region => false. Unlike the TypeScript client it does not ask /v1/status to discover it: a PHP process lives for one web request, and a discovery call per request would cost more than it saves.

servedRegion()

Returns the region that served the latest answer (the x-sb-edge-region header), or null before the first answer or when the header is absent.

status($options = [])

GET /v1/status. No scope, no signing key. Confirms the key works and returns, as an array, ok, version, ambiente, emisor, llave (with alcances, tiposDte and catalogMode), firma, sincronizacion and limites. Call it first; the route has its own rate window and does not spend the quota it reports.

diagnose($options = [])

Reads /v1/status and /v1/storage/status and answers whether this key is ready. It does not open vaults, reserve a correlative or issue. Options: expectedEnvironment ("00" or "01"), requiredScopes (a list) and dteType (for example "01"). Returns overall, canIssue, canQuery, canDownload, canIssueAndArchive, the checks list (each with id, state and message), storageReady, catalogMode, region and servedRegion. If the status cannot be read it does not throw: it returns a blocked report carrying the error's safe code.

canIssueAndArchive is always false in this version because the local archive does not exist yet, and the checks that would depend on local storage are reported as unknown with that reason.

Issuing

issue($request, $options = [])

POST /v1/dte. Requires the issue scope and sends the signing key. Prepares, signs and transmits in one operation; the normal path. $request carries tipoDte, the receptor and the items, with the API's field names; the server computes everything. It returns the sealed document (estado "sellado", with selloRecibido, totales and archivoDte) or, if Hacienda did not answer, a contingency document (estado "contingencia", no seal yet) — a 202 is not an error. The answer also carries codigoGeneracion, numeroControl, representacionGrafica (the PDF in base64), entrega, storage and advertencias when present.

Per-call options: idempotencyKey (yours; otherwise the client mints one per call and reuses it on every retry), deliver (see Delivery), timeoutMs and headers.

Typical own errors: mh_rejected (Hacienda read and refused it; it spent a correlative), validation_failed (it did not), amount_limit, sign_key_required, sign_key_invalid, idempotency_key_reuse.

prepare($request, $options = [])

POST /v1/dte/prepare. Requires issue; it does not open the signing vault, so it does not send the signing key. Reserves the correlative and returns the canonical unsigned document with its prepareToken. Use it when you need to show or review the document before signing. documento stays the decoded \stdClass tree on purpose, so an empty object {} inside it is not turned into [] on the way back. Treat it as opaque and pass it to sign() untouched; if you store it in between, use json_encode and json_decode($text) (objects), never json_decode($text, true).

sign($prepared, $options = [])

POST /v1/dte/sign. Requires issue and sends the signing key. Signs and transmits what prepare() returned. Pass the result unchanged: the server checks a MAC over the canonical document, so one altered cent is refused (prepare_token_invalid) instead of signed. A result without its prepareToken or documento throws InvalidArgumentException before anything is sent. Returns the same shape as issue().

Delivery by e-mail and WhatsApp

Issuing never waits for delivery; they are separate requests. issue() only marks channels, through the deliver option:

php
$result = $facta->issue($sale, [
    'idempotencyKey' => 'order-00417',
    'deliver' => [
        'email'    => true,   // or an address; true uses receptor.correo
        'whatsapp' => ['number' => '+50370001234', 'consent' => true],
    ],
]);
$token = $result['entrega']['token'];   // valid five minutes: a secret, keep it on your server

consent => true attests that the receiver agreed to WhatsApp delivery; the client makes you write it out. A sealed answer carries entrega with a token valid for five minutes and the initial state of each channel. A contingency document gets no token.

deliverEmail($generationCode, $token, $options = [])

POST /v1/dte/{codigoGeneracion}/entrega/correo. Requires issue (or entrega:correo) and the token the issue returned. Answers the channel's final state or en_proceso; a second call returns the current state without a second message. A channel that cannot be delivered is a state (fallido, sin_consentimiento…), not an exception. An expired token is entrega_vencida, a wrong one entrega_token_invalido; the client strips the token from the message and details if the server echoed it.

deliverWhatsApp($generationCode, $token, $options = [])

POST /v1/dte/{codigoGeneracion}/entrega/whatsapp. Requires issue (or entrega:whatsapp). Same as e-mail; it is charged to the company's prepaid wallet (sin_credito with no balance).

getDelivery($generationCode, $options = [])

GET /v1/dte/{codigoGeneracion}/entrega. Requires query (or either delivery scope). Reads each channel's state under canales; it keeps working after the token expired.

waitForDelivery($generationCode, $options = [])

Polls getDelivery() until every awaited channel is in a final state (anything but pendiente or en_proceso). Options: channels (by default those the server reports), timeoutMs (60000) and intervalMs (2000). On timeout it does not throw: it returns the last read with settled => false, because delivery never changes the fiscal result.

Querying and listing

getDocumentStatus($generationCode, $options = [])

GET /v1/dte/{codigoGeneracion}. Requires query. It also answers for rejected documents. Returns the estado (sellado, firmado, rechazado, contingencia, invalidado, reservado, liberado, descartado), numeroControl, tipoDte, ambiente, fecEmi, selloRecibido, totales and, when present, retornos and disponible. A document of another company answers not_found, exactly like one that does not exist.

listDocuments($filters = [], $options = [])

GET /v1/dte. Requires query (and download if you ask for include). One page at a time. Filters: desde, hasta, estado, tipoDte, limit, cursor and include => ['dte'], which adds the Archivo DTE to every row. Returns documentos and siguiente; pass siguiente back as cursor for the next page, until it is null. An unknown filter throws InvalidArgumentException. With include a page holds at most 25; above that the server answers include_limit_exceeded.

downloadDocument($generationCode, $kind = 'json', $options = [])

GET /v1/dte/{codigoGeneracion}/file. Requires download. Returns the exact bytes the server sent, never re-serialized. kind is "json" (the Archivo DTE the receiver gets, the default), "pdf" or "ticket" (a roll receipt drawn on the server). The result is an array with codigoGeneracion, kind, bytes, contentType and filename and, when the server says so, storageSource (managed, holding or archive), jsonFormat (archivo-dte or raw) and paperWidthMm.

Options: raw => true (json only: the stored original, needed for a contingency document, which has no Archivo DTE yet and otherwise answers not_sealed); source => 'managed' (json and pdf only: require the Facta Storage copy, with no fallback to temporary retention); paperWidthMm (ticket only, 40 to 120, 80 by default). An invalid combination throws InvalidArgumentException before anything is sent. The PDF is drawn by the server with the company's template; this client draws none.

Invalidating and returning

invalidate($generationCode, $request, $options = [])

POST /v1/dte/{codigoGeneracion}/invalidate. Requires issue and sends the signing key. Invalidates a sealed document; it is irreversible. $request carries tipoAnulacion (1, 2 or 3), motivo, codigoGeneracionReemplazo where it applies, and responsable and solicita, each with nombre, tipoDocumento and numDocumento. Returns estado "invalidado" and the event, or yaEstabaInvalidado => true. A document with return events answers has_return_events.

registerReturn($generationCode, $request, $options = [])

POST /v1/dte/{codigoGeneracion}/return. Requires issue and sends the signing key. Registers a return event over an invoice, export invoice or excluded-subject document this API issued (guide). $request carries items — each with its linea (counted from 1) and exactly one of cantidad or noGravado — and, optionally, fechaEvento (YYYY-MM-DD). The client checks those rules locally and throws InvalidArgumentException if the server would refuse them anyway. Irreversible once sealed.

A 202 with estado "firmado" means Hacienda did not answer: repeat with the same idempotencyKey and the same body to resend the same signed event. Refusals are return_exceeds_available, return_window_closed and return_type_not_allowed.

Facta Storage

These three methods concern the storage managed by Facta (JSON and PDF), not your own destinations.

getStorageStatus($options = [])

GET /v1/storage/status. Requires download. Returns capabilityVersion, managed (coverage, capacity and readiness), byos (whether a verified own destination is ready) and the supportedKinds and unsupportedKinds lists. No fiscal work. If the server does not publish the route it throws storage_unsupported; if the answer breaks the contract, storage_contract_invalid.

getDocumentCopies($options = [])

GET /v1/storage/copies. Requires download. Lists the receipts of the managed JSON and PDF copies for this company and environment, pending and failed ones included, with state, bytes and sha256. The generationCode option limits it to one document. No document content and no storage paths. A receipt for another environment or another document is treated as storage_contract_invalid.

retryDocumentStorage($generationCode, $options = [])

POST /v1/storage/copies/{codigoGeneracion}/repair. Requires download and issue. Repairs the copies of an already sealed document: it does not sign, reissue or spend a correlative. Returns the storage receipt (destination, environment, json, pdf, operationId). If the original bytes no longer exist it fails explicitly rather than inventing them.

Fiscal success and copy success are independent. If the receipt attached to an issue breaks the contract, the client drops it and leaves storageErrorCode: "storage_contract_invalid" in the result, keeping the sealed document.

Retention and contract

listHolding($limit = 50)

GET /v1/dte/holding. Requires download. Inspects the holding area — documents the server keeps for a while when no other durable copy exists. It downloads no bytes. Returns documentos.

getContract()

GET /v1/openapi.json. No scope. The OpenAPI contract the server publishes, as an array.

Customer and product catalog

The catalog is read and written through the API, and what works depends on how the company stores it. A key with catalog:read reads a plain or readable catalog; writing needs catalog:write and the owner having allowed catalog administration from the API (which stores it unencrypted at Facta). Records come back with both spellings of each field, the API's and the stored one (nombre and name, for example). With an encrypted catalog these reads throw unauthorized (details.reason), because reading it would need the local snapshot this version does not include.

MethodWhat it does
listCustomers($options)Lists active customers; with includeInactive => true, deactivated ones too. catalog:read.
getCustomer($customerId, $options)One customer by its stable id, or null. A deactivated customer is also null unless includeInactive.
searchCustomers($query, $options)Searches name, document number, NRC and e-mail. Options: limit (1 to 500, 50 by default) and includeInactive.
listProducts($options)Lists active products. catalog:read.
getProduct($productId, $options)One product by its stable id, or null.
searchProducts($query, $options)Searches descriptions, codes and barcodes.
createCustomer($input, $options)Creates a customer. nombre is required. catalog:write. With idempotencyKey, a retried create is safe.
updateCustomer($customerId, $changes, $options)Changes only the fields you pass. To reactivate, ['activo' => true].
deactivateCustomer($customerId, $options)Deactivates (DELETE): it stops being listed and issued documents keep it. There is no hard delete.
createProduct($input, $options)Creates a product.
updateProduct($productId, $changes, $options)Changes only the fields you pass.
deactivateProduct($productId, $options)Deactivates a product.

A duplicate document number on a customer, or a duplicate code on a product, answers catalog_duplicate; details.id says which one and details.activo whether it is deactivated.

Idempotency and retries

Any method that can spend a correlative needs a stable Idempotency-Key. Pass your own (idempotencyKey) and keep it with the order: that makes the operation idempotent even across a process restart, not just between retries of one call. If you do not, the client mints one per call, once, outside the retry loop, and resends it on every attempt. A fresh key per attempt is exactly the failure the header exists to prevent. The body is encoded once per call too, and every retry resends the same bytes.

The request fingerprint

issue() and prepare() also send X-Facta-Request-Fingerprint: the SHA-256 of the canonical form (RFC 8785) of your request as you wrote it, before the server resolves any catalog id. The server uses it to recognise a retry even if the catalog changed between the attempt and its repetition; without it that repetition would answer idempotency_key_reuse. It travels only on POST /v1/dte and POST /v1/dte/prepare, and you do not compute it.

What is retried

The client retries only where retrying is safe, up to maxRetries more times, with growing waits (250 ms, 500 ms… up to 4 s) or the server's Retry-After when it sends one, and these six codes are the only ones:

  • idempotency_in_flight: the first request with that key is still working.
  • mh_unreachable
  • service_unavailable
  • correlative_unavailable
  • storage_unavailable
  • network_error: the request got no answer (connection failure or timeout).

It is safe because every attempt resends the same bytes with the same Idempotency-Key. A 4xx and a Hacienda rejection are never retried. When the attempts run out the last error is thrown, and any code not in that list is final. A contingency 202 is not an error: it arrives as a result with estado "contingencia", explained in Contingency and uncertain outcomes (Spanish).

FactaError

Every failure is a Facta\Api\FactaError. Decide by the code, never by the message: the text is for whoever reads the log and may be rewritten. The codes are the same as the TypeScript client's.

MethodWhat it returns
getErrorCode()One of FactaError's constants (or a code a newer server added).
getStatus()The HTTP status; 0 when the request got no answer.
getDetails()What the server sent in details, secrets already removed. validation_failed carries issues; rate_limited, the window and the wait.
getRetryAfterMs()What the Retry-After header asked for, in milliseconds, or null.
isRejection()true if Hacienda read the document and refused it (mh_rejected), as opposed to "we never reached Hacienda". You fix it by changing the data, not by waiting.
spent()['codigoGeneracion' => …, 'numeroControl' => …] if the failure already spent a correlative, or null. The correction may reuse that number.
mhObservations()What Hacienda said, word for word, as a list of strings.

An error message never contains a configured key, and details omits credential-looking fields. Besides FactaError, the client throws InvalidArgumentException for misuse it can detect before sending (an unknown option, an invalid kind, a return without lines) and Facta\Api\UnsupportedEnvironmentException if that PHP has no way to make HTTP requests.

Error codes

These are the codes the client defines; the server's own are explained in full in the HTTP reference. network_error, operation_outcome_unknown and archive_integrity_error belong to the client and the server never sends them; the last two exist for compatibility with TypeScript and this version never throws them.

CodeMeaning and what to do
unauthorizedThe key is missing, or an encrypted catalog cannot be read in this version (see details.reason).
invalid_api_keyThe key does not exist, is mistyped, or does not start with facta_test_ or facta_live_.
key_revokedIt was revoked from the app; it does not come back. Mint another.
key_expiredIts expiry date passed. Mint another.
key_inactiveIt is deactivated. Reactivate it from the app.
forbidden_scopeThe key lacks the scope the method requires. details.required says which.
dte_type_not_allowedThe key does not allow that tipoDte. details.allowed lists them.
ip_not_allowedThe key has an address list and yours is not on it.
environment_not_allowedThe key's environment is not the company's.
sign_key_requiredThe factask_… signing key is missing on an operation that signs.
sign_key_invalidThat password does not open the signing vault. details says how many attempts remain.
sign_vault_lockedToo many failed attempts: the key was suspended. Reactivate it from the app.
sign_vault_missingThe key is not provisioned to sign. Mint the key again.
invalid_requestThe body or a field has the wrong shape. details.field points at it. It spends no correlative.
include_limit_exceededinclude with more than 25 rows per page. Lower limit.
validation_failedThe document does not meet the official schema. details.issues lists each field. It spends no correlative.
retention_mixed_class_unsupportedIncome-tax retention requested on a document with exempt or non-subject lines. Issue those lines in another document.
not_foundThe document, the id or the route does not exist.
method_not_allowedThe route exists, but not with that method.
not_sealedThe Archivo DTE of a document with no seal yet was requested. Wait, or download with raw => true.
catalog_write_disabledThe owner has not allowed catalog administration from the API.
catalog_encryptedThe server cannot read an encrypted catalog.
catalog_duplicateThat customer or product already exists. details.id says which.
idempotency_key_requiredThe Idempotency-Key is missing on an operation that cannot be undone.
idempotency_key_reuseThat key was already used with a different request. Use a new one, or repeat exactly the same request.
idempotency_in_flightThe first request with that key is still being processed. It is retried automatically.
prepare_token_invalidThe prepareToken expired or the document changed. Call prepare() again.
return_exceeds_availableMore was requested than remains. details.lineas says how much.
return_window_closedThe event is outside the return window.
return_type_not_allowedThat document type does not admit a return event; fix it with a credit note.
return_pdf_unavailableThe signed JSON needed to draw the return event's sheet is gone.
has_return_eventsA document that already has return events cannot be invalidated.
rate_limitedThe key reached its hourly or daily ceiling. details says how long to wait.
amount_limitThe document exceeds the key's per-document maximum. It spends no correlative.
mh_rejectedHacienda read the document and refused it. It spent a correlative; see spent().
mh_unreachableReserved; it is retried. A transport failure toward Hacienda comes out as contingency.
correlative_unavailableThe correlative could not be reserved. Retried with the same idempotency key.
service_unavailableSomething at Facta did not answer. Retried with the same key.
no_storage_destinationA reported own destination is not among the key's published ones.
storage_unsupportedThe server does not publish that managed-storage route yet.
storage_unavailableManaged storage did not answer. It is retried; do not issue again.
storage_contract_invalidThe capability or the receipt contradicts the contract. Keep the fiscal success and reconcile storage.
entrega_vencidaMore than five minutes passed: the token no longer works. Read the state with getDelivery().
entrega_token_invalidoThe token does not match this document and channel.
canal_no_marcadoThe document did not mark that channel when it was issued.
internal_errorAn unforeseen failure. If it repeats, write to support.
operation_outcome_unknownA client code defined to match TypeScript; this version does not throw it yet (it will come with operation recovery).
archive_integrity_errorA client code defined to match TypeScript; this version does not throw it, because it has no encrypted local archive.
network_errorThe request got no answer. It is retried automatically with the same key.

What this version does not include yet

This is the first stage. The functions that need the unlock key (factauk_…) and local storage are missing, and they have not been left half-done: they are simply not there.

  • The local snapshot of the encrypted catalog. With an encrypted catalog the TypeScript client decrypts customers and products on your machine; this one does not yet. Meanwhile API-readable and plain catalogs work, because the server reads them for you (customerId and productId are resolved on the server), and you can always send the full recipient and lines.
  • Your own destinations (BYOS) with the unlock key. Replicating the JSON and PDF to your buckets, and telling Facta about that copy, is not in this version.
  • The encrypted local archive and recovery after a restart. There is no equivalent of issueAndArchive() or recoverOperation(). To survive a restart, use your own idempotencyKey and store it with the order: repeating the call returns the same document.
  • Printing.

The normal path does not need them. With Facta Storage connected, the server keeps each document's JSON and PDF, and getStorageStatus(), getDocumentCopies() and retryDocumentStorage() let you check and repair that. That is the path this version covers.

What this client does not do

  1. It does not compute money. No VAT, no retentions, no totals, no control number, no fiscal dates. A client that computed money would be a second tax engine, and two engines drift apart. An architecture test in the package fails if signing primitives or fiscal field construction appear in the code.
  2. It does not sign. The certificate never passes through here. What does travel, and only on the routes that sign, is X-Facta-Sign-Key: the password that opens the signing vault on the server.
  3. It does not draw the PDF. The PDF and the ticket are drawn by the server with the company's template; downloadDocument() hands you their exact bytes.
  4. It does not disable TLS verification. Not even for an old server: that is what caBundle is for.

For direct HTTP without this library, follow the PHP examples.

Escriba para buscar Por ejemplo: idempotencia, contingencia, 422, emitir.

↑ ↓ moverse Enter abrir Esc cerrar