Probar tu integración sin facturas reales
Valida una factura sin emitirla, descarga un PDF de prueba sin validez fiscal, ensaya una rectificativa y recibe avisos de ejemplo firmados como los de verdad, antes de emitir tu primera factura real.
Actualizado el . 17 min de lectura
Incluido en todos los planes. Ver precios
Cada factura que tu aplicación emite con POST /external/v1/invoices es un documento fiscal de verdad: lleva un número de la serie de tu organización y no se puede borrar. Esta página explica cómo comprobar todo lo que hace tu integración (el cálculo de la factura, su PDF, las rectificativas y los avisos a tu servidor) sin emitir ninguna, y en qué orden pasar después a la primera factura real.
En el panel tienes lo mismo en una pestaña: Gestión de proyectos, Aplicaciones externas, tu aplicación, Pruebas. Allí ves qué le falta a la aplicación antes de facturar, copias las peticiones de prueba ya rellenas y envías avisos de ejemplo a tu servidor.
Las pruebas nunca usan la serie real
La numeración de las facturas es correlativa dentro de cada serie (art. 6 del Real Decreto 1619/2012, que regula las obligaciones de facturación). Una factura emitida no se borra ni se renumera: si estaba mal, se corrige con una rectificativa, que es otro documento con su propio número. Si además tu organización usa VeriFactu, cada factura queda encadenada en su registro.
Por eso no existe una «serie de pruebas» dentro de la API real ni un modo sandbox que emita facturas falsas: una factura emitida para probar sería una factura de verdad, con su hueco o su rectificativa. Las pruebas pasan por tres rutas que calculan sin emitir:
| Para probar | Ruta | Qué devuelve |
|---|---|---|
| Una factura | POST /external/v1/invoices/validate | El veredicto y una vista previa de la factura, sin número. |
| Su PDF | POST /external/v1/invoices/validate/pdf | Un PDF de muestra marcado como documento de prueba. |
| Una rectificativa | POST /external/v1/invoices/:id/rectifications/validate | El veredicto y una vista previa de la rectificativa. |
Ninguna reserva número, escribe en la base de datos, envía correos ni dispara avisos. Puedes llamarlas tantas veces como quieras.
Los ejemplos que emiten de verdad
El guion 01-create-invoice.ts de examples/04-node-billing-integration y external-issue-invoice.ts de examples/sdk-js llaman a POST /external/v1/invoices: emiten una factura real y numerada. Para probar, cambia sdk.invoices.create por sdk.invoices.validate.
Validar una factura sin emitirla
/api/external/v1/invoices/validateComprueba una factura como si fuera a emitirse y devuelve su vista previa, sin numerarla ni guardarla. Mismo cuerpo que POST /external/v1/invoices. Basta con cualquier permiso crm:invoices:*.
El cuerpo es exactamente el mismo que el de crear una factura: manda lo que mandarías para emitirla y verás lo que pasaría. Cómo se comporta:
- Autenticación: la cabecera
X-Api-Keycon la clave de tu aplicación, como el resto de la API externa. - Permisos: basta con que la aplicación tenga concedido cualquier permiso
crm:invoices:(por ejemplo, solocrm:invoices:read). Sin ninguno,403 INSUFFICIENT_SCOPE. Así puedes probar antes de pedir los permisos de emisión. - No exige la facturación encendida. Puedes probar antes de activarla; mientras esté apagada, la respuesta lo dice como bloqueo (
BILLING_NOT_ENABLED). - No emite nada: no reserva número, no escribe, no envía el correo, no dispara avisos y no consume la clave de idempotencia.
idempotencyKeyse acepta y se ignora, así que puedes probar con el cuerpo definitivo. - Responde siempre
200con el resultado de negocio, también cuando la factura no se podría emitir: el motivo viaja enblockers. Solo un cuerpo mal formado (un campo obligatorio que falta, un tipo que no es el suyo) responde400 VALIDATION_ERROR, igual que en el alta.
Ejemplos
curl -X POST https://os.utilia.ai/api/external/v1/invoices/validate \
-H "X-Api-Key: utl_live_..." \
-H "Content-Type: application/json" \
-d '{
"user": {
"externalId": "user_1234",
"email": "cliente@example.com",
"name": "Ana Pérez"
},
"type": "SIMPLIFIED",
"taxSystem": "IGIC",
"lines": [
{
"name": "Plan Premium mensual",
"quantity": 1,
"unitPrice": 28.04,
"taxType": "IGIC_7"
}
],
"payment": { "method": "CARD", "reference": "pago-1234" },
"sendEmail": true,
"idempotencyKey": "order_1234-invoice",
"metadata": { "orderId": "order_1234" }
}'Con el SDK, el mismo objeto sirve para validate y, cuando todo cuadre, para create: no tienes que mantener dos cuerpos.
Respuesta (200)
{
"valid": true,
"livemode": false,
"blockers": [],
"warnings": [],
"preview": {
"type": "SIMPLIFIED",
"seriesCode": "DEFAULT",
"numberFormat": "T-AAAA00000",
"displayNumberFormat": "T-AAAA00000",
"issueDate": "2026-10-19",
"taxSystem": "IGIC",
"currency": "eur",
"subtotal": 28.04,
"totalTax": 1.96,
"totalAmount": 30,
"lines": [
{
"name": "Plan Premium mensual",
"quantity": 1,
"unitPrice": 28.04,
"discount": 0,
"subtotal": 28.04,
"taxType": "IGIC_7",
"taxRate": 7,
"taxAmount": 1.96,
"total": 30,
"exemptionReason": null,
"exempt": false
}
],
"taxes": [
{ "taxType": "IGIC_7", "taxRate": 7, "taxBase": 28.04, "taxAmount": 1.96 }
],
"legalMentions": ["Factura simplificada (RD 1619/2012 art. 7)"],
"recipient": {
"name": "Consumidor final",
"taxId": null,
"address": null,
"city": null,
"postalCode": null,
"country": null
},
"payment": { "willRegister": true, "method": "CARD", "paidAt": null },
"email": { "willSend": true, "recipient": "cliente@example.com", "reason": null }
}
}- validbooleanobligatorio
- true si POST /external/v1/invoices emitiría esta factura tal cual. false si hay al menos un bloqueo.
- livemodebooleanobligatorio
- Siempre false: es una prueba y no tiene efecto fiscal.
- blockersarrayobligatorio
- Lo que impediría emitir, cada uno con code, message y, a veces, details. El code es el mismo errorCode que respondería el alta (por ejemplo SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED o INVOICE_RECIPIENT_INCOMPLETE con missingFields en details), más BILLING_NOT_ENABLED (la facturación de la aplicación está apagada), INSUFFICIENT_SCOPE (faltan los permisos de emisión) y APP_RESPONSIBLE_INACTIVE (la aplicación no tiene una persona responsable activa).
- warningsarrayobligatorio
- Avisos que no impiden emitir pero conviene leer, cada uno con code y message.
- previewobjeto | nullobligatorio
- La factura que se emitiría. Con bloqueos puede llegar a null: no te fíes de ella hasta que valid sea true.
- preview.numberFormatstringobligatorio
- Forma del número que recibirá, sin el número en sí (AAAA es el año y los ceros, la secuencia). Nunca se reserva un número en una prueba.
- preview.displayNumberFormatstringobligatorio
- La forma del número tal como se imprime en el PDF: es la misma que numberFormat (el número se imprime exactamente como se registra).
- preview.issueDatestringobligatorio
- Fecha de expedición que tendría (AAAA-MM-DD), en la zona horaria de tu organización.
- preview.subtotal / totalTax / totalAmountnumberobligatorio
- Base, cuota y total, redondeados como los redondea la emisión real.
- preview.linesarrayobligatorio
- Cada línea con su base, su tipo, su cuota, su total y, si está exenta, la causa de la exención.
- preview.taxesarrayobligatorio
- El desglose por tipo de impuesto que se imprimirá.
- preview.legalMentionsstring[]obligatorio
- Las menciones legales que llevará el PDF, en su orden.
- preview.recipientobjetoobligatorio
- El destinatario tal como se imprimirá.
- preview.paymentobjeto | nullobligatorio
- Si mandas el bloque payment, el cobro que se registraría al emitir (willRegister, method y paidAt). null sin él.
- preview.emailobjetoobligatorio
- willSend dice si saldría el correo, recipient a qué dirección y reason, si no sale, por qué: NOT_REQUESTED (no lo pides y la aplicación no lo envía por defecto), SEND_SCOPE_MISSING (falta crm:invoices:send) o NO_RECIPIENT (no hay dirección a la que enviarlo).
Una factura que no se podría emitir responde también 200, con valid: false:
{
"valid": false,
"livemode": false,
"blockers": [
{
"code": "BILLING_NOT_ENABLED",
"message": "La facturación de esta aplicación está desactivada: la emisión respondería 403 BILLING_NOT_ENABLED. Actívala en la pestaña Facturación de la aplicación («Activar la facturación»)."
},
{
"code": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
"message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria."
}
],
"warnings": [],
"preview": null
}La facturación apagada o un permiso de emisión que falte no impiden
calcular la vista previa: con solo esos bloqueos, preview llega
completa. Llega en null cuando el propio
documento no se puede calcular, como en el ejemplo, por el importe de la
simplificada. El único aviso que hoy puede traer warnings es
SEND_SCOPE_GRACE: la aplicación no tiene crm:invoices:send y, por
ahora, el correo saldría igual.
Cuando valid sea true con el cuerpo definitivo, ese mismo cuerpo emitirá la factura con POST /external/v1/invoices, salvo que algo cambie entre las dos llamadas (la facturación se apaga, un permiso se retira o la fecha de la última factura de la serie avanza).
PDF de prueba
/api/external/v1/invoices/validate/pdfGenera el PDF de muestra de la factura sin emitirla: número PRUEBA, sin código QR y con la marca de agua DOCUMENTO DE PRUEBA · SIN VALIDEZ FISCAL. Mismo cuerpo y mismos permisos que /invoices/validate.
Sirve para ver cómo quedará el documento con la marca, los datos fiscales y la plantilla de tu organización. Responde application/pdf con una muestra sin validez fiscal:
- En lugar del número lleva la palabra «PRUEBA».
- No lleva el código QR de VeriFactu: no hay ningún registro que comprobar.
- Lleva en diagonal la marca de agua «DOCUMENTO DE PRUEBA · SIN VALIDEZ FISCAL».
No lo entregues a tus usuarios ni lo guardes como factura. La facturación apagada o un permiso de emisión que falte no impiden la muestra. Si el propio documento no se puede calcular, no hay PDF: la respuesta es 422 con el error de siempre, el primer bloqueo que daría /invoices/validate como errorCode y la lista completa en blockers:
{
"statusCode": 422,
"message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria.",
"error": "UNPROCESSABLE_ENTITY",
"timestamp": "2026-10-19T10:00:00.000Z",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"errorCode": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
"blockers": [
{
"code": "SIMPLIFIED_INVOICE_AMOUNT_EXCEEDED",
"message": "Una factura simplificada no puede superar 400 € impuestos incluidos. Emite una ordinaria."
}
]
}import { writeFile } from 'node:fs/promises';
// `factura` es el mismo objeto que pasas a `sdk.invoices.validate`.
const bytes = await sdk.invoices.validatePdf(factura); // Uint8Array
await writeFile('factura-de-prueba.pdf', bytes);Rectificativas de prueba
/api/external/v1/invoices/:id/rectifications/validateComprueba la rectificativa de la factura :id sin emitirla ni registrar la devolución. Mismo cuerpo y mismos permisos que POST /external/v1/invoices/:id/rectifications.
Antes de devolver dinero de verdad, comprueba qué rectificativa saldría. El cuerpo es el de emitir una rectificativa y se comprueban sus mismos permisos: crm:invoices:rectify, además crm:invoices:write si mandas refund y crm:invoices:send si pides sendEmail: true. Si falta alguno, la respuesta sigue siendo 200, con el bloqueo INSUFFICIENT_SCOPE. :id es una factura real ya emitida por tu aplicación: la prueba no la toca, no crea la rectificativa ni registra la devolución.
La respuesta tiene la misma forma que /invoices/validate, con una vista previa propia. Si la factura no admite todavía la rectificativa (por ejemplo, porque su emisión sigue en curso), el bloqueo es RECTIFICATION_NOT_ALLOWED con su motivo y, si basta con esperar, retryable y retryAfterSeconds en details. Si la idempotencyKey ya tiene una operación, warnings trae IDEMPOTENT_REPLAY y preview llega en null. Todos los motivos están en Cuándo se puede rectificar.
{
"valid": true,
"livemode": false,
"blockers": [],
"warnings": [],
"preview": {
"cause": "REFUND",
"rectificationCode": "R5",
"totalAmount": -30,
"remainingAfter": 0,
"issueDate": "2026-10-19",
"refund": null,
"willSendEmail": false
}
}- preview.causeenumobligatorio
- La causa que mandaste: REFUND o CHARGEBACK.
- preview.rectificationCodestringobligatorio
- El código legal que decidiría UTILIA OS (R5 para una simplificada, R1 para una ordinaria).
- preview.totalAmountnumberobligatorio
- Total de la rectificativa, impuestos incluidos, en negativo.
- preview.remainingAfternumberobligatorio
- Lo que quedaría por rectificar de la factura original después de esta.
- preview.issueDatestringobligatorio
- Fecha de expedición que tendría (YYYY-MM-DD, en la zona horaria de tu organización).
- preview.refundobjeto | nullobligatorio
- La devolución que se registraría si mandas el bloque refund: reference, method, amount y refundedOn. null sin él.
- preview.willSendEmailbooleanobligatorio
- Si la rectificativa se enviaría por correo al emitirla.
Con el SDK:
const prueba = await sdk.invoices.rectifications.validate('cksimp1234567890', {
userId: 'user_1234',
idempotencyKey: 'devolucion-order_1234',
cause: 'REFUND',
});
if (prueba.valid) {
// Ahora sí: la misma llamada con create emite la rectificativa.
}En Python, await sdk.invoices.rectifications.validate(factura_id, datos) con el mismo CreateExternalInvoiceRectificationInput que usarías para create.
Avisos de prueba
Para comprobar que tu servidor recibe, verifica y entiende los avisos de facturación, no hace falta emitir nada: UTILIA OS te envía un aviso de ejemplo del evento que elijas.
- Desde el panel: Gestión de proyectos, Aplicaciones externas, tu aplicación, Pruebas, bloque «Probar los avisos a tu servidor». Eliges el evento, pulsas enviar y ves la respuesta de tu servidor.
- Desde la API del panel (con tu sesión, no con la clave de la aplicación):
POST /api/integrations/apps/:id/webhook/testcon el evento en el cuerpo. Sin cuerpo envía elWEBHOOK_TESTde siempre, el de Probar la conexión.
/api/integrations/apps/:id/webhook/testEnvía a la dirección guardada de la aplicación un aviso de ejemplo del evento indicado, firmado como una entrega real. Permiso: EXTERNAL_APPS_EDIT o INTEGRATIONS_WEBHOOKS_MANAGE.
El evento tiene que estar en el catálogo de eventos que ofrece el plan de tu organización (el mismo de la pestaña Avisos a tu servidor). Un nombre que no existe responde 400 VALIDATION_ERROR, y uno de un aplicativo que tu plan no incluye, 403 MODULE_NOT_ENABLED. No hace falta que la aplicación esté suscrita al evento para recibir la muestra; la respuesta te dice si lo está, porque sin suscripción los avisos reales de ese evento no te llegarán. Como en Probar la conexión, la prueba no sale si los avisos están desactivados o no hay dirección guardada, y no queda en el historial de entregas ni se reintenta.
Cómo es un aviso de ejemplo
Se firma exactamente como una entrega real, con la clave de firma vigente de la aplicación y las mismas cabeceras: X-Utilia-Signature, X-Utilia-Webhook-Signatures, X-Utilia-Timestamp, X-Utilia-Event, X-Utilia-Schema-Version y X-Utilia-Delivery-Id, que en las pruebas empieza por test_. Si tu servidor acepta la muestra, aceptará los avisos reales.
El cuerpo lleva dos marcas que los avisos reales no llevan: livemode: false en el sobre y test: true en meta. Los identificadores son inventados y llevan test en el nombre (inv_test_…), el número de factura es PRUEBA-202600001 y los importes son pequeños y redondos:
{
"event": "INVOICE_CREATED",
"timestamp": "2026-10-19T10:00:00.000Z",
"app": { "id": "ckapp1234567890", "slug": "mi-integracion" },
"livemode": false,
"data": {
"invoice": {
"id": "inv_test_8f3k2m1q",
"invoiceNumber": "PRUEBA-202600001",
"displayNumber": "PRUEBA-202600001",
"type": "SIMPLIFIED",
"seriesCode": "DEFAULT",
"taxSystem": "IGIC",
"consumerCountryCode": null,
"ossScheme": null,
"status": "ISSUED",
"totalAmount": 30,
"paidAmount": 0,
"currency": "eur",
"issueDate": "2026-10-19T00:00:00.000Z",
"dueDate": null
},
"externalUser": { "externalId": "user_test_1234" },
"metadata": { "orderId": "order_test_1234" }
},
"meta": { "schemaVersion": "2026-05", "test": true }
}Las muestras de los eventos de facturación (INVOICE_*) tienen la misma forma que los avisos reales, campo por campo, para que pruebes tu código de lectura. Las de otros eventos son genéricas: identifican el evento y una entidad de ejemplo, pero no reproducen todos sus campos. El campo sampleShape de la respuesta lo dice: exact o generic. La forma de cada evento está en Catálogo de eventos.
Primero la firma, después la marca de prueba
Comprueba la firma antes de mirar livemode: las marcas viajan dentro del cuerpo firmado, así que solo te puedes fiar de ellas en un aviso con la firma buena. Después, trata como prueba todo aviso con livemode === false o meta.test === true: respóndelo con 2xx y no lo guardes en tus datos de producción, para que una muestra no te dé de alta una factura inventada.
Lo que te devuelve la prueba
El panel pinta el código de respuesta de tu servidor, cuánto tardó y el principio de lo que contestó. La API del panel devuelve:
- successbooleanobligatorio
- true si tu servidor respondió 2xx.
- statusCodenumber
- Código HTTP que devolvió tu servidor. No viaja si no contestó.
- responseTimeMsnumber
- Milisegundos hasta la respuesta.
- errorstring
- Por qué no fue bien: tiempo agotado, redirección, avisos desactivados o clave de firma sin resolver.
- eventstring
- El evento enviado.
- deliveryIdstring
- El X-Utilia-Delivery-Id de la prueba, que empieza por test_.
- subscribedboolean
- Si la aplicación está suscrita a ese evento. Con false, la muestra te llega, pero los avisos reales del evento no.
- sampleShapeenum
- exact (la muestra tiene la forma de un aviso real) o generic.
- payloadobjeto
- El cuerpo exacto que se envió, para que lo copies a tus pruebas.
- responseSnippetstring
- Los primeros 500 caracteres de lo que contestó tu servidor.
Probar tu receptor sin red
Para las pruebas automáticas de tu servidor, los SDK firman un cuerpo en local con la misma cadena que UTILIA OS: sdk.webhooks.signTestPayload(payload, secret) en JavaScript y sign_test_payload(payload, secret) en Python. Devuelven el cuerpo en texto (rawBody), la marca de tiempo, la firma y las cabeceras listas para mandar, y sdk.webhooks.verify acepta el resultado sin más. Usa una clave inventada solo para las pruebas, nunca la de producción.
import request from 'supertest';
import { UtiliaSDK } from '@utilia-os/sdk-js';
import { app } from '../src/servidor'; // tu servidor Express
const CLAVE_DE_PRUEBA = 'whsec_solo_para_pruebas';
// El SDK no llama a la red para firmar ni para verificar.
const sdk = new UtiliaSDK({ baseURL: 'https://os.utilia.ai/api', apiKey: 'utl_prueba' });
const aviso = {
event: 'INVOICE_CREATED',
timestamp: new Date().toISOString(),
app: { id: 'app_test', slug: 'mi-integracion' },
livemode: false,
data: {
invoice: {
id: 'inv_test_1',
invoiceNumber: 'PRUEBA-202600001',
displayNumber: 'PRUEBA-202600001',
totalAmount: 30,
currency: 'eur',
},
externalUser: { externalId: 'user_test_1234' },
},
meta: { schemaVersion: '2026-05', test: true },
};
test('la firma de la muestra se verifica', () => {
const { rawBody, headers } = sdk.webhooks.signTestPayload(aviso, CLAVE_DE_PRUEBA);
const sobre = sdk.webhooks.verify(rawBody, headers, CLAVE_DE_PRUEBA);
expect(sobre.event).toBe('INVOICE_CREATED');
});
test('mi receptor acepta la muestra y no la guarda', async () => {
const { rawBody, headers } = sdk.webhooks.signTestPayload(aviso, CLAVE_DE_PRUEBA);
await request(app)
.post('/webhooks/utilia')
.set(headers)
.set('Content-Type', 'application/json')
.send(rawBody)
.expect(200);
});Cómo se calcula la firma y cómo comprobarla sin los SDK está en Firma HMAC de los avisos.
Recorrido completo
El orden recomendado, de cero a la primera factura real. La pestaña Pruebas de la aplicación tiene esta misma lista para ir marcando: las marcas se guardan solo en tu navegador, no en UTILIA OS, así que en otro equipo o en otra persona empiezan vacías.
- Permisos. Concede a la aplicación lo que va a usar:
crm:invoices:writeycrm:invoices:issuepara emitir,crm:invoices:sendsi envías por correo ycrm:invoices:rectifysi vas a rectificar (este último solo lo concede un Super Administrador). Ver Permisos de la API. - Validar la factura. Llama a
/invoices/validatecon el cuerpo definitivo hasta quevalidseatrueywarningsno diga nada que no esperes. Ver Validar una factura sin emitirla. - PDF de prueba. Descarga la muestra con
/invoices/validate/pdfy revisa los datos fiscales, la plantilla y las menciones. Ver PDF de prueba. - Aviso de ejemplo. Envía un
INVOICE_CREATEDde ejemplo y comprueba que tu servidor verifica la firma, lo reconoce como prueba y responde2xx. Ver Avisos de prueba. - Activar la facturación. Enciende Activar la facturación en la pestaña Facturación de la aplicación (solo un Super Administrador, con motivo) y decide si UTILIA OS envía las facturas por correo. Ver Paso 1 de la guía de facturación.
- Primera factura real, con clave de idempotencia.
POST /external/v1/invoicescon unaidempotencyKeyque salga de tu propio pedido (por ejemplo,order_1234-invoice). Es el primer documento con número. - Reintento con la misma clave. Repite la misma petición con la misma clave y el mismo cuerpo: tiene que devolver la misma factura con
idempotent: true, sin emitir otra. Ver Reintentar sin duplicar la factura. - El correo. Lee
emailDeliveryen la respuesta y actúa según su estado. Ver El correo de la factura. - Cobro con
mark-paid. Si cobras fuera de la pasarela y no mandastepayment, registra el cobro conPOST /external/v1/invoices/:id/mark-paidy comprueba que llegaINVOICE_PAID. Ver Registrar un cobro fuera de la pasarela. - Rectificativa de prueba. Sobre esa factura real, comprueba con
/rectifications/validatequé rectificativa saldría si tuvieras que devolver el dinero. Ver Rectificativas de prueba. - Rectificativa real, solo si hace falta. No emitas una rectificativa para probar: es otro documento fiscal numerado. Emítela solo cuando de verdad devuelvas el dinero o un contracargo deje la venta sin efecto. Ver Facturas rectificativas.
Tiempos de espera y reintentos
Emitir una factura hace varias cosas en el mismo paso (numerarla, registrarla, generar su PDF y, si lo pides, registrar el cobro y enviar el correo), y a veces tarda varios segundos. Pon a tu cliente HTTP un tiempo de espera de al menos 30 segundos para POST /external/v1/invoices; uno más corto corta peticiones que sí terminan.
Si la petición se agota o responde 5xx, no sabes si la factura llegó a emitirse. Repítela con la misma idempotencyKey y el mismo cuerpo: si ya se emitió, recibes esa factura con idempotent: true; si la primera petición sigue en marcha, la misma factura con issuanceDeliveryStatus: processing (vuelve a consultar con la misma clave en unos segundos); y si no llegó a emitirse, se emite ahora. Nunca reintentes con una clave nueva: para UTILIA OS sería otra factura, con otro número, y tendrías que rectificar la que sobra.
Para que eso sea posible, calcula la clave a partir de tu pedido (no al azar en cada intento) o guárdala junto al pedido antes de la primera llamada. Cuándo merece la pena reintentar y cuándo no está en Errores, identificador de petición y reintentos.
El correo de la factura
La respuesta de una factura real trae dos campos pensados para tu interfaz y tu soporte:
displayNumber: el número tal como se imprime en el PDF. Es el mismo queinvoiceNumber(por ejemplo,T-202600001), el de los libros registro y, si la factura lo lleva, el de su código QR; se mantiene por compatibilidad. Guarda y busca porinvoiceNumbero porid.emailDelivery: qué ha pasado con el correo de la factura. Solo viaja si pediste el correo (sendEmail: true, o sinsendEmailcon el interruptor de la aplicación encendido).
Por ejemplo, mientras el envío se reintenta:
{
"emailDelivery": {
"status": "retrying",
"reason": "MAIL_SERVER_UNAVAILABLE",
"message": "Todavía no se ha enviado por correo porque el servidor de correo no respondía. UTILIA la enviará sola en unos minutos; no hace falta que la reenvíes.",
"attempts": 1,
"lastAttemptAt": "2026-10-19T10:00:04.000Z"
}
}- statusenumobligatorio
- sent, retrying, failed o withheld (ver la tabla de abajo).
- reasonstring
- Código estable del motivo cuando el correo no ha salido. No viaja con sent.
- messagestringobligatorio
- Explicación en castellano, lista para tu registro o para tu equipo de soporte.
- attemptsnumber
- Intentos de envío hechos hasta ahora.
- lastAttemptAtstring
- Fecha y hora del último intento (ISO 8601).
- sentAtstring
- Fecha y hora en que salió el correo, con status sent.
status | Qué significa | Qué haces |
|---|---|---|
sent | El correo ha salido hacia el destinatario, al emitirla o después. | Nada. |
retrying | Un fallo pasajero (el PDF aún se preparaba, el almacén o el servidor de correo no respondían, o se agotó el cupo por hora) y UTILIA OS lo está reintentando solo, durante unas dos horas. | Espera. No la reenvíes ni vuelvas a crear la factura. |
failed | No se ha podido enviar: un fallo definitivo, los reintentos agotados (RETRIES_EXHAUSTED), ninguna dirección de destino (NO_RECIPIENT) o una emisión cortada a mitad del envío (INTERRUPTED). | Resuelve el motivo y envíala con POST /external/v1/invoices/:id/send. |
withheld | No se ha enviado a propósito: falta el permiso crm:invoices:send (SEND_SCOPE_MISSING) o el cobro del bloque payment no se registró (PAYMENT_NOT_REGISTERED). | Concede el permiso, o registra el cobro con mark-paid, y envíala con send. |
La lista completa de motivos y cómo reintenta UTILIA OS están en El correo de la factura. Para ensayarlo sin emitir nada, preview.email de /invoices/validate dice si saldría y a qué dirección.
En ningún caso el estado del correo afecta a la factura: ya está emitida y numerada, y se puede rectificar o anular aunque el correo no haya salido. Un correo que no sale se reenvía; la factura no se vuelve a crear nunca.