Cowisar Partner Documents API
1. What it is and the base URL
The Cowisar Partner Documents API lets any external system create, read, update, delete, duplicate, convert, correct and send a business's invoices and estimates without using the Cowisar app. It is the same engine the app itself uses, exposed over the same website key the owner already mints in Ajustes. Nothing about the app's safety rules changes: an issued fiscal document is never deleted (it is corrected with a credit or debit note), and only proforma and rejected documents can be discarded.
Base URL: https://api.cowisar.com
Two dialects are deliberate and documented below:
- Document endpoints (
/api/v1/partner/invoices,/api/v1/partner/estimates) speak the app's wire: display currency units plus*Cents, camelCase field names. - Intake endpoints (
/api/v1/partner/products,/api/v1/partner/sales, and the rest of the connector family) keep snake_case field names and integer cents.
If you are an AI assistant reading this to build an integration: the entire contract is on this page, and the "Copy for LLM" button copies this exact markdown.
2. Get a key
In the app: Ajustes -> Conexion de sitio web -> Crear clave. The key is shown once (64 hex characters). Cowisar stores it encrypted and can never show it again; if it is lost, rotate it and the old one stops working immediately.
Every partner request carries both halves of the credential:
x-service-token: <the 64-hex website key>
?businessId=<your business id>
The key is bound to exactly one business, so the pair is checked against each other. There is no read-only variant and no scoping: the same key has full authority over that business's documents. businessId is REQUIRED on every partner call; a request that omits it (or sends it more than once) is refused. The token is what authorizes; the parameter is the binding check.
All responses are JSON except the PDF endpoints, which return application/pdf.
3. Conventions
3.1 Response envelope
Success bodies carry success: true. Failures carry:
{ "success": false, "error": "<human text>", "code": "<MACHINE_CODE>" }
code is the machine-readable identifier your integration should branch on; error is English developer text. Some refusals merge extra typed metadata into the body (limitData, creditData, details, costCents, balanceCents, reason, blockedPresend, invoice{...}). Metadata never overwrites success, error or code.
Uncoded branches map to a small fixed set:
- any uncoded 404 becomes
NOT_FOUND; - any uncoded parameter or body validation becomes
INVALID_INPUT; - any uncoded 500 becomes
INTERNAL_ERROR.
3.2 Platform exceptions (answers that arrive BEFORE the routers)
These do not use the envelope above. They are answered by the platform layer, not the documents routers:
| What happened | Status | Body |
|---|---|---|
| Business binding lookup failed | 404 | { "error": "BUSINESS_NOT_FOUND", "message": "..." } |
| Global API rate limit (600/minute) | 429 | { "error": "TOO_MANY_REQUESTS", "message": "...", "retryAfter": <seconds> } plus a Retry-After header |
| Mutation rate limit (60/minute per IP) | 429 | same TOO_MANY_REQUESTS body plus a Retry-After header |
| Request exceeded the server timeout | 503 | { "error": "Request timeout" } |
| Malformed JSON body | 400 | parser-shaped error (not the envelope) |
| Body too large | 413 | parser-shaped error (not the envelope) |
| Authentication lookup fault | 401/403 | the error handler's nested body |
3.3 Idempotency-Key
Idempotency-Key is REQUIRED on every mutating route. Hygiene rules:
- Exactly one header value. An array, a missing header, or an empty value is refused with
400 IDEMPOTENCY_KEY_INVALID. - The value is trimmed before the length check. After trimming, a length greater than 64 is refused with
400 IDEMPOTENCY_KEY_INVALID. - The value is case-sensitive.
The header's behavior per operation is exactly the Replay matrix below. No replay guarantee lives outside that table. For rows that are not dedup-backed, the header is a contract check only; it is never implied replay protection.
Replay matrix
| Operation | First call | Same-key replay | Reused key with different operation or source |
|---|---|---|---|
Invoice create (POST /invoices; the legacy /sales keys share this class) | 201 {success, invoice{...}} | 200 {success, invoice{<ORIGINAL>}, deduplicated:true} | 409 IDEMPOTENCY_KEY_REUSED, nothing written |
| Estimate create | 201 {success, estimate{id,estimateNumber}} | 200 same plus deduplicated:true | 409 same |
| Invoice duplicate | 200 {success, newInvoiceId} | 200 {success, newInvoiceId:<original duplicate id>, deduplicated:true} | 409 |
| Estimate duplicate | 200 {success, estimate{id,estimateNumber}} | 200 same plus deduplicated:true | 409 |
| Credit-note / debit-note | 201 {success, note{...}, noteNumber} | 200 {success, note{<ORIGINAL>}, noteNumber, deduplicated:true} | 409 |
| Estimate convert-to-invoice | 200 {success, invoiceId, invoiceNumber} | 200 same plus deduplicated:true | 409 |
| convert-to-fiscal | 200 (converts in place) | Already fiscal: the mirror's own 400 ONLY_PROFORMA_CONVERTIBLE; not dedup-backed | n/a (no store) |
| resubmit-fiscal | 200 or 422 | Serialized per invoice (row lock inside the service) so two attempts never overlap; a sequential second call hits the state guards (409 NOT_REJECTED or the terminal state's own refusal); not dedup-backed: the header is a contract check, never replay protection | n/a |
| cancel, restore, mark-applied, recover, reject | mirror behavior | Second call returns the operation's OWN existing refusal (frozen per endpoint below; never claimed as replay-success) | n/a |
| send | mirror behavior | NOT replay-protected: the header is a contract check, not protection; a retry may deliver again and re-debit credits | n/a |
Identity rule for the 409. The stored (business_id, idempotency_key) row must match the requested operation class and, for duplicate, notes and convert, the SOURCE document id. Any mismatch answers 409 IDEMPOTENCY_KEY_REUSED and nothing is written. Cross-table (invoice vs estimate) keys never collide because they live in separate tables. A hard-deleted document frees its key (partial index).
3.4 Rate limits
- Mutations share one limiter: 60 requests per minute per IP.
- The global API limiter (600 requests per minute) also applies to every call.
Both answer 429 with the platform-exception body plus Retry-After.
3.5 Units table (read this before sending money)
Field units differ by endpoint family and by response shape. Each difference is stated explicitly:
| Field | Where | Unit |
|---|---|---|
items[].unitPrice | request (documents) | display currency units (for example dollars), exactly like the app's wire. Precision stays sub-cent until the service converts it; the API layer never pre-rounds. |
discount | request (documents) | currency units ONLY when discountType = "fixed"; otherwise a percentage value |
items[].taxRate | request (documents) | percentage, 0 to 100 |
subtotal, taxAmount, total, amountPaid, amountDue, item prices, payment amounts, refund amounts | response detail and edit bodies | display currency units |
the same values with a Cents suffix (totalCents, amountDueCents, and so on) | response detail and edit bodies | integer cents, ALONGSIDE the display-unit fields above |
total, amountDue | response list rows | display currency units |
note.total | create credit/debit note response | display currency units |
price_cents, amount_cents, total_cents, costCents | intake endpoints (/products, /sales, subscriptions, webhooks) | integer cents |
issueDate, dueDate, validUntil | request and response | calendar date YYYY-MM-DD |
created_at, paid_at, refunded_at and other metadata timestamps | response | ISO-8601 UTC |
A display-unit value and its *Cents sibling are two honest views of the same integer cents; they are never re-derived from each other. Calendar dates are stored as calendar days, not instants. Metadata timestamps are UTC instants.
3.6 Frozen error codes
These machine codes are part of the contract and will not be renamed:
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.
Three frozen guards are worth calling out because their code is delivered while the mirror sometimes carried only text:
- DELETE invoice guard:
400 INVOICE_NOT_DELETABLE - cancel guard:
400 INVOICE_NOT_CANCELLABLE - convert a non-proforma to fiscal:
400 ONLY_PROFORMA_CONVERTIBLE
4. Intake endpoints (connector family)
These endpoints use snake_case field names and integer cents. They are the same connector contract the app documents in the website-connector guide; they share the key and the ?businessId= binding.
GET /api/v1/partner/products
Returns the products your site may sell. Prices are integer cents in the business's document currency.
{
"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 is one of weekly, biweekly, monthly, quarterly, yearly (null for one-off products).
POST /api/v1/partner/sales (requires Idempotency-Key)
Records a sale. Body 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" means your site already collected the money; reference is then required and is the fence against recording the same money twice (a repeat reference, even with a different Idempotency-Key, answers 409 DUPLICATE_PAYMENT_REFERENCE). issue_fiscal: true asks Cowisar to issue the fiscal document right away.
Response 201 (first time) / 200 (replay):
{ "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 }
Errors: 400 INVALID_INPUT, 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_ITEMS (unknown or foreign product), 409 DUPLICATE_PAYMENT_REFERENCE, 503 INVOICE_LIMIT_REACHED.
Note the difference in shape from the document endpoints: intake answers use invoice_id / invoice_number / total_cents (snake_case, integer cents), while the document endpoints answer with {success, invoice{...}} (camelCase, display units alongside *Cents).
The rest of the connector family (customers, subscriptions, support reports) keeps the same key and binding:
POST /api/v1/partner/customers(requiresIdempotency-Key): register or link a customer, returnsclient_idandcustomer_uuid.GET /api/v1/partner/customers/:customer_uuidPOST /api/v1/partner/subscriptions(requiresIdempotency-Key)GET /api/v1/partner/subscriptions/:idPOST /api/v1/partner/subscriptions/:id/pause,/resume,/cancel(each requiresIdempotency-Key)POST /api/v1/partner/reports(requiresIdempotency-Key),GET /api/v1/partner/reports/:id,POST /api/v1/partner/reports/:id/messages
Support reports ship off by default for a business. While the support lane is off, the report endpoints answer 423 Locked; the owner enables it in Ajustes before any report can be filed.
GET /api/v1/web/connector/* (owner key management)
The app's own surface, driven with a normal owner session (?businessId= on every call). It is documented because a partner integrating deeply may want to automate key rotation; it is not part of the website integration contract.
| method + path | returns |
|---|---|
GET /keys | { keys: [{ id, created_at, last_used_at, rotated_at, active, masked }] } |
POST /keys | 201 { key, key_hint } once, or 409 KEY_EXISTS |
POST /keys/rotate | 200 { key } once (404 KEY_NOT_FOUND when there is none) |
POST /keys/deactivate | 200 { active: false, changed } |
GET /endpoints | { endpoints: [{ id, url, enabled_events, active, description, created_at, updated_at }] } |
POST /endpoints | 201 { endpoint, secret } once (400 INVALID_URL / 400 INVALID_ENABLED_EVENTS) |
PATCH /endpoints/:id | 200 { endpoint } (400 NO_FIELDS, 404 ENDPOINT_NOT_FOUND) |
DELETE /endpoints/:id | 200 { deleted: true } |
GET /event-samples | { headers, events }: one sample envelope per catalog event |
POST /endpoints/:id/test | 202 { event_id, delivery_id }; body {event_type} optional, default ping |
GET /deliveries?endpoint_id=&limit=50 | { deliveries: [...] } (limit default 50, max 200) |
POST /deliveries/:id/redeliver | 200 { delivery_id, status: "pending" } |
5. Invoices
All invoice endpoints live under /api/v1/partner/invoices. Every one requires ?businessId= and the x-service-token header. Mutating routes (POST, PUT, DELETE) require Idempotency-Key.
Fields are camelCase; detail and edit bodies carry display-unit money alongside *Cents (see the units table).
GET /api/v1/partner/invoices
List invoices. Query: page, limit (max 50), search, status, statusNot, invoiceType, insurance, referenceInvoiceId, createdBy, createdByNot, origin.
Response 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 }
}
List rows carry display-unit total and amountDue. The web stats block is omitted from this surface.
GET /api/v1/partner/invoices/:invoiceId
Read one invoice, including refundable and 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 (requires Idempotency-Key)
Create an invoice. Body fields include 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. Each item carries description, quantity, unitPrice, productId, taxRate.
orderType accepts "dine_in", "takeout", or absent/null. Only "dine_in" carries the statutory 10% Propina Legal tip on the stored document; "takeout" and absent never do. Any other value is rejected with 400 INVALID_INPUT (the same rule applies to the PUT /edit door).
items[].unitPrice is display currency units. discount is currency units only when discountType = "fixed", otherwise a percentage.
{
"clientName": "Ana Perez", "clientPhone": "+18095551234",
"items": [{ "description": "Cafe 454g", "quantity": 2, "unitPrice": 380.00, "taxRate": 18 }],
"invoiceType": "proforma", "dueDate": "2026-11-01"
}
Response 201:
{ "success": true,
"invoice": { "id": 912, "invoiceNumber": "F-000912", "invoiceType": "proforma",
"totalCents": 76000, "status": "pending", "clientId": 77 } }
Replay per the Replay matrix (200 plus deduplicated:true). Errors include 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 (requires Idempotency-Key)
Update an editable proforma's lines and totals. Request: items (required), taxRate, discount, discountType, taxInclusive, tableIdentifier.
{ "items": [{ "description": "Cafe 454g", "quantity": 3, "unitPrice": 380.00 }], "taxRate": 18 }
Response 200:
{ "success": true, "invoice": { "id": 912, "invoiceNumber": "F-000912", "totalCents": 114000 } }
Errors: 422 INVALID_STATE when the document is not editable, 404 NOT_FOUND.
PUT /api/v1/partner/invoices/:invoiceId/edit (requires Idempotency-Key)
Edit an existing invoice in place (proforma full edit, proforma to fiscal in-place convert, or fiscal scalar-only edit). Request spreads the body and accepts issueDate, dueDate, fiscalDocumentType, clientId, clientPhone, sellerId plus the create fields.
{ "issueDate": "2026-10-03", "dueDate": "2026-11-01", "fiscalDocumentType": "E32", "clientId": 77 }
Response 200: { "success": true, "invoice": { ... } }. A fiscal failure returns 422 with {error, invoice{id, invoiceType}}.
DELETE /api/v1/partner/invoices/:invoiceId (requires Idempotency-Key)
Delete a proforma (or a rejected) invoice. Only proforma and rejected documents are deletable; a document with registered payments is refused.
Response 200: { "success": true }.
Frozen guard: 400 INVOICE_NOT_DELETABLE.
GET /api/v1/partner/invoices/:invoiceId/pdf
Download the invoice PDF. Real bytes with Content-Disposition and render identity headers (X-Render-Id, X-Render-Input-Hash, X-Document-Sha256, X-Document-Format, X-Document-Language).
POST /api/v1/partner/invoices/:invoiceId/duplicate (requires Idempotency-Key)
Duplicate an invoice. No body.
Response 200: { "success": true, "newInvoiceId": 913 }. Replay per matrix. 403 INVOICE_LIMIT_REACHED on limit.
POST /api/v1/partner/invoices/:invoiceId/convert-to-fiscal (requires Idempotency-Key)
Convert a proforma to a fiscal document. Body: fiscalDocumentType?, clientTaxId?, clientName?, affectsInventory?.
{ "fiscalDocumentType": "E32", "clientTaxId": "101010101" }
Response 200:
{ "success": true, "fiscalNumber": "B0100000001",
"invoice": { "id": 912, "invoiceNumber": "F-000912", "totalCents": 76000 } }
Already fiscal answers 400 ONLY_PROFORMA_CONVERTIBLE. A fiscal failure answers 422 with {error, invoice{id, invoiceType}}; DGII_REJECTED carries reason, PRESEND_BLOCKED carries code plus blockedPresend:true.
POST /api/v1/partner/invoices/:invoiceId/resubmit-fiscal (requires Idempotency-Key)
Resubmit a DGII-rejected fiscal invoice. No body. The service serializes per invoice (row lock) so two attempts never overlap. Not dedup-backed: check state, do not blind-retry.
Response 200: { "success": true, "fiscalNumber": "B0100000001" }. 409 NOT_REJECTED when the invoice is not a resubmit candidate; otherwise 422 with the same split codes as convert-to-fiscal.
POST /api/v1/partner/invoices/:invoiceId/credit-note (requires Idempotency-Key)
Create a credit note against an issued fiscal invoice. Request: items (required), reason?, modificationCode?, affectsInventory? (default true).
{ "items": [{ "description": "Cafe 454g", "quantity": 1, "unitPrice": 380.00, "taxRate": 18 }], "reason": "Devolucion" }
Response 201:
{ "success": true,
"note": { "id": 950, "invoiceNumber": "NC-000950", "total": 380.00, "status": "applied" },
"noteNumber": "B0100000002" }
note.total is display currency units. Replay per matrix. Errors include 400 ITEMS_REQUIRED, 400 INVALID_ITEM, and note guards carrying details.
POST /api/v1/partner/invoices/:invoiceId/debit-note (requires Idempotency-Key)
Create a debit note. Request identical to credit-note, except affectsInventory defaults to false.
Response 201: { "success": true, "note": {...}, "noteNumber": "..." }. Replay per matrix.
POST /api/v1/partner/invoices/:invoiceId/cancel (requires Idempotency-Key)
Cancel an invoice. Body: { reason? }.
Response 200: { "success": true }.
Frozen guard: 400 INVOICE_NOT_CANCELLABLE when the status is not cancellable. A second call returns the operation's own refusal; it is never claimed as replay-success.
POST /api/v1/partner/invoices/:invoiceId/restore (requires Idempotency-Key)
Restore a cancelled proforma. No body.
Response 200: { "success": true, "restoredStatus": "pending" }. A second call returns the operation's own refusal.
POST /api/v1/partner/invoices/:invoiceId/mark-applied (requires Idempotency-Key)
Mark a pending credit note as applied. No body.
Response 200: { "success": true }. A missing or foreign invoice answers the same 404 NOT_FOUND.
POST /api/v1/partner/invoices/:invoiceId/send (requires Idempotency-Key)
Send an invoice to the client. Body: { recipientPhone?, channel?, recipientEmail? } (channel one of whatsapp, email, both).
Response 200: { "success": true, "deliveryMethod": "business_whatsapp" }.
The send is NOT replay-protected: the header is a contract check, not protection; a retry may deliver again and re-debit credits. A genuine DGII-rejected document is refused 403 FISCAL_REJECTED (with blockedPresend for a pre-send block). Insufficient credits answer 402 with costCents and balanceCents; delivery quota answers 429 QUOTA_EXCEEDED; other delivery failures answer 422. shared_to_owner is owner delivery, never proof of client receipt.
6. Estimates
All estimate endpoints live under /api/v1/partner/estimates. Every one requires ?businessId= and the x-service-token header. Mutating routes require Idempotency-Key.
GET /api/v1/partner/estimates
List estimates. Query: page, limit, search, status, statusNot, bucket, bucketNot, createdBy, createdByNot.
Response 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
Read one estimate, including creator fields (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 (requires Idempotency-Key)
Create an estimate. Body includes clientId, clientName, clientTaxId, clientPhone, clientEmail, clientAddress, items[], validUntil, discount, discountType, taxRate, taxInclusive, notes, terms, footerText, language, currency, exchangeRate, baseCurrency, paymentCondition, paymentTermsDays, autoCreateIsService. Each item carries description, quantity, unitPrice, productId, taxRate, is_service, discount, discountType.
items[].unitPrice is display currency units. A nonzero per-line item discount is refused with 400 INVALID_ITEM (a per-line discount other than zero is not supported on estimates).
{
"clientName": "Ana Perez", "clientPhone": "+18095551234",
"items": [{ "description": "Cafe 454g", "quantity": 2, "unitPrice": 380.00, "taxRate": 18 }],
"validUntil": "2026-11-01"
}
Response 201:
{ "success": true, "estimate": { "id": 55, "estimateNumber": "P-000055" } }
Replay per matrix (200 plus deduplicated:true). 403 ESTIMATE_LIMIT_REACHED on limit.
PUT /api/v1/partner/estimates/:id (requires Idempotency-Key)
Update an estimate header and/or line items. Body optional items (must be a non-empty array when present), 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 }
Response 200: { "success": true, "estimate": { ... } }. validUntil null is refused with 400 INVALID_INPUT (never silently dropped). Errors: 400 ITEMS_REQUIRED, 400 INVALID_ITEM, 404 NOT_FOUND.
DELETE /api/v1/partner/estimates/:id (requires Idempotency-Key)
Hard-delete a draft estimate (remove a mistake). A converted estimate is blocked.
Response 200: { "success": true }. Errors: 404 NOT_FOUND, and the converted-block refusal.
GET /api/v1/partner/estimates/:id/pdf
Download the estimate PDF. Query ?format selects the layout (receipt for the 80mm thermal layout). Real bytes with the same render identity headers as the invoice PDF.
POST /api/v1/partner/estimates/:id/duplicate (requires Idempotency-Key)
Duplicate an estimate. No body.
Response 200: { "success": true, "estimate": { "id": 56, "estimateNumber": "P-000056" } }. Replay per matrix.
POST /api/v1/partner/estimates/:id/convert-to-invoice (requires Idempotency-Key)
Convert an estimate to an invoice. Body: { invoiceType?, fiscalDocumentType? }. Always creates a proforma; when invoiceType = "fiscal" it chains through the fiscal conversion.
{ "invoiceType": "proforma" }
Response 200:
{ "success": true, "invoiceId": 912, "invoiceNumber": "F-000912" }
Replay per matrix (200 plus deduplicated:true). Transitioning the estimate and copying the invoice happen in the same transaction so overlapping calls cannot double-create.
Which 422s have already committed a document. If the fiscal leg fails, the proforma invoice has already been created and the estimate has already been marked converted. In that case the response is 422 WITH invoiceId and invoiceNumber:
{ "success": false, "error": "DGII_REJECTED", "reason": "...", "invoiceId": 912, "invoiceNumber": "F-000912" }
A rejection BEFORE the proforma exists (a pre-fiscal client validation, for example a missing client RNC) returns 422 WITHOUT invoiceId; nothing was committed.
POST /api/v1/partner/estimates/:id/send (requires Idempotency-Key)
Send an estimate to the client. Body: { recipientPhone?, channel?, recipientEmail? } (channel one of whatsapp, email, both; default whatsapp). A recipient is required for the chosen channel (recipientPhone for WhatsApp).
Response 200: { "success": true, "deliveryMethod": "business_whatsapp" }.
A missing or foreign id answers 404 before any test-business shortcut. A bad channel answers 400 INVALID_INPUT. Insufficient credits answer 402 with costCents and balanceCents; quota answers 429 QUOTA_EXCEEDED; other failures answer 422. Both-channel partial success and owner fallback are honored. A converted-estimate resend preserves its converted status and invoice link. NOT replay-protected.
POST /api/v1/partner/estimates/:id/recover (requires Idempotency-Key)
Recover a rejected estimate (reopen as sent). No body.
Response 200: { "success": true, "estimate": { ... } }. A second call returns the operation's own refusal.
POST /api/v1/partner/estimates/:id/reject (requires Idempotency-Key)
Mark an estimate as rejected. Body: { reason? }.
Response 200: { "success": true }. A second call returns the operation's own refusal.
7. Webhooks
Cowisar POSTs a signed JSON envelope to your registered URL when something happens (a sale was paid, a subscription was cancelled, a customer was created). Register an endpoint in Ajustes -> Conexion de sitio web -> Webhooks, choose which events it wants, and copy the signing secret (shown once).
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" } }
A test delivery carries test: true at the envelope's top level; a real event omits the key entirely.
Event catalog (v1)
| type | data |
|---|---|
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 } |
Verify the signature
v1 is HMAC-SHA256(secret, "<t>.<raw body>"), hex, over the exact bytes you received. Compare in constant time and reject a t older than a few minutes. Parse the JSON only AFTER verifying the raw body.
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);
}
Delivery, retries and the log
- One delivery row per (event, endpoint).
2xx= delivered; anything else (or a timeout after 10s) is a failed attempt. - Retries follow
[1m, 5m, 30m, 2h]; the fifth failed attempt marks the delivery failed and stops retrying. The endpoint is never disabled automatically. - Delivery is at-least-once, not exactly-once. The same envelope (same
id) can arrive more than once. Dedupe on the envelopeidbefore applying anything, and answer2xxonce you have durably recorded it. - Emission is deduplicated by an internal key, so a retried business operation produces one event row.
- Emission is not transactional with the business write. The document or sale commits first; if enqueuing the event fails, the write still succeeds and only a warning is logged. Treat webhooks as notifications, not as a guarantee that a matching event exists for every change, and reconcile with the read endpoints when it matters.
8. Copy for LLM
This page is the canonical contract. The runtime asset is src/api/docs/partner-api.md, served as HTML at https://api.cowisar.com/docs and as raw markdown at https://api.cowisar.com/docs/api.md. The "Copy for LLM" button copies this exact markdown so you can hand the whole contract to your AI assistant and build against it. llms.txt and llms-full.txt are also published at the host root.