Search by

SDK de PHP para la API de Cord: cotizaciones, facturas, webhooks verificados, tipos de PHPStan y modo prueba. Sin dependencias.

v1.1.0 2026-11-04 13:50 UTC

This package is auto-updated.

Last update: 2026-11-04 14:13:09 UTC


README

SDK de PHP para la API de Cord. PHP 8.2+, sin dependencias: usa curl, o el cliente PSR-18 que ya tengas (Guzzle, Symfony HttpClient, el de tu framework).

composer require flouviahq/cord
use Flouvia\Cord\Cord;

$cord = new Cord(getenv('CORD_SECRET_KEY'));
$quote = $cord->quotes->create([
    'cliente' => ['empresa' => 'Acme'],
    'items' => [['descripcion' => 'Instalación', 'cantidad' => 1, 'precio_unitario' => 12500, 'tax_rate' => 0.16]],
    'base_currency' => 'MXN',
]);

foreach ($cord->invoices->listAll(['estado' => 'open']) as $invoice) {
    echo $invoice['numero'], ' ', $invoice['saldo'], ' ', $invoice['moneda'], PHP_EOL;
}

$cord->invoices->recordPayment($id, 5000, 'MXN', idempotencyKey: "pago-{$miId}");

// Un CFDI a un cliente extranjero sin mercancías: confírmalo al emitir.
$cord->invoices->finalize($facturaId, sinComercioExterior: true);

$cord->workflows->invoke('wf_…', 'invoice', [$facturaId]);
$plan = $cord->setup->propose(['sitio' => 'tunegocio.com', 'descripcion' => 'Instalaciones eléctricas, cobro a 30 días']);
  • Toda mutación lleva Idempotency-Key, la misma en cada reintento.
  • listAll() recorre por cursor: no salta ni repite registros aunque alguien escriba mientras recorres. Acepta los filtros de list() y limit como tamaño de cada página.
  • Errores CordException con status, errorCode, requestId, docUrl, headers y retryAfter. Una excepción del transporte que no sea TransportException sale tal cual y no se reintenta.
  • Manda Cord-Version con Cord::API_VERSION, la versión del contrato con que se generaron los tipos (opción api_version).

