Metadata-Version: 2.5
Name: cord-sdk
Version: 1.1.0
Summary: SDK de Python para la API de Cord: cotizaciones, facturas, webhooks verificados, tipos y cliente async. Sin dependencias.
Project-URL: Homepage, https://cordhq.app
Project-URL: Documentation, https://docs.cordhq.app/docs/desarrolladores/herramientas/sdks
Project-URL: API reference, https://docs.cordhq.app/docs/desarrolladores/referencia
Author: Flouvia
License: MIT
Keywords: api,async,cord,cotizaciones,facturas,webhooks
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: async
Requires-Dist: httpx>=0.27; extra == 'async'
Description-Content-Type: text/markdown

# cord-sdk (Python)

SDK de Python para la API de [Cord](https://cordhq.app). Sin dependencias, Python 3.10+, con tipos y cliente async.

```bash
pip install cord-sdk
pip install 'cord-sdk[async]'   # AsyncCord, con httpx
```

```python
from cord import Cord

cord = Cord("sk_test_...")
quote = cord.quotes.create(
    cliente={"empresa": "Acme"},
    items=[{"descripcion": "Instalación", "cantidad": 1, "precio_unitario": 12500, "tax_rate": 0.16}],
    base_currency="MXN",
)
print(quote["folio"], quote["link_publico"])

for invoice in cord.invoices.list_all(estado="open"):
    print(invoice["numero"], invoice["saldo"], invoice["moneda"])
```

- Toda mutación lleva `Idempotency-Key`, la misma en cada reintento: un corte de red nunca duplica nada. Pasa `idempotency_key=` (siempre por nombre) para atarla a tu propio identificador.
- `list_all()` 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.
- Los errores son `CordError` con `status`, `code`, `request_id`, `doc_url`, `headers` y `retry_after`. Un error de tu código, como un `TypeError`, sale tal cual y no se reintenta.
- Manda `Cord-Version: 2026-10-01`; cámbiala con `Cord(sk, api_version=...)`.
- Un `Decimal` se manda como número y un `date` o `datetime` como fecha ISO 8601, en el cuerpo y en los filtros. Los tipos siguen la spec (`float` y `str`), salvo `record_payment` y `credit_note`, que aceptan `Decimal`.

## 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 (`ConnectionError`, `TimeoutError` o `URLError`), 408, 425 y 5xx. Un 429 solo si es de ritmo (`code` `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 el `CordError`, con `retry_after` 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 `CordError` con `code` `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

```python
cord = Cord("sk_test_...", app_info={"name": "mi-erp", "version": "4.2.0"})  # User-Agent: cord-python/1.1.0 mi-erp/4.2.0

rapido = cord.with_options(timeout=5.0, max_retries=0)
rapido.invoices.finalize(factura_id)
print(cord.last_response.request_id)  # búscalo en Desarrolladores › Registros

r = cord.raw_request("GET", "/cotizaciones", query={"status": "sent", "limit": 5})
print(r.data, r.meta, r.request_id)
```

- `with_options(timeout=, max_retries=)` devuelve un cliente con esas opciones que comparte transporte y `last_response` con el original.
- `last_response` es un `CordResponse` (`status`, `headers`, `request_id`, `idempotent_replayed`, `api_version`) de la última llamada exitosa de ese hilo o esa tarea de asyncio.
- `raw_request(method, path, query=, body=, idempotency_key=, headers=)` llama una ruta que el SDK aún no tiene como método, con la llave, `Cord-Version`, 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 un `ValueError`: la llave nunca sale a otro host.

## Tipos

Cada respuesta es un `TypedDict` de `cord.types`, generado de la spec OpenAPI con los mismos nombres que el SDK de Node (`Quote`, `QuoteDetail`, `Invoice`, `InvoiceDetail`, `Client`, `Product`…). Los argumentos de `create`, `update`, `list` y `list_all` también están tipados: mypy o Pyright marcan un campo mal escrito antes de llamar a Cord.

```python
from cord.types import InvoiceDetail

factura: InvoiceDetail = cord.invoices.retrieve(factura_id)
print(factura["saldo"], factura["moneda"])
```

## Async

```python
from cord import AsyncCord

async with AsyncCord("sk_test_...") as cord:
    quote = await cord.quotes.create(items=[{"descripcion": "Instalación", "cantidad": 1, "precio_unitario": 12500}])
    async for invoice in cord.invoices.list_all(estado="open"):
        print(invoice["numero"])
```

`AsyncCord` tiene los mismos métodos que `Cord`, con la misma idempotencia y los mismos reintentos. Usa httpx; con `transport=` puedes pasar el tuyo (una función async que recibe `method, url, headers, body, timeout` y devuelve `status, headers, body`).

## Recursos

| Atributo | Métodos |
|---|---|
| `cord.me()`, `cord.collections()`, `cord.create_task(...)` | `GET /me`, `GET /cobranza`, `POST /tareas` |
| `cord.quotes` | `list`, `list_all`, `retrieve`, `create`, `update`, `send`, `resend`, `approve`, `reject`, `mark_paid`, `delete`, `draft_from_text` |
| `cord.clients` | `list`, `list_all`, `retrieve`, `create`, `update` |
| `cord.products` | `list`, `list_all`, `retrieve`, `create`, `update`, `delete` |
| `cord.invoices` | `list`, `list_all`, `retrieve`, `create`, `finalize`, `send`, `void`, `record_payment`, `credit_note` |
| `cord.events` | `list`, `list_all` |
| `cord.workflows` | `invoke` |
| `cord.setup` | `propose`, `retrieve` |
| `cord.elements` | `config` |
| `cord.webhook_endpoints` | `list`, `create`, `update`, `rotate_secret`, `delete` |
| `cord.test_helpers` | `reset`, `set_next_fiscal_outcome`, `view_quote`, `expire_quote`, `trigger_webhook` (solo `sk_test_`) |
| `cord.webhooks` | `construct_event`, `construct_workflow_delivery`, `verify_signature`, `sign_payload` |

```python
factura = cord.invoices.create(cliente_id=cliente_id, items=[...], currency="MXN")
# Un CFDI a un cliente extranjero sin mercancías: confírmalo al emitir.
cord.invoices.finalize(factura["id"], sin_comercio_exterior=True)

cord.workflows.invoke("wf_…", "invoice", [factura["id"]])

plan = cord.setup.propose(sitio="tunegocio.com", descripcion="Instalaciones eléctricas, cobro a 30 días")
print(plan["review_url"])  # una persona la aprueba ahí

cord.webhook_endpoints.update(endpoint_id, activo=False)  # lo pausa
nuevo = cord.webhook_endpoints.rotate_secret(endpoint_id, 72)
print(nuevo["secret"], nuevo["secreto_anterior_expira"])  # el anterior sigue firmando 72 horas
```

## Webhooks

Verifica siempre sobre el cuerpo **crudo**, tal como llegó.

Flask:

```python
import os
from flask import Flask, request
from cord import construct_event, WebhookSignatureError

app = Flask(__name__)

@app.post("/webhooks/cord")
def cord_webhook():
    try:
        event = construct_event(request.get_data(), request.headers, os.environ["CORD_WEBHOOK_SECRET"])
    except WebhookSignatureError:
        return "", 400
    if event["event"] == "invoice.paid":
        ...
    return "ok"
```

FastAPI:

```python
import os
from fastapi import FastAPI, Request, Response
from cord import construct_event, WebhookSignatureError

app = FastAPI()

@app.post("/webhooks/cord")
async def cord_webhook(request: Request) -> Response:
    try:
        event = construct_event(await request.body(), request.headers, os.environ["CORD_WEBHOOK_SECRET"])
    except WebhookSignatureError:
        return Response(status_code=400)
    if event["event"] == "invoice.paid":
        ...
    return Response("ok")
```

En Django, el cuerpo crudo es `request.body` y la vista va con `@csrf_exempt`.

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, `tolerance=`); con las dos basta con que cuadre una. La firma legacy solo con `allow_legacy_signature=True`. Deduplica por `event["id"]`.

`WebhookSignatureError.code`: `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 }`:

```python
from cord import construct_workflow_delivery

envio = construct_workflow_delivery(request.get_data(), request.headers, os.environ["CORD_WORKFLOW_SECRET"])
print(envio["evento"], envio["datos"])
```

`verify_signature(payload, headers, secret)` solo comprueba la firma, sin leer el cuerpo.

## Modo prueba

```python
cord = Cord("sk_test_...")
cord.test_helpers.set_next_fiscal_outcome("pac_caido")
cord.test_helpers.trigger_webhook("invoice.paid")
cord.test_helpers.reset()  # vacía el entorno de prueba; conserva llaves y endpoints
```
