flouviahq / cord
SDK de PHP para la API de Cord: cotizaciones, facturas, webhooks verificados, tipos de PHPStan y modo prueba. Sin dependencias.
Requires
- php: >=8.2
- ext-json: *
Requires (Dev)
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
Suggests
- ext-curl: El transporte por defecto.
- psr/http-client-implementation: Para usar Psr18Transport con Guzzle, Symfony HttpClient o el cliente HTTP de tu framework.
Provides
None
Conflicts
None
Replaces
None
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 delist()ylimitcomo tamaño de cada página.- Errores
CordExceptionconstatus,errorCode,requestId,docUrl,headersyretryAfter. Una excepción del transporte que no seaTransportExceptionsale tal cual y no se reintenta. - Manda
Cord-VersionconCord::API_VERSION, la versión del contrato con que se generaron los tipos (opciónapi_version).
Reintentos
- Si la respuesta trae
Cord-Should-Retry, manda ese header:falseno se reintenta (una cuota agotada, el tope de envíos de una factura, un rechazo del PAC al emitir) ytruesí. - Sin él se reintenta la red caída (
TransportException), 408, 425 y 5xx. Un 429 solo si es de ritmo (errorCoderate_limited) o llega sincode, 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 laCordException, conretryAfterpara 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í unfinalizelento termina con la factura.'max_retries' => 0apaga todo reintento.- Nunca sigue una redirección: un 3xx es
CordExceptionconerrorCodeunexpected_redirecty la dirección a la que apunta. Revisabase_url(la de producción eshttps://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 ylastResponse()con el original.lastResponse()es unCordResponse(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 unRawResponse. La ruta es relativa a/api/v1(/api/v1/…también vale); una URL absoluta,//host,.o..sonInvalidArgumentException: 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.