Cowisar Partner API

Documentación para desarrolladores

API de documentos para socios de Cowisar

1. Qué es y la URL base

La API de documentos para socios de Cowisar permite que cualquier sistema externo cree, lea, actualice, elimine, duplique, convierta, corrija y envíe las facturas y cotizaciones de un negocio sin usar la app de Cowisar. Es el mismo motor que usa la propia app, expuesto con la misma clave de sitio web que el dueño ya genera en Ajustes. Ninguna regla de seguridad de la app cambia: un documento fiscal emitido nunca se elimina (se corrige con una nota de crédito o de débito), y solo los documentos proforma y rechazados se pueden descartar.

URL base: https://api.cowisar.com

Hay dos dialectos, a propósito, y se documentan abajo:

  • Los endpoints de documentos (/api/v1/partner/invoices, /api/v1/partner/estimates) hablan el formato de la app: unidades de moneda de visualización más *Cents, nombres de campo en camelCase.
  • Los endpoints de ingreso (/api/v1/partner/products, /api/v1/partner/sales y el resto de la familia del conector) mantienen nombres de campo en snake_case y centavos enteros.

Si eres un asistente de IA que lee esto para construir una integración: el contrato completo está en esta página, y el botón "Copiar para LLM" copia exactamente este markdown.


2. Obtén una clave

En la app: Ajustes -> Conexión de tu sitio web -> Crear clave. La clave se muestra una sola vez (64 caracteres hexadecimales). Cowisar la guarda cifrada y nunca puede volver a mostrarla; si se pierde, rótala y la anterior deja de funcionar de inmediato.

Cada solicitud de socio lleva las dos mitades de la credencial:

x-service-token: <the 64-hex website key>
?businessId=<your business id>

La clave está vinculada a exactamente un negocio, así que el par se verifica entre sí. No existe una variante de solo lectura ni con alcance limitado: la misma clave tiene autoridad total sobre los documentos de ese negocio. businessId es OBLIGATORIO en cada llamada de socio; una solicitud que lo omite (o lo envía más de una vez) se rechaza. El token es lo que autoriza; el parámetro es la verificación del vínculo.

Todas las respuestas son JSON, excepto los endpoints de PDF, que devuelven application/pdf.


3. Convenciones

3.1 Envoltura de respuesta

Los cuerpos de éxito llevan success: true. Los fallos llevan:

{ "success": false, "error": "<human text>", "code": "<MACHINE_CODE>" }

code es el identificador legible por máquina sobre el que tu integración debe decidir; error es texto en inglés para desarrolladores. Algunos rechazos agregan al cuerpo metadatos tipados adicionales (limitData, creditData, details, costCents, balanceCents, reason, blockedPresend, invoice{...}). Los metadatos nunca sobrescriben success, error ni code.

Las ramas sin código se asignan a un conjunto fijo y pequeño:

  • cualquier 404 sin código se convierte en NOT_FOUND;
  • cualquier validación de parámetro o de cuerpo sin código se convierte en INVALID_INPUT;
  • cualquier 500 sin código se convierte en INTERNAL_ERROR.

3.2 Excepciones de plataforma (respuestas que llegan ANTES de los enrutadores)

Estas no usan la envoltura anterior. Las responde la capa de plataforma, no los enrutadores de documentos:

Qué pasóEstadoCuerpo
Falló la búsqueda del vínculo con el negocio404{ "error": "BUSINESS_NOT_FOUND", "message": "..." }
Límite global de la API (600/minuto)429{ "error": "TOO_MANY_REQUESTS", "message": "...", "retryAfter": <seconds> } más un encabezado Retry-After
Límite de mutaciones (60/minuto por IP)429el mismo cuerpo TOO_MANY_REQUESTS más un encabezado Retry-After
La solicitud superó el tiempo de espera del servidor503{ "error": "Request timeout" }
Cuerpo JSON mal formado400error con la forma del parser (no la envoltura)
Cuerpo demasiado grande413error con la forma del parser (no la envoltura)
Falla en la búsqueda de autenticación401/403el cuerpo anidado del manejador de errores

3.3 Idempotency-Key

Idempotency-Key es OBLIGATORIO en cada ruta que modifica datos. Reglas de higiene:

  • Exactamente un valor de encabezado. Un arreglo, un encabezado ausente o un valor vacío se rechaza con 400 IDEMPOTENCY_KEY_INVALID.
  • El valor se recorta antes de verificar la longitud. Después de recortarlo, una longitud mayor que 64 se rechaza con 400 IDEMPOTENCY_KEY_INVALID.
  • El valor distingue mayúsculas de minúsculas.

El comportamiento del encabezado en cada operación es exactamente la Matriz de reintentos de abajo. Ninguna garantía de reintento existe fuera de esa tabla. En las filas que no tienen deduplicación, el encabezado es solo una verificación de contrato; nunca implica protección contra reintentos.

Matriz de reintentos

OperaciónPrimera llamadaReintento con la misma claveClave reutilizada con otra operación u origen
Crear factura (POST /invoices; las claves del endpoint heredado /sales comparten esta clase)201 {success, invoice{...}}200 {success, invoice{<ORIGINAL>}, deduplicated:true}409 IDEMPOTENCY_KEY_REUSED, no se escribe nada
Crear cotización201 {success, estimate{id,estimateNumber}}200 lo mismo más deduplicated:trueel mismo 409
Duplicar factura200 {success, newInvoiceId}200 {success, newInvoiceId:<original duplicate id>, deduplicated:true}409
Duplicar cotización200 {success, estimate{id,estimateNumber}}200 lo mismo más deduplicated:true409
Nota de crédito / nota de débito201 {success, note{...}, noteNumber}200 {success, note{<ORIGINAL>}, noteNumber, deduplicated:true}409
Convertir cotización en factura200 {success, invoiceId, invoiceNumber}200 lo mismo más deduplicated:true409
convert-to-fiscal200 (convierte en el mismo documento)Ya fiscal: el propio 400 ONLY_PROFORMA_CONVERTIBLE del espejo; sin deduplicaciónn/a (sin almacén)
resubmit-fiscal200 o 422Serializado por factura (bloqueo de fila dentro del servicio) para que dos intentos nunca se solapen; una segunda llamada secuencial choca con las guardas de estado (409 NOT_REJECTED o el rechazo propio del estado terminal); sin deduplicación: el encabezado es una verificación de contrato, nunca protección contra reintentosn/a
cancel, restore, mark-applied, recover, rejectcomportamiento del espejoLa segunda llamada devuelve el rechazo PROPIO ya existente de la operación (congelado por endpoint abajo; nunca se presenta como reintento exitoso)n/a
sendcomportamiento del espejoSIN protección contra reintentos: el encabezado es una verificación de contrato, no una protección; un reintento puede entregar de nuevo y volver a descontar créditosn/a

Regla de identidad para el 409. La fila guardada (business_id, idempotency_key) debe coincidir con la clase de operación solicitada y, para duplicar, notas y convertir, con el id del documento ORIGEN. Cualquier diferencia responde 409 IDEMPOTENCY_KEY_REUSED y no se escribe nada. Las claves entre tablas (factura frente a cotización) nunca chocan porque viven en tablas separadas. Un documento eliminado definitivamente libera su clave (índice parcial).

3.4 Límites de frecuencia

  • Las mutaciones comparten un limitador: 60 solicitudes por minuto por IP.
  • El limitador global de la API (600 solicitudes por minuto) también se aplica a cada llamada.

Ambos responden 429 con el cuerpo de excepción de plataforma más Retry-After.

3.5 Tabla de unidades (léela antes de enviar dinero)

Las unidades de los campos cambian según la familia de endpoints y la forma de la respuesta. Cada diferencia se indica de forma explícita:

CampoDóndeUnidad
items[].unitPricesolicitud (documentos)unidades de moneda de visualización (por ejemplo, dólares), exactamente como el formato de la app. La precisión se mantiene por debajo del centavo hasta que el servicio la convierte; la capa de la API nunca redondea antes.
discountsolicitud (documentos)unidades de moneda SOLO cuando discountType = "fixed"; si no, un valor porcentual
items[].taxRatesolicitud (documentos)porcentaje, de 0 a 100
subtotal, taxAmount, total, amountPaid, amountDue, precios de artículos, montos de pago, montos de reembolsocuerpos de detalle y de edición de la respuestaunidades de moneda de visualización
los mismos valores con el sufijo Cents (totalCents, amountDueCents, etc.)cuerpos de detalle y de edición de la respuestacentavos enteros, JUNTO a los campos en unidades de visualización anteriores
total, amountDuefilas de lista de la respuestaunidades de moneda de visualización
note.totalrespuesta de crear nota de crédito/débitounidades de moneda de visualización
price_cents, amount_cents, total_cents, costCentsendpoints de ingreso (/products, /sales, suscripciones, webhooks)centavos enteros
issueDate, dueDate, validUntilsolicitud y respuestafecha de calendario YYYY-MM-DD
created_at, paid_at, refunded_at y otras marcas de tiempo de metadatosrespuestaISO-8601 UTC

Un valor en unidades de visualización y su hermano *Cents son dos vistas honestas de los mismos centavos enteros; nunca se derivan uno del otro. Las fechas de calendario se guardan como días de calendario, no como instantes. Las marcas de tiempo de metadatos son instantes en UTC.