Reintentos

  • Si la respuesta trae Cord-Should-Retry, manda ese header: false no se reintenta (una cuota agotada, el tope de envíos de una factura, un rechazo del PAC al emitir) y true sí.
  • Sin él se reintenta la red caída (TransportException), 408, 425 y 5xx. Un 429 solo si es de ritmo (errorCode rate_limited) o llega sin code, como el de un proxy; uno de cuota agotada (api_quota_exceeded, ai_quota_exceeded) sale de inmediato.
  • Se espera lo que diga Retry-After (segundos o fecha HTTP) o un backoff exponencial, y todas las esperas de una llamada suman como mucho 60 s. Si la siguiente no cabe, sale la CordException, con retryAfter para que decidas tú cuándo volver.
  • 409 idempotency_in_progress (la misma clave sigue en curso) no gasta reintentos: el SDK vuelve a preguntar hasta recibir la respuesta guardada, así un finalize lento termina con la factura. 'max_retries' => 0 apaga todo reintento.
  • Nunca sigue una redirección: un 3xx es CordException con errorCode unexpected_redirect y la dirección a la que apunta. Revisa base_url (la de producción es https://cordhq.app).

Opciones por llamada, respuesta y rutas sin método

$cord = new Cord(getenv('CORD_SECRET_KEY'), ['app_info' => ['name' => 'mi-erp', 'version' => '4.2.0']]); // cord-php/1.1.0 mi-erp/4.2.0

$cord->withOptions(timeout: 5.0, maxRetries: 0)->invoices->finalize($facturaId);
echo $cord->lastResponse()?->requestId; // búscalo en Desarrolladores › Registros

$r = $cord->rawRequest('GET', '/cotizaciones', ['status' => 'sent', 'limit' => 5]);
var_dump($r->data, $r->meta, $r->requestId);
  • withOptions(timeout:, maxRetries:) devuelve un cliente con esas opciones que comparte transporte y lastResponse() con el original.
  • lastResponse() es un CordResponse (status, headers, requestId, idempotentReplayed, apiVersion) de la última llamada exitosa.
  • rawRequest($method, $path, $query, $body, $idempotencyKey) llama una ruta que el SDK aún no tiene como método, con la llave, la idempotencia, los reintentos y los errores ya puestos, y devuelve un RawResponse. La ruta es relativa a /api/v1 (/api/v1/… también vale); una URL absoluta, //host, . o .. son InvalidArgumentException: la llave nunca sale a otro host.

Recursos

Propiedad Métodos
$cord->me(), $cord->collections(), $cord->createTask([...]) GET /me, GET /cobranza, POST /tareas
$cord->quotes list, listAll, retrieve, create, update, send, resend, approve, reject, markPaid, delete, draftFromText
$cord->clients list, listAll, retrieve, create, update
$cord->products list, listAll, retrieve, create, update, delete
$cord->invoices list, listAll, retrieve, create, finalize, send, void, recordPayment, creditNote
$cord->events list, listAll
$cord->workflows invoke
$cord->setup propose, retrieve
$cord->elements config
$cord->webhookEndpoints list, create, update, rotateSecret, delete
$cord->testHelpers reset, setNextFiscalOutcome, viewQuote, expireQuote, triggerWebhook (solo sk_test_)

Cada recurso tiene solo los métodos que su ruta acepta: $cord->invoices->delete() no existe porque la API no tiene DELETE /facturas/{id}.

$cord->webhookEndpoints->update($endpointId, ['activo' => false]); // lo pausa
$nuevo = $cord->webhookEndpoints->rotateSecret($endpointId, 72);   // el anterior sigue firmando 72 horas
$cord->testHelpers->reset(); // vacía el entorno de prueba; conserva llaves y endpoints

Tipos para PHPStan

Flouvia\Cord\Types declara un @phpstan-type por objeto de la API, generado de la spec OpenAPI con los mismos nombres que los SDK de Node y Python (Quote, QuoteDetail, Invoice, InvoiceDetail, Client…). Cada método los usa en su @param y su @return, y el paquete pasa phpstan en el nivel máximo. En tu código:

/** @phpstan-import-type InvoiceDetail from \Flouvia\Cord\Types */
final class Cobranza
{
    public function __construct(private \Flouvia\Cord\Cord $cord)
    {
    }

    /** @return InvoiceDetail */
    public function factura(string $id): array
    {
        return $this->cord->invoices->retrieve($id);
    }
}

Con un cliente PSR-18

use Flouvia\Cord\Cord;
use Flouvia\Cord\Psr18Transport;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;

$factory = new HttpFactory();
$cord = new Cord(getenv('CORD_SECRET_KEY'), [
    'transport' => new Psr18Transport(new Client(['timeout' => 30]), $factory, $factory),
]);

El tiempo límite lo fija el cliente PSR-18. Con el fake HTTP de tu framework o cualquier cliente de prueba, las peticiones a Cord se prueban sin red. Un transporte propio es cualquier callable que recibe (método, url, headers, cuerpo, timeout) y devuelve [status, headers, cuerpo]; para pedir un reintento ante un fallo de red lanza TransportException.

Webhooks (Laravel)

// config/services.php
'cord' => ['webhook_secret' => env('CORD_WEBHOOK_SECRET'), 'workflow_secret' => env('CORD_WORKFLOW_SECRET')],
// routes/api.php: sin la sesión ni el CSRF de routes/web.php (en Laravel 11+, créalo con php artisan install:api)
use Flouvia\Cord\Webhook;
use Flouvia\Cord\WebhookSignatureException;
use Illuminate\Http\Request;

Route::post('/webhooks/cord', function (Request $request) {
    try {
        $event = Webhook::constructEvent($request->getContent(), $request->headers->all(), (string) config('services.cord.webhook_secret'));
    } catch (WebhookSignatureException $e) {
        abort(400);
    }
    if ($event['event'] === 'invoice.paid') { /* ... */ }
    return response('ok');
});

Si prefieres dejarla en routes/web.php, exclúyela del CSRF o Laravel responde 419: en Laravel 11+, $middleware->validateCsrfTokens(except: ['webhooks/cord']); en bootstrap/app.php. Lee el secreto con config(), nunca con env() fuera de config/: con php artisan config:cache, env() devuelve null.

Usa el cuerpo crudo. Acepta X-Cord-Signature-V1 o Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), ambas con timestamp (5 minutos de tolerancia); con las dos basta con que cuadre una. La legacy solo con allowLegacySignature: true. Compara con hash_equals (tiempo constante). Deduplica por $event['id'].

WebhookSignatureException->reason: missing_secret, missing_signature, invalid_signature_header, timestamp_out_of_tolerance, signature_mismatch, legacy_signature_rejected o invalid_payload.

El POST de un workflow

La acción "Mandar los datos a una URL" de Cord Workflows firma su POST igual, pero su cuerpo es { workflow, workflow_id, evento, objeto, objeto_id, enviado_en, mensaje?, datos }:

$envio = Webhook::constructWorkflowDelivery($request->getContent(), $request->headers->all(), (string) config('services.cord.workflow_secret'));

Webhook::verifySignature($payload, $headers, $secret) solo comprueba la firma, sin leer el cuerpo.