> ## Documentation Index
> Fetch the complete documentation index at: https://docs.timbrix.mx/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK de PHP

> Instala timbrix/sdk-php, timbra tu primer CFDI de prueba y verifica webhooks

> [!TIP]
>
> Tiempo de lectura: \~5 minutos.

`timbrix/sdk-php` es el cliente oficial de Timbrix para PHP 8.1 o superior. No depende de ningún framework (solo de [Guzzle](https://docs.guzzlephp.org)), devuelve modelos de solo lectura con autocompletado y lanza excepciones tipadas en lugar de errores genéricos de HTTP.

> [!WARNING]
>
> No uses tu API key en código que corre en el navegador: cualquiera que abra la
> página podría verla. Llama al SDK desde tu servidor.

## Instalación

```bash theme={null}
composer require timbrix/sdk-php
```

## Quickstart

Sigue el mismo flujo que en [Quickstart](https://docs.timbrix.mx/quickstart.md): regístrate en [app.timbrix.mx/register](https://app.timbrix.mx/register), copia tu **API key de sandbox** desde **Configuración → API Keys**, y crea cliente → producto → CFDI en ese orden.

```php theme={null}
use Timbrix\TimbrixClient;

$timbrix = new TimbrixClient(getenv('TIMBRIX_API_KEY') ?: ''); // API key de sandbox

$customer = $timbrix->customers->create([
    'legalName' => 'Cliente de Prueba SA de CV',
    'taxId' => 'DUM901231AB3',
    'taxSystem' => '601',
    'email' => 'facturas@cliente-prueba.mx',
    'defaultInvoiceUse' => 'G01',
    'addressStreet' => 'Blvd. Atardecer',
    'addressExterior' => '142',
    'addressNeighborhood' => 'Centro',
    'addressCity' => 'Huatabampo',
    'addressMunicipality' => 'Huatabampo',
    'addressZip' => '86500',
    'addressState' => 'Sonora',
]);

$product = $timbrix->products->create([
    'description' => 'Servicio de prueba',
    'productKey' => 84111506,
    'unitKey' => 'E48',
    'price' => 100.0,
]);

// `date` va en hora de Ciudad de México, no en UTC.
// Ver /api-reference/invoices/create#date
$date = (new DateTimeImmutable('now', new DateTimeZone('America/Mexico_City')))
    ->format('Y-m-d\TH:i:s');

$invoice = $timbrix->invoices->create([
    'type' => 'I',
    'date' => $date,
    'paymentForm' => '01',
    'paymentMethod' => 'PUE',
    'use' => 'G03',
    'customerId' => $customer->id,
    'items' => [['productId' => $product->id, 'quantity' => 1]],
    'idempotencyKey' => 'pedido-1042',
]);

echo $invoice->uuid; // folio fiscal (UUID) que asigna el PAC al timbrar

file_put_contents('factura.pdf', $timbrix->invoices->getPdf($invoice->uuid));
file_put_contents('factura.xml', $timbrix->invoices->getXml($invoice->uuid));
```

Para pasar a producción, cambia la API key de sandbox por una de producción: la URL base, los endpoints y las respuestas son idénticos en ambos entornos.

## Opciones del cliente

```php theme={null}
$timbrix = new TimbrixClient(getenv('TIMBRIX_API_KEY') ?: '', [
    'baseUrl' => 'https://api.timbrix.mx', // opcional
    'timeout' => 30,                       // segundos; 120 al timbrar
    'maxRetries' => 2,                     // reintentos automáticos
]);
```

También acepta `httpClient` (un `GuzzleHttp\ClientInterface` propio, por ejemplo con un proxy). El SDK envía el canal `sdk` en el header `X-Timbrix-Client`, que queda registrado en cada CFDI como `channel`. Una opción desconocida lanza `InvalidArgumentException`.

Cada API key pertenece a una sola organización; para emitir con otro RFC emisor, usa una API key de esa organización.

## Idempotencia y reintentos

Un CFDI duplicado es un problema fiscal real, así que el SDK es estricto con qué reintenta:

* Solo se reintenta ante errores de red, `429`, `5xx` y, con `idempotencyKey`, el `409 PAC_STAMP_PENDING`, con espera exponencial y respetando `Retry-After`. Por defecto son 2 reintentos (`maxRetries`).
* `GET` y `DELETE` se reintentan siempre.
* `invoices->create` e `invoices->registerPayment` se reintentan **solo si pasas `idempotencyKey`**. Sin ella, un fallo de red o un `5xx` llega como excepción y el SDK no repite el timbrado.
* `cancel`, `sendEmail` y los `POST`/`PUT` de clientes y productos nunca se reintentan automáticamente. `previewPdf` sí, porque no timbra nada.
* El SDK no inventa una `idempotencyKey`: usa una llave estable que tú controles, como el id de tu pedido. Con la misma llave, un reintento devuelve el CFDI ya timbrado en vez de crear otro.
* Con `idempotencyKey`, un `409` con código `PAC_STAMP_PENDING` (otra petición con la misma llave sigue en curso) también se reintenta. Con el `maxRetries` por defecto (2) el SDK espera solo unos segundos, así que un `PAC_STAMP_PENDING` que dure más llega como `ConflictException`: reintenta más tarde (por ejemplo, desde un job en cola) con la misma `idempotencyKey`.

Si la API responde `PAC_STAMP_UNCONFIRMED`, no se sabe todavía si el PAC timbró. Llega como `ConflictException` y no se reintenta: espera el webhook `invoice.stamped` o `invoice.stamping_failed`, o vuelve a intentar más tarde con la misma `idempotencyKey`.

Del mismo modo, `PAC_CANCEL_UNCONFIRMED` al cancelar significa que no se sabe si el SAT procesó la cancelación: consulta la factura con `$timbrix->invoices->get($uuid)` antes de intentarlo de nuevo.

## Manejo de errores

Toda petición fallida lanza una subclase de `Timbrix\Exceptions\TimbrixException`, nunca una excepción de Guzzle.

| Excepción | Cuándo |
| - | - |
| `ValidationException` | `400` o `422`: datos inválidos |
| `AuthenticationException` | `401`: API key inválida o revocada |
| `PaymentRequiredException` | `402` al timbrar: `SUBSCRIPTION_INACTIVE` (sin plan activo en producción; el motivo viene en `getBody()['reason']`) o `RFC_LIMIT_EXCEEDED`. Sandbox no se ve afectado |
| `PermissionException` | `403`: la key no tiene permiso |
| `NotFoundException` | `404`: el recurso no existe |
| `ConflictException` | `409`: conflicto, por ejemplo `PAC_STAMP_UNCONFIRMED` |
| `RateLimitException` | `429`: demasiadas peticiones |
| `ApiException` | Cualquier otro error HTTP (por ejemplo `5xx`) |
| `NetworkException` | No hubo respuesta: conexión, DNS o timeout |
| `SignatureVerificationException` | La firma de un webhook no es válida |

Todas exponen `getStatusCode()` (0 si no hubo respuesta), `getErrorCode()` (el código de dominio de la API), `getSuggestion()`, `getMessages()` y `getBody()`. `RateLimitException` agrega `getRetryAfter()`.

```php theme={null}
use Timbrix\Exceptions\ConflictException;
use Timbrix\Exceptions\TimbrixException;
use Timbrix\Exceptions\ValidationException;

try {
    $invoice = $timbrix->invoices->create($data);
} catch (ValidationException $e) {
    foreach ($e->getMessages() as $message) {
        error_log($message); // uno por campo inválido
    }
} catch (ConflictException $e) {
    if ($e->getErrorCode() === 'PAC_STAMP_UNCONFIRMED') {
        // Espera el webhook invoice.stamped o invoice.stamping_failed.
    }
} catch (TimbrixException $e) {
    error_log($e->getErrorCode() . ': ' . $e->getMessage());
}
```

## Verificar webhooks

Timbrix firma el body exacto de cada webhook con HMAC-SHA256 y lo manda en el header `X-Webhook-Signature` con el formato `sha256=<hex>`. `Webhook::constructEvent` verifica la firma y devuelve el evento ya decodificado. Pásale el **body crudo**, tal como llegó: si lo decodificas y lo vuelves a serializar, la firma ya no coincide.

```php theme={null}
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Timbrix\Exceptions\SignatureVerificationException;
use Timbrix\Webhook;

class TimbrixWebhookController
{
    public function handle(Request $request): Response
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(), // body crudo, sin decodificar
                (string) $request->header('X-Webhook-Signature'),
                (string) config('services.timbrix.webhook_secret'),
            );
        } catch (SignatureVerificationException $e) {
            return response('Firma inválida', 400);
        }

        // $event es el payload del evento. El tipo (por ejemplo "invoice.stamped")
        // llega solo en el header X-Webhook-Event, no dentro del payload.
        $type = $request->header('X-Webhook-Event');

        return response('ok');
    }
}
```

Excluye esta ruta de la protección CSRF (`VerifyCsrfToken` en Laravel 10 o anterior, `validateCsrfTokens(except: [...])` en Laravel 11), porque Timbrix no envía el token.

Si solo necesitas saber si la firma es válida, `Webhook::verify($payload, $signature, $secret)` devuelve un booleano y nunca lanza excepciones.

> [!WARNING]
>
> La firma no cubre el header `X-Webhook-Timestamp`, así que no sirve para
> detectar repeticiones. Deduplica las entregas por `X-Webhook-ID`: un reintento
> de la misma entrega conserva el mismo id. Guarda los ids ya procesados y
> descarta los repetidos.

Para los eventos disponibles y la forma de cada payload, ver [Create Webhook](https://docs.timbrix.mx/api-reference/webhooks/create.md).

## Laravel

El SDK funciona hoy en Laravel con el ejemplo de arriba. Un paquete dedicado para Laravel (service provider y facade) está planeado, sin fecha definida.

## Qué no incluye la v1

* **Series y webhooks:** se configuran una sola vez desde el panel. Al timbrar, eliges la serie por nombre con `series` en el payload. Ver [Series y folios](https://docs.timbrix.mx/series-y-folios.md).
* **Autenticación:** solo API key. No hay soporte de bearer token ni OAuth.
* **Administración:** organizaciones, API keys, usuarios y logs se manejan desde el panel o el CLI de Timbrix.
* **Exportaciones masivas** y reportes.

## Referencia completa

El [README del paquete](https://github.com/Timbrix/sdk-php) lista cada recurso y método. Para el detalle de cada endpoint REST subyacente, ver [API Reference](https://docs.timbrix.mx/api-reference/introduction.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.