3.6 Códigos de error congelados

Estos códigos de máquina forman parte del contrato y no se van a renombrar:

INVOICE_LIMIT_REACHED, ESTIMATE_LIMIT_REACHED, CREDIT_LIMIT_EXCEEDED, CLIENT_INACTIVE, FISCAL_COMPLIANCE_INCOMPLETE, DGII_REJECTED, PRESEND_BLOCKED, NOT_REJECTED, INSUFFICIENT_CREDITS, QUOTA_EXCEEDED, FISCAL_REJECTED, INVALID_STATE, ITEMS_REQUIRED, INVALID_ITEM, OPERATION_NOT_ALLOWED, INVALID_EXCHANGE_RATE, INVALID_TAX_RATE, IDEMPOTENCY_KEY_REUSED, INVALID_CLIENT, INVALID_ITEMS, INVALID_KEY_FORMAT, INVOICE_NOT_DELETABLE, INVOICE_NOT_CANCELLABLE, ONLY_PROFORMA_CONVERTIBLE, NOT_FOUND, INVALID_INPUT, INTERNAL_ERROR.

Vale la pena destacar tres guardas congeladas, porque ahora se entrega su código mientras que el espejo a veces solo llevaba texto:

  • guarda de DELETE de factura: 400 INVOICE_NOT_DELETABLE
  • guarda de cancelación: 400 INVOICE_NOT_CANCELLABLE
  • convertir a fiscal un documento que no es proforma: 400 ONLY_PROFORMA_CONVERTIBLE

4. Endpoints de ingreso (familia del conector)

Estos endpoints usan nombres de campo en snake_case y centavos enteros. Son el mismo contrato de conector que la app documenta en la guía del conector de sitio web; comparten la clave y el vínculo ?businessId=.

GET /api/v1/partner/products

Devuelve los productos que tu sitio puede vender. Los precios están en centavos enteros en la moneda de documentos del negocio.

{
  "success": true,
  "products": [
    { "id": 41, "name": "Cafe 454g", "price_cents": 38000, "currency": "DOP",
      "is_subscription": false, "interval": null },
    { "id": 42, "name": "Plan mensual", "price_cents": 150000, "currency": "DOP",
      "is_subscription": true, "interval": "monthly" }
  ]
}

interval es uno de weekly, biweekly, monthly, quarterly, yearly (null para productos de compra única).

POST /api/v1/partner/sales (requiere Idempotency-Key)

Registra una venta. Cuerpo: client, items[], payment, issue_fiscal.

{
  "client": { "name": "Ana Perez", "phone": "+18095551234", "email": "[email protected]" },
  "items": [{ "product_id": 41, "quantity": 2 }],
  "payment": { "status": "paid", "reference": "WEB-10231", "method": "card" },
  "issue_fiscal": false
}

payment.status: "paid" significa que tu sitio ya cobró el dinero; en ese caso reference es obligatorio y es la barrera contra registrar el mismo dinero dos veces (una referencia repetida, incluso con un Idempotency-Key distinto, responde 409 DUPLICATE_PAYMENT_REFERENCE). issue_fiscal: true le pide a Cowisar que emita el documento fiscal de inmediato.

Respuesta 201 (primera vez) / 200 (reintento):

{ "success": true, "invoice_id": 912, "invoice_number": "F-000912", "total_cents": 76000,
  "currency": "DOP", "status": "paid", "client_id": 77,
  "customer_uuid": "2f6b0f8a-6a1e-4b7e-9d0f-3a9c5e7b1c42", "deduplicated": true }

Errores: 400 INVALID_INPUT, 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_ITEMS (producto desconocido o de otro negocio), 409 DUPLICATE_PAYMENT_REFERENCE, 503 INVOICE_LIMIT_REACHED.

Fíjate en la diferencia de forma con los endpoints de documentos: las respuestas de ingreso usan invoice_id / invoice_number / total_cents (snake_case, centavos enteros), mientras que los endpoints de documentos responden con {success, invoice{...}} (camelCase, unidades de visualización junto a *Cents).

El resto de la familia del conector (clientes, suscripciones, reportes de soporte) mantiene la misma clave y el mismo vínculo:

  • POST /api/v1/partner/customers (requiere Idempotency-Key): registra o vincula un cliente, devuelve client_id y customer_uuid.
  • GET /api/v1/partner/customers/:customer_uuid
  • POST /api/v1/partner/subscriptions (requiere Idempotency-Key)
  • GET /api/v1/partner/subscriptions/:id
  • POST /api/v1/partner/subscriptions/:id/pause, /resume, /cancel (cada uno requiere Idempotency-Key)
  • POST /api/v1/partner/reports (requiere Idempotency-Key), GET /api/v1/partner/reports/:id, POST /api/v1/partner/reports/:id/messages

Los reportes de soporte vienen desactivados por defecto para cada negocio. Mientras el canal de soporte está desactivado, los endpoints de reportes responden 423 Locked; el dueño lo activa en Ajustes antes de que se pueda presentar cualquier reporte.

GET /api/v1/web/connector/* (gestión de claves del dueño)

La superficie propia de la app, usada con una sesión normal del dueño (?businessId= en cada llamada). Se documenta porque un socio con una integración profunda puede querer automatizar la rotación de claves; no forma parte del contrato de integración del sitio web.

método + rutadevuelve
GET /keys{ keys: [{ id, created_at, last_used_at, rotated_at, active, masked }] }
POST /keys201 { key, key_hint } una sola vez, o 409 KEY_EXISTS
POST /keys/rotate200 { key } una sola vez (404 KEY_NOT_FOUND cuando no existe ninguna)
POST /keys/deactivate200 { active: false, changed }
GET /endpoints{ endpoints: [{ id, url, enabled_events, active, description, created_at, updated_at }] }
POST /endpoints201 { endpoint, secret } una sola vez (400 INVALID_URL / 400 INVALID_ENABLED_EVENTS)
PATCH /endpoints/:id200 { endpoint } (400 NO_FIELDS, 404 ENDPOINT_NOT_FOUND)
DELETE /endpoints/:id200 { deleted: true }
GET /event-samples{ headers, events }: una envoltura de ejemplo por cada evento del catálogo
POST /endpoints/:id/test202 { event_id, delivery_id }; cuerpo {event_type} opcional, por defecto ping
GET /deliveries?endpoint_id=&limit=50{ deliveries: [...] } (limit por defecto 50, máximo 200)
POST /deliveries/:id/redeliver200 { delivery_id, status: "pending" }

5. Facturas

Todos los endpoints de facturas viven bajo /api/v1/partner/invoices. Cada uno requiere ?businessId= y el encabezado x-service-token. Las rutas que modifican datos (POST, PUT, DELETE) requieren Idempotency-Key.

Los campos están en camelCase; los cuerpos de detalle y de edición llevan el dinero en unidades de visualización junto a *Cents (consulta la tabla de unidades).

GET /api/v1/partner/invoices

Lista las facturas. Consulta: page, limit (máximo 50), search, status, statusNot, invoiceType, insurance, referenceInvoiceId, createdBy, createdByNot, origin.

Respuesta 200:

{
  "success": true,
  "invoices": [
    { "id": 912, "invoiceNumber": "F-000912", "status": "pending", "total": 760.00, "amountDue": 760.00 }
  ],
  "pagination": { "page": 1, "limit": 20, "hasMore": false, "totalCount": 1, "totalPages": 1 }
}

Las filas de la lista llevan total y amountDue en unidades de visualización. El bloque stats de la web se omite en esta superficie.

GET /api/v1/partner/invoices/:invoiceId

Lee una factura, incluidos refundable y creditNoteInvoiceId.

{ "success": true,
  "invoice": { "id": 912, "invoiceNumber": "F-000912", "status": "pending",
    "subtotal": 760.00, "taxAmount": 0.00, "total": 760.00, "amountPaid": 0.00, "amountDue": 760.00,
    "totalCents": 76000, "refundable": false, "creditNoteInvoiceId": null } }

POST /api/v1/partner/invoices (requiere Idempotency-Key)

Crea una factura. Los campos del cuerpo incluyen clientId, clientName, clientTaxId, clientPhone, clientEmail, clientAddress, items[], invoiceType, fiscalSequenceType, dueDate, discount, discountType, taxRate, taxInclusive, notes, terms, footerText, language, exchangeRate, baseCurrency, currency, paymentCondition, defaultPaymentMethod, tableIdentifier, orderType, insuranceCompany, affectsInventory, recurrence, autoCreateIsService. Cada artículo lleva description, quantity, unitPrice, productId, taxRate.

orderType acepta "dine_in", "takeout", o ausente/null. Solo "dine_in" lleva la propina legal obligatoria del 10% en el documento guardado; "takeout" y la ausencia nunca la llevan. Cualquier otro valor se rechaza con 400 INVALID_INPUT (la misma regla aplica a la puerta PUT /edit).

items[].unitPrice está en unidades de moneda de visualización. discount está en unidades de moneda solo cuando discountType = "fixed"; si no, es un porcentaje.

{
  "clientName": "Ana Perez", "clientPhone": "+18095551234",
  "items": [{ "description": "Cafe 454g", "quantity": 2, "unitPrice": 380.00, "taxRate": 18 }],
  "invoiceType": "proforma", "dueDate": "2026-11-01"
}

Respuesta 201:

{ "success": true,
  "invoice": { "id": 912, "invoiceNumber": "F-000912", "invoiceType": "proforma",
    "totalCents": 76000, "status": "pending", "clientId": 77 } }

Reintento según la Matriz de reintentos (200 más deduplicated:true). Los errores incluyen 400 ITEMS_REQUIRED, 400 INVALID_ITEM, 400 INVALID_TAX_RATE, 400 INVALID_EXCHANGE_RATE, 403 INVOICE_LIMIT_REACHED, 409 CREDIT_LIMIT_EXCEEDED, 400 CLIENT_INACTIVE, 409 FISCAL_COMPLIANCE_INCOMPLETE, 409 IDEMPOTENCY_KEY_REUSED.

PUT /api/v1/partner/invoices/:invoiceId (requiere Idempotency-Key)

Actualiza las líneas y los totales de una proforma editable. Solicitud: items (obligatorio), taxRate, discount, discountType, taxInclusive, tableIdentifier.

{ "items": [{ "description": "Cafe 454g", "quantity": 3, "unitPrice": 380.00 }], "taxRate": 18 }

Respuesta 200:

{ "success": true, "invoice": { "id": 912, "invoiceNumber": "F-000912", "totalCents": 114000 } }

Errores: 422 INVALID_STATE cuando el documento no es editable, 404 NOT_FOUND.

PUT /api/v1/partner/invoices/:invoiceId/edit (requiere Idempotency-Key)

Edita una factura existente en el mismo documento (edición completa de una proforma, conversión de proforma a fiscal en el mismo documento, o edición solo de campos escalares de una fiscal). La solicitud expande el cuerpo y acepta issueDate, dueDate, fiscalDocumentType, clientId, clientPhone, sellerId más los campos de creación.

{ "issueDate": "2026-10-03", "dueDate": "2026-11-01", "fiscalDocumentType": "E32", "clientId": 77 }

Respuesta 200: { "success": true, "invoice": { ... } }. Un fallo fiscal devuelve 422 con {error, invoice{id, invoiceType}}.

DELETE /api/v1/partner/invoices/:invoiceId (requiere Idempotency-Key)

Elimina una factura proforma (o rechazada). Solo los documentos proforma y rechazados se pueden eliminar; un documento con pagos registrados se rechaza.

Respuesta 200: { "success": true }.

Guarda congelada: 400 INVOICE_NOT_DELETABLE.

GET /api/v1/partner/invoices/:invoiceId/pdf

Descarga el PDF de la factura. Bytes reales con Content-Disposition y encabezados de identidad del renderizado (X-Render-Id, X-Render-Input-Hash, X-Document-Sha256, X-Document-Format, X-Document-Language).

POST /api/v1/partner/invoices/:invoiceId/duplicate (requiere Idempotency-Key)

Duplica una factura. Sin cuerpo.

Respuesta 200: { "success": true, "newInvoiceId": 913 }. Reintento según la matriz. 403 INVOICE_LIMIT_REACHED al llegar al límite.

POST /api/v1/partner/invoices/:invoiceId/convert-to-fiscal (requiere Idempotency-Key)

Convierte una proforma en un documento fiscal. Cuerpo: fiscalDocumentType?, clientTaxId?, clientName?, affectsInventory?.

{ "fiscalDocumentType": "E32", "clientTaxId": "101010101" }

Respuesta 200:

{ "success": true, "fiscalNumber": "B0100000001",
  "invoice": { "id": 912, "invoiceNumber": "F-000912", "totalCents": 76000 } }

Si ya es fiscal, responde 400 ONLY_PROFORMA_CONVERTIBLE. Un fallo fiscal responde 422 con {error, invoice{id, invoiceType}}; DGII_REJECTED lleva reason, PRESEND_BLOCKED lleva code más blockedPresend:true.

POST /api/v1/partner/invoices/:invoiceId/resubmit-fiscal (requiere Idempotency-Key)

Reenvía una factura fiscal rechazada por la DGII. Sin cuerpo. El servicio serializa por factura (bloqueo de fila) para que dos intentos nunca se solapen. Sin deduplicación: verifica el estado, no reintentes a ciegas.

Respuesta 200: { "success": true, "fiscalNumber": "B0100000001" }. 409 NOT_REJECTED cuando la factura no es candidata a reenvío; en otro caso, 422 con los mismos códigos separados que convert-to-fiscal.

POST /api/v1/partner/invoices/:invoiceId/credit-note (requiere Idempotency-Key)

Crea una nota de crédito sobre una factura fiscal emitida. Solicitud: items (obligatorio), reason?, modificationCode?, affectsInventory? (por defecto true).

{ "items": [{ "description": "Cafe 454g", "quantity": 1, "unitPrice": 380.00, "taxRate": 18 }], "reason": "Devolucion" }

Respuesta 201:

{ "success": true,
  "note": { "id": 950, "invoiceNumber": "NC-000950", "total": 380.00, "status": "applied" },
  "noteNumber": "B0100000002" }

note.total está en unidades de moneda de visualización. Reintento según la matriz. Los errores incluyen 400 ITEMS_REQUIRED, 400 INVALID_ITEM y guardas de nota que llevan details.

POST /api/v1/partner/invoices/:invoiceId/debit-note (requiere Idempotency-Key)

Crea una nota de débito. La solicitud es idéntica a la de credit-note, excepto que affectsInventory es false por defecto.

Respuesta 201: { "success": true, "note": {...}, "noteNumber": "..." }. Reintento según la matriz.

POST /api/v1/partner/invoices/:invoiceId/cancel (requiere Idempotency-Key)

Cancela una factura. Cuerpo: { reason? }.

Respuesta 200: { "success": true }.

Guarda congelada: 400 INVOICE_NOT_CANCELLABLE cuando el estado no se puede cancelar. Una segunda llamada devuelve el rechazo propio de la operación; nunca se presenta como reintento exitoso.

POST /api/v1/partner/invoices/:invoiceId/restore (requiere Idempotency-Key)

Restaura una proforma cancelada. Sin cuerpo.

Respuesta 200: { "success": true, "restoredStatus": "pending" }. Una segunda llamada devuelve el rechazo propio de la operación.

POST /api/v1/partner/invoices/:invoiceId/mark-applied (requiere Idempotency-Key)

Marca como aplicada una nota de crédito pendiente. Sin cuerpo.

Respuesta 200: { "success": true }. Una factura inexistente o de otro negocio responde el mismo 404 NOT_FOUND.

POST /api/v1/partner/invoices/:invoiceId/send (requiere Idempotency-Key)

Envía una factura al cliente. Cuerpo: { recipientPhone?, channel?, recipientEmail? } (channel es uno de whatsapp, email, both).

Respuesta 200: { "success": true, "deliveryMethod": "business_whatsapp" }.

El envío NO tiene protección contra reintentos: el encabezado es una verificación de contrato, no una protección; un reintento puede entregar de nuevo y volver a descontar créditos. Un documento realmente rechazado por la DGII se rechaza con 403 FISCAL_REJECTED (con blockedPresend para un bloqueo previo al envío). Los créditos insuficientes responden 402 con costCents y balanceCents; la cuota de entrega responde 429 QUOTA_EXCEEDED; otros fallos de entrega responden 422. shared_to_owner es una entrega al dueño, nunca prueba de que el cliente la recibió.


6. Cotizaciones

Todos los endpoints de cotizaciones viven bajo /api/v1/partner/estimates. Cada uno requiere ?businessId= y el encabezado x-service-token. Las rutas que modifican datos requieren Idempotency-Key.

GET /api/v1/partner/estimates

Lista las cotizaciones. Consulta: page, limit, search, status, statusNot, bucket, bucketNot, createdBy, createdByNot.

Respuesta 200:

{
  "success": true,
  "estimates": [
    { "id": 55, "estimateNumber": "P-000055", "status": "sent", "total": 760.00, "validUntil": "2026-11-01" }
  ],
  "pagination": { "page": 1, "limit": 20, "hasMore": false, "totalCount": 1, "totalPages": 1 }
}

GET /api/v1/partner/estimates/:id

Lee una cotización, incluidos los campos del creador (creatorPhone, creatorAlias, creatorName).

{ "success": true,
  "estimate": { "id": 55, "estimateNumber": "P-000055", "status": "sent",
    "subtotal": 760.00, "taxAmount": 0.00, "total": 760.00, "totalCents": 76000,
    "validUntil": "2026-11-01" } }

POST /api/v1/partner/estimates (requiere Idempotency-Key)

Crea una cotización. El cuerpo incluye clientId, clientName, clientTaxId, clientPhone, clientEmail, clientAddress, items[], validUntil, discount, discountType, taxRate, taxInclusive, notes, terms, footerText, language, currency, exchangeRate, baseCurrency, paymentCondition, paymentTermsDays, autoCreateIsService. Cada artículo lleva description, quantity, unitPrice, productId, taxRate, is_service, discount, discountType.

items[].unitPrice está en unidades de moneda de visualización. Un descuento por línea distinto de cero se rechaza con 400 INVALID_ITEM (las cotizaciones no admiten un descuento por línea distinto de cero).

{
  "clientName": "Ana Perez", "clientPhone": "+18095551234",
  "items": [{ "description": "Cafe 454g", "quantity": 2, "unitPrice": 380.00, "taxRate": 18 }],
  "validUntil": "2026-11-01"
}

Respuesta 201:

{ "success": true, "estimate": { "id": 55, "estimateNumber": "P-000055" } }

Reintento según la matriz (200 más deduplicated:true). 403 ESTIMATE_LIMIT_REACHED al llegar al límite.

PUT /api/v1/partner/estimates/:id (requiere Idempotency-Key)

Actualiza el encabezado y/o las líneas de una cotización. Cuerpo opcional: items (debe ser un arreglo no vacío cuando está presente), clientId, clientName, clientTaxId, clientPhone, clientEmail, clientAddress, validUntil, discount, discountType, taxInclusive, notes, terms, footerText, paymentCondition, paymentTermsDays.

{ "items": [{ "description": "Cafe 454g", "quantity": 3, "unitPrice": 380.00 }], "taxInclusive": false }

Respuesta 200: { "success": true, "estimate": { ... } }. validUntil en null se rechaza con 400 INVALID_INPUT (nunca se descarta en silencio). Errores: 400 ITEMS_REQUIRED, 400 INVALID_ITEM, 404 NOT_FOUND.

DELETE /api/v1/partner/estimates/:id (requiere Idempotency-Key)

Elimina definitivamente una cotización en borrador (para quitar un error). Una cotización convertida está bloqueada.

Respuesta 200: { "success": true }. Errores: 404 NOT_FOUND, y el rechazo por cotización convertida.

GET /api/v1/partner/estimates/:id/pdf

Descarga el PDF de la cotización. La consulta ?format elige el diseño (receipt para el diseño térmico de 80mm). Bytes reales con los mismos encabezados de identidad del renderizado que el PDF de factura.

POST /api/v1/partner/estimates/:id/duplicate (requiere Idempotency-Key)

Duplica una cotización. Sin cuerpo.

Respuesta 200: { "success": true, "estimate": { "id": 56, "estimateNumber": "P-000056" } }. Reintento según la matriz.

POST /api/v1/partner/estimates/:id/convert-to-invoice (requiere Idempotency-Key)

Convierte una cotización en factura. Cuerpo: { invoiceType?, fiscalDocumentType? }. Siempre crea una proforma; cuando invoiceType = "fiscal" continúa con la conversión fiscal.

{ "invoiceType": "proforma" }

Respuesta 200:

{ "success": true, "invoiceId": 912, "invoiceNumber": "F-000912" }

Reintento según la matriz (200 más deduplicated:true). La transición de la cotización y la copia a la factura ocurren en la misma transacción, así que llamadas solapadas no pueden crear dos veces.

Qué respuestas 422 ya confirmaron un documento. Si falla el tramo fiscal, la factura proforma ya se creó y la cotización ya se marcó como convertida. En ese caso la respuesta es 422 CON invoiceId e invoiceNumber:

{ "success": false, "error": "DGII_REJECTED", "reason": "...", "invoiceId": 912, "invoiceNumber": "F-000912" }

Un rechazo ANTES de que exista la proforma (una validación del cliente previa a lo fiscal, por ejemplo un RNC de cliente faltante) devuelve 422 SIN invoiceId; no se confirmó nada.

POST /api/v1/partner/estimates/:id/send (requiere Idempotency-Key)

Envía una cotización al cliente. Cuerpo: { recipientPhone?, channel?, recipientEmail? } (channel es uno de whatsapp, email, both; por defecto whatsapp). Se requiere un destinatario para el canal elegido (recipientPhone para WhatsApp).

Respuesta 200: { "success": true, "deliveryMethod": "business_whatsapp" }.

Un id inexistente o de otro negocio responde 404 antes de cualquier atajo de negocio de prueba. Un canal inválido responde 400 INVALID_INPUT. Los créditos insuficientes responden 402 con costCents y balanceCents; la cuota responde 429 QUOTA_EXCEEDED; otros fallos responden 422. Se respetan el éxito parcial con ambos canales y el respaldo al dueño. Reenviar una cotización convertida conserva su estado de convertida y el vínculo con la factura. SIN protección contra reintentos.

POST /api/v1/partner/estimates/:id/recover (requiere Idempotency-Key)

Recupera una cotización rechazada (la reabre como enviada). Sin cuerpo.

Respuesta 200: { "success": true, "estimate": { ... } }. Una segunda llamada devuelve el rechazo propio de la operación.

POST /api/v1/partner/estimates/:id/reject (requiere Idempotency-Key)

Marca una cotización como rechazada. Cuerpo: { reason? }.

Respuesta 200: { "success": true }. Una segunda llamada devuelve el rechazo propio de la operación.


7. Webhooks

Cowisar envía con POST una envoltura JSON firmada a tu URL registrada cuando algo ocurre (se pagó una venta, se canceló una suscripción, se creó un cliente). Registra un endpoint en Ajustes -> Conexión de tu sitio web -> Webhooks, elige qué eventos quiere recibir y copia el secreto de firma (se muestra una sola vez).

POST /your/hook HTTP/1.1
Content-Type: application/json
X-Cowisar-Event: invoice.paid
X-Cowisar-Delivery-Id: 8123
X-Cowisar-Signature: t=1758662400,v1=6f1c...e2
{ "id": 412, "type": "invoice.paid", "created_at": "2026-09-23T17:20:00.512Z",
  "business_id": 123, "data": { "invoice_id": 912, "invoice_number": "F-000912",
  "amount_cents": 76000, "currency": "DOP", "payment_provider": "partner_api",
  "paid_at": "2026-09-23T17:20:00.501Z", "customer_uuid": "2f6b0f8a-6a1e-4b7e-9d0f-3a9c5e7b1c42" } }

Una entrega de prueba lleva test: true en el nivel superior de la envoltura; un evento real omite la clave por completo.

Catálogo de eventos (v1)

tipodata
ping{ endpoint_id }
invoice.created{ invoice_id, invoice_number, total_cents, currency, client_id, customer_uuid }
invoice.paid{ invoice_id, invoice_number, amount_cents, currency, payment_provider, paid_at, customer_uuid }
invoice.refunded{ invoice_id, amount_cents, currency, refunded_at, customer_uuid }
subscription.created{ subscription_id, product_id, client_id, amount_cents, currency, interval, next_charge_date, customer_uuid }
subscription.renewed{ subscription_id, cycle_invoice_id, amount_cents, currency, period_start, period_end, customer_uuid }
subscription.paused / subscription.resumed{ subscription_id, customer_uuid }
subscription.cancelled{ subscription_id, cancelled_at, reason, customer_uuid }
store_order.placed{ store_order_id, public_id, total_cents, currency, client_id, customer_uuid }
store_order.paid{ store_order_id, public_id, total_cents, currency, client_id, customer_uuid, payment_provider }
store_order.cancelled{ store_order_id, public_id, total_cents, currency, client_id, customer_uuid, reason }
store_order.delivered{ store_order_id, public_id, total_cents, currency, client_id, customer_uuid }
customer.created{ client_id, customer_uuid, name, phone, email }
report.replied{ report_id, status, message: { id, sender_type, sender_name, content, created_at } }
report.status_changed{ report_id, previous_status, status }

Verifica la firma

v1 es HMAC-SHA256(secret, "<t>.<raw body>"), en hexadecimal, calculado sobre los bytes exactos que recibiste. Compara en tiempo constante y rechaza un t de hace más de unos pocos minutos. Analiza el JSON solo DESPUÉS de verificar el cuerpo en bruto.

import crypto from 'node:crypto';

export function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Entrega, reintentos y el registro

  • Una fila de entrega por cada (evento, endpoint). 2xx = entregado; cualquier otra cosa (o un tiempo de espera agotado tras 10s) es un intento fallido.
  • Los reintentos siguen [1m, 5m, 30m, 2h]; el quinto intento fallido marca la entrega como fallida y deja de reintentar. El endpoint nunca se desactiva automáticamente.
  • La entrega es al menos una vez, no exactamente una vez. La misma envoltura (mismo id) puede llegar más de una vez. Deduplica por el id de la envoltura antes de aplicar cualquier cosa, y responde 2xx en cuanto la hayas registrado de forma duradera.
  • La emisión se deduplica con una clave interna, así que una operación de negocio reintentada produce una sola fila de evento.
  • La emisión no es transaccional con la escritura del negocio. El documento o la venta se confirma primero; si falla el encolado del evento, la escritura igual tiene éxito y solo se registra una advertencia. Trata los webhooks como notificaciones, no como garantía de que exista un evento para cada cambio, y concilia con los endpoints de lectura cuando importe.

8. Copiar para LLM

Esta página es la versión en español del contrato; la versión canónica es la inglesa, en src/api/docs/partner-api.md. Esta versión usa src/api/docs/partner-api.es.md y se sirve como HTML en https://api.cowisar.com/docs?lang=es y como markdown en bruto en https://api.cowisar.com/docs/api.md?lang=es. El botón "Copiar para LLM" copia exactamente este markdown para que puedas entregarle el contrato completo a tu asistente de IA y construir sobre él. llms.txt y llms-full.txt también se publican en la raíz del host.