Conecta tu aplicación a MailMerge#
MailMerge convierte una plantilla de Microsoft Word en un documento terminado —DOCX o PDF— a partir de los datos que envíe tu aplicación en formato JSON. Todo se hace por HTTP: subes la plantilla una vez y después generas tantos documentos como necesites.
Contratos, pagarés, cotizaciones
Cualquier documento que hoy armas a mano en Word y necesitas producir en serie desde tu sistema.
Un JSON con tus datos
Campos sueltos, filas de tabla, imágenes por URL y hasta fragmentos de HTML con formato.
El archivo, listo
PDF para distribuir o DOCX editable, descargable con una petición autenticada.
Cómo funciona#
El modelo es sencillo: una plantilla es un archivo .docx con campos combinables
incrustados; un documento es el resultado de combinar esa plantilla con un conjunto de datos.
Las plantillas se suben una sola vez y se reutilizan; los documentos se generan bajo demanda.
-
Autentícate
Envía tu correo y contraseña a
/api/v2/auth/loginy guarda elaccess_token. Ese token acompaña a todas las demás llamadas. -
Sube tu plantilla
Envía el
.docxa/api/v2/templates. La API detecta automáticamente los campos combinables y te devuelve la lista enmerge_fields, junto con elidde la plantilla. -
Genera el documento
Llama a
/api/v2/documentscon eltemplate_id, tu referencia interna (ref_id) y los datos. Eliges si quierespdfodocx. -
Descarga el resultado
La respuesta trae el
iddel documento y sudownload_url. Descárgalo con/api/v2/documents/{id}/download.
La generación es síncrona. Cuando POST /api/v2/documents responde
201, el archivo ya está listo: puedes descargarlo de inmediato. El endpoint de estado existe
para quienes prefieran confirmar por polling, pero no es necesario en el flujo normal.
Antes de empezar#
Necesitas dos cosas:
-
Credenciales. Un usuario y contraseña de MailMerge. Si tu empresa ya está dada de alta,
pídeselas a tu administrador; si no, escribe a soporte para abrir la cuenta de tu empresa.
Cada empresa ve únicamente sus propias plantillas y documentos.
¿Te entregaron directamente un token, sin usuario ni contraseña? Entonces sáltate el inicio de sesión y ve a usar un token que ya tienes. - El token de tu aplicación. Si lo que conectas es un producto que atiende a varias empresas —y no una sola integración—, 789 te entrega además un token de aplicación por entorno, junto con la lista de direcciones IP desde las que podrás llamar.
-
Una plantilla de Word con campos combinables. En Word: pestaña
Insertar → Elementos rápidos → Campo… → MergeField, y le das al campo un nombre que puedas
identificar (por ejemplo
nombre_cliente). Puedes repetir el mismo nombre en varias partes del documento: se rellenará en todas ellas con el mismo valor.
Nombra los campos como en tus datos. El nombre del campo combinable en Word debe coincidir,
carácter por carácter, con la clave que envías en merge_data.fields. Es sensible a mayúsculas,
acentos y guiones bajos. Si no estás seguro de cómo quedaron los nombres, súbela y mira
merge_fields en la respuesta.
URLs base y entornos#
Todo el servicio vive en https://mailmerge.789.com.mx. Desarrolla contra el entorno de pruebas
y cambia a producción cuando tu integración esté lista: los endpoints son idénticos, solo cambia la URL base.
| Entorno | URL base | Para qué |
|---|---|---|
| Producción | https://mailmerge.789.com.mx/api/v2 |
La API actual. Documentada en esta página. |
| Pruebas | https://mailmerge.789.com.mx/api/v2/test |
Mismos endpoints y datos de prueba. Úsalo mientras desarrollas. |
Además hay tres recursos útiles, sin autenticación:
| Recurso | URL |
|---|---|
| Swagger UI interactivo | /docs |
| Especificación OpenAPI 3.1 | /openapi.json |
| Estado del servicio | /health |
Los ejemplos de esta página usan $MM (o MM) como URL base. Defínela una vez y
apunta a pruebas mientras desarrollas:
export MM=https://mailmerge.789.com.mx/api/v2/test
Guía rápida en 4 llamadas#
Este es el recorrido completo, de cero a PDF descargado. Cambia el selector de lenguaje en la barra del ejemplo para verlo en cURL, PHP, JavaScript o Python; la selección se aplica a toda la página.
# URL base — usa /api/v2/test mientras desarrollas
MM=https://mailmerge.789.com.mx/api/v2
# El token de tu aplicacion acompana a TODAS las llamadas, login incluido
APP_TOKEN=mmapp_a1b2c3d4_kZ9rTq2WmNp4XeLs7VhB
# 1 · Autenticarte y guardar el token
TOKEN=$(curl -s -X POST "$MM/auth/login" \
-H "X-App-Token: $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"email":"tu-usuario@empresa.mx","password":"TuContrasena123"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# 2 · Subir la plantilla (una sola vez por plantilla)
curl -s -X POST "$MM/templates" \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@contrato.docx' \
-F 'ref_id=CONTRATO-BASE' \
-F 'name=Contrato de arrendamiento' \
-F 'template_type=contract'
# → {"id": 550, "merge_fields": ["nombre_cliente","fecha_contrato","monto_total"], ...}
# 3 · Generar el documento con tus datos
curl -s -X POST "$MM/documents" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"template_id": 550,
"ref_id": "CONTRATO-2026-0187",
"merge_data": {
"fields": {
"nombre_cliente": "Distribuidora Alamo S.A. de C.V.",
"fecha_contrato": "18/09/2026",
"monto_total": "$128,400.00"
}
},
"output_format": "pdf"
}'
# → {"id": 1234, "status": "completed", "download_url": "/api/v2/documents/1234/download", ...}
# 4 · Descargar el archivo generado
curl -s "$MM/documents/1234/download" \
-H "Authorization: Bearer $TOKEN" \
-o contrato-2026-0187.pdf
<?php
// composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
$MM = getenv('MAILMERGE_API') ?: 'https://mailmerge.789.com.mx/api/v2';
$http = new Client(['base_uri' => $MM . '/', 'http_errors' => false]);
// 1 · Autenticarte
$res = $http->post('auth/login', ['json' => [
'email' => 'tu-usuario@empresa.mx',
'password' => 'TuContrasena123',
]]);
$auth = json_decode((string) $res->getBody(), true);
$token = $auth['access_token'];
$auth_header = ['Authorization' => 'Bearer ' . $token];
// 2 · Subir la plantilla (una sola vez por plantilla)
$res = $http->post('templates', [
'headers' => $auth_header,
'multipart' => [
['name' => 'file', 'contents' => fopen('contrato.docx', 'r')],
['name' => 'ref_id', 'contents' => 'CONTRATO-BASE'],
['name' => 'name', 'contents' => 'Contrato de arrendamiento'],
['name' => 'template_type', 'contents' => 'contract'],
],
]);
$plantilla = json_decode((string) $res->getBody(), true);
// $plantilla['merge_fields'] trae los campos detectados en el .docx
// 3 · Generar el documento
$res = $http->post('documents', [
'headers' => $auth_header,
'json' => [
'template_id' => $plantilla['id'],
'ref_id' => 'CONTRATO-2026-0187',
'merge_data' => [
'fields' => [
'nombre_cliente' => 'Distribuidora Alamo S.A. de C.V.',
'fecha_contrato' => '18/09/2026',
'monto_total' => '$128,400.00',
],
],
'output_format' => 'pdf',
],
]);
$documento = json_decode((string) $res->getBody(), true);
// 4 · Descargar el archivo
$http->get("documents/{$documento['id']}/download", [
'headers' => $auth_header,
'sink' => 'contrato-2026-0187.pdf',
]);
// Node.js 18+ o cualquier navegador moderno (fetch nativo)
import { readFile, writeFile } from 'node:fs/promises';
const MM = process.env.MAILMERGE_API ?? 'https://mailmerge.789.com.mx/api/v2';
// 1 · Autenticarte
const login = await fetch(`${MM}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'tu-usuario@empresa.mx',
password: 'TuContrasena123',
}),
});
const { access_token } = await login.json();
const auth = { Authorization: `Bearer ${access_token}` };
// 2 · Subir la plantilla (una sola vez por plantilla)
const form = new FormData();
form.append('file', new Blob([await readFile('contrato.docx')]), 'contrato.docx');
form.append('ref_id', 'CONTRATO-BASE');
form.append('name', 'Contrato de arrendamiento');
form.append('template_type', 'contract');
const plantilla = await fetch(`${MM}/templates`, {
method: 'POST', headers: auth, body: form,
}).then((r) => r.json());
// plantilla.merge_fields trae los campos detectados en el .docx
// 3 · Generar el documento
const documento = await fetch(`${MM}/documents`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
template_id: plantilla.id,
ref_id: 'CONTRATO-2026-0187',
merge_data: {
fields: {
nombre_cliente: 'Distribuidora Alamo S.A. de C.V.',
fecha_contrato: '18/09/2026',
monto_total: '$128,400.00',
},
},
output_format: 'pdf',
}),
}).then((r) => r.json());
// 4 · Descargar el archivo
const pdf = await fetch(`${MM}/documents/${documento.id}/download`, { headers: auth });
await writeFile('contrato-2026-0187.pdf', Buffer.from(await pdf.arrayBuffer()));
# pip install requests
import os
import requests
MM = os.getenv("MAILMERGE_API", "https://mailmerge.789.com.mx/api/v2")
# 1 · Autenticarte
login = requests.post(f"{MM}/auth/login", json={
"email": "tu-usuario@empresa.mx",
"password": "TuContrasena123",
}, timeout=30)
login.raise_for_status()
token = login.json()["access_token"]
auth = {"Authorization": f"Bearer {token}"}
# 2 · Subir la plantilla (una sola vez por plantilla)
with open("contrato.docx", "rb") as f:
plantilla = requests.post(
f"{MM}/templates",
headers=auth,
files={"file": ("contrato.docx", f)},
data={
"ref_id": "CONTRATO-BASE",
"name": "Contrato de arrendamiento",
"template_type": "contract",
},
timeout=60,
).json()
# plantilla["merge_fields"] trae los campos detectados en el .docx
# 3 · Generar el documento
documento = requests.post(f"{MM}/documents", headers=auth, json={
"template_id": plantilla["id"],
"ref_id": "CONTRATO-2026-0187",
"merge_data": {
"fields": {
"nombre_cliente": "Distribuidora Alamo S.A. de C.V.",
"fecha_contrato": "18/09/2026",
"monto_total": "$128,400.00",
},
},
"output_format": "pdf",
}, timeout=120).json()
# 4 · Descargar el archivo
pdf = requests.get(f"{MM}/documents/{documento['id']}/download",
headers=auth, timeout=120)
with open("contrato-2026-0187.pdf", "wb") as f:
f.write(pdf.content)
Sube la plantilla una sola vez. El id que recibes en el paso 2 es
permanente: guárdalo en tu base de datos y reutilízalo en cada generación. Volver a subir el mismo
ref_id con el mismo template_type devuelve 409 Conflict.
Autenticación#
MailMerge usa JWT de tipo Bearer. Según cómo llegues, empieza en un punto distinto:
- Ya te entregaron un token —tu administrador o el equipo de 789 te lo pasó—: ve directo a usar un token que ya tienes. No necesitas nada más.
- Tienes usuario y contraseña: empieza por iniciar sesión para obtener el token, y luego úsalo igual que en el caso anterior.
Usar un token que ya tienes#
Todas las llamadas protegidas piden lo mismo: el access_token en el encabezado
Authorization, con el prefijo Bearer y un espacio. No hay nada que registrar ni
configurar antes; con ese encabezado ya puedes llamar a cualquier endpoint.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Un token, una empresa
Ese token no solo dice quién eres: determina la empresa en cuyo nombre actúas. MailMerge deduce la empresa a partir del usuario dueño del token y filtra todo por ella —las plantillas que listas, los documentos que generas, el directorio donde se guardan—. No existe ningún encabezado ni parámetro para cambiar de empresa dentro de una petición: la empresa es la del token.
Lo que eso significa al integrar:
- Si tu aplicación atiende a varias empresas, necesita un token por cada una y decidir cuál usa en cada operación. No hay forma de que un solo token abarque dos empresas.
- Pedir un recurso de otra empresa devuelve
404, no403: desde fuera, lo ajeno es indistinguible de lo inexistente. - El vínculo se resuelve en cada petición, no queda congelado dentro del token. Si el usuario cambia de empresa o se desactiva, los tokens que ya emitió reflejan el cambio de inmediato, sin reemitir nada.
- Por eso mismo, si un token se filtra: revócalo con
/auth/logoutpara el corte inmediato, y avisa a soporte si hay que desactivar la cuenta entera —eso invalida también elrefresh_token, y surte efecto en la siguiente llamada.
Conviene configurarlo una sola vez en tu cliente HTTP, en lugar de repetirlo en cada llamada:
# Guarda el token en una variable de entorno y reutilizala
export MM=https://mailmerge.789.com.mx/api/v2
export TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
curl "$MM/templates" -H "Authorization: Bearer $TOKEN"
use GuzzleHttp\Client;
// El encabezado queda fijo en el cliente: todas las llamadas lo llevan
$http = new Client([
'base_uri' => 'https://mailmerge.789.com.mx/api/v2/',
'headers' => ['Authorization' => 'Bearer ' . getenv('MAILMERGE_TOKEN')],
]);
$res = $http->get('templates');
const MM = 'https://mailmerge.789.com.mx/api/v2';
const auth = { Authorization: `Bearer ${process.env.MAILMERGE_TOKEN}` };
// Reutiliza `auth` en cada llamada
const plantillas = await fetch(`${MM}/templates`, { headers: auth })
.then((r) => r.json());
import os
import requests
MM = "https://mailmerge.789.com.mx/api/v2"
# Una sesion con el encabezado ya puesto: todas las llamadas lo heredan
api = requests.Session()
api.headers["Authorization"] = f"Bearer {os.environ['MAILMERGE_TOKEN']}"
plantillas = api.get(f"{MM}/templates", timeout=30).json()
Comprueba que tu token sirve
Antes de escribir nada más, haz esta llamada: es la más barata de la API y te dice de inmediato si el token es válido.
curl -i "$MM/templates" -H "Authorization: Bearer $TOKEN"
| Lo que ves | Qué significa |
|---|---|
200 con un arreglo [] o con plantillas | El token sirve. Ya puedes integrar. |
401 {"detail": "Missing or invalid authorization header"} | Falta el encabezado, o le falta el prefijo Bearer con su espacio. |
401 {"detail": "Could not validate credentials"} | El token está mal copiado o ya caducó. Renuévalo con /auth/refresh. |
Los dos tropiezos más comunes al copiar un token: olvidar la palabra
Bearer y el espacio que la sigue —el valor del encabezado no es el token a secas—, y
arrastrar un salto de línea al pegarlo. Un JWT es una sola línea larga, sin espacios.
| Token | Vigencia | Para qué sirve |
|---|---|---|
access_token | 8 horas en pruebas · 30 días en producción | Autoriza cada llamada a la API. |
refresh_token | 7 días en pruebas · 182 días en producción | Obtiene un access_token nuevo sin volver a pedir la contraseña. |
No programes contra una vigencia fija. Las dos difieren por entorno, y la de pruebas es corta a propósito: con 8 horas, renovar no es un caso excepcional que atiendes algún día, es algo que tu integración hace sola todos los días. Si la resuelves en pruebas, en producción ya está resuelta.
La forma correcta no es calcular cuándo caduca, sino reaccionar al 401:
renueva con /auth/refresh y reintenta la llamada una vez. Así
funciona igual si mañana cambiamos los plazos.
Guarda los tokens del lado del servidor. Un access_token da acceso a
todos los documentos de tu empresa. No lo pongas en localStorage, en el código de tu
front-end ni en un repositorio.
El token de tu aplicación#
En despliegue por entornos. Esta credencial se empieza a exigir en pruebas y después en producción, con aviso previo. Si ya tienes tu token de aplicación, mándalo desde ahora: donde todavía no se exige, se ignora sin romper nada.
Una integración presenta dos credenciales que responden preguntas distintas, y ninguna sustituye a la otra:
| Encabezado | Qué dice | De dónde sale |
|---|---|---|
X-App-Token |
Qué aplicación está llamando. | Te lo entrega 789 al dar de alta tu aplicación. Uno por entorno. |
Authorization: Bearer |
En nombre de qué empresa. | Lo obtienes con login, y cambia según la empresa. |
X-App-Token: mmapp_a1b2c3d4_kZ9rTq2WmNp4XeLs7VhB
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
El token de aplicación va acompañado de una lista de direcciones IP autorizadas: solo se
acepta si la petición llega desde una de ellas. Si cambias de servidor o agregas uno, avísanos antes —
desde una dirección no registrada la respuesta es 403 aunque el token sea correcto.
| Respuesta | Qué pasó |
|---|---|
401 {"detail": "Invalid or missing application token"} | Falta el encabezado, o el token no es válido en este entorno. Revisa que no estés usando el de pruebas contra producción. |
403 {"detail": "Client address is not on this application's allowlist"} | El token sirve, pero la dirección desde la que llamas no está autorizada. |
Un token por entorno, y no son intercambiables. El de producción solo funciona en
/api/v2 y el de pruebas solo en /api/v2/test. Es deliberado: evita que una
prueba escriba en producción por haber dejado la URL vieja en una variable de entorno.
Login y register no son excepciones. Son los dos endpoints que no pueden pedir el
token de usuario —son la forma de obtenerlo—, pero sí exigen el de aplicación. Es justamente ahí donde
importa: sin él, cualquiera que alcance el servidor podría intentar contraseñas o dar de alta empresas.
El único endpoint sin credenciales es /health, que vive fuera de /api/v2.
Iniciar sesión#
Valida las credenciales y entrega el par de tokens.
| Campo | Tipo | Descripción |
|---|---|---|
email requerido | string | Correo del usuario. |
password requerido | string | Contraseña. Mínimo 8 caracteres. |
curl -X POST "$MM/auth/login" \
-H "X-App-Token: $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"email": "tu-usuario@empresa.mx",
"password": "TuContrasena123"
}'
$res = $http->post('auth/login', ['json' => [
'email' => 'tu-usuario@empresa.mx',
'password' => 'TuContrasena123',
]]);
$auth = json_decode((string) $res->getBody(), true);
const auth = await fetch(`${MM}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'tu-usuario@empresa.mx',
password: 'TuContrasena123',
}),
}).then((r) => r.json());
auth = requests.post(f"{MM}/auth/login", json={
"email": "tu-usuario@empresa.mx",
"password": "TuContrasena123",
}, timeout=30).json()
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"user": {
"id": 12,
"company_id": 3,
"email": "tu-usuario@empresa.mx",
"full_name": "Ana Ruiz",
"role": "company_admin",
"is_active": true,
"last_login_at": "2026-09-18T14:02:11Z",
"created_at": "2026-01-20T09:14:00Z",
"updated_at": "2026-09-18T14:02:11Z"
}
}
Renovar el token de acceso#
Cuando el access_token caduca, cámbialo por uno nuevo enviando el refresh_token
en el cuerpo, no en el encabezado Authorization. Es el error más común al
implementarlo, porque todos los demás endpoints funcionan al revés.
La respuesta trae solo un access_token. El refresh_token no se
renueva ni se reemplaza: sigue siendo el mismo hasta que caduque por su cuenta, y cuando eso pase hay que
volver a iniciar sesión con la contraseña.
Cuándo llamarlo. No calcules la caducidad: reacciona al 401. Una llamada
devuelve 401 → renuevas aquí → reintentas esa misma llamada una vez. Si el refresh
también devuelve 401, el refresh_token ya caducó y toca login.
Reintenta una vez, no en bucle. Si la segunda llamada vuelve a dar 401,
el problema no es la caducidad y reintentar solo multiplica el tráfico.
curl -X POST "$MM/auth/refresh" \
-H "X-App-Token: $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"refresh_token": "eyJhbGciOiJIUzI1NiIs..."}'
$res = $http->post('auth/refresh', [
'json' => ['refresh_token' => $auth['refresh_token']],
]);
$token = json_decode((string) $res->getBody(), true)['access_token'];
const { access_token } = await fetch(`${MM}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: auth.refresh_token }),
}).then((r) => r.json());
token = requests.post(f"{MM}/auth/refresh", json={
"refresh_token": auth["refresh_token"],
}, timeout=30).json()["access_token"]
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
| Código | Cuándo ocurre | Respuesta |
|---|---|---|
401 | El refresh_token es inválido o caducó. | {"detail": "Invalid or expired refresh token"} |
401 | Enviaste un access_token en lugar del de refresco. | {"detail": "Invalid token type. Expected refresh token"} |
401 | El token no trae usuario, o el usuario ya no existe. | {"detail": "Invalid token payload"} · {"detail": "User not found"} |
403 | La cuenta fue desactivada. | {"detail": "User account is inactive"} |
Registrar una empresa#
Crea una empresa nueva junto con su usuario administrador, y devuelve ya el par de tokens. Es el endpoint que llama el panel de tu aplicación cuando das de alta a un cliente: guardas las credenciales que devuelve y a partir de ahí operas en nombre de esa empresa.
La empresa nace en el entorno de la URL que uses: /api/v2/auth/register la
crea en producción, /api/v2/test/auth/register en pruebas. Son mundos separados, sin datos
en común.
| Campo | Tipo | Reglas |
|---|---|---|
company_name requerido | string | Entre 2 y 100 caracteres. |
email requerido | string | Correo del administrador. Debe ser único. |
password requerido | string | Mínimo 8 caracteres, con mayúscula, minúscula y dígito. |
full_name requerido | string | Nombre del administrador. |
relationship opcional |
enum |
owner si esta empresa es tu propia aplicación usando el servicio;
subleased si es un cliente al que se lo revendes. Omitirlo equivale a
subleased. Requiere token de aplicación.
|
Esto es lo que vincula la empresa con tu aplicación. Al registrarla queda registrada también como tuya, en la misma operación: no hay un paso aparte que se pueda olvidar, ni un momento en el que la empresa exista sin dueño.
Marca como owner la primera, la tuya. Solo puede haber una por aplicación y por
entorno —una en producción y una en pruebas—; pedir una segunda responde 409 y no
crea nada, ni la empresa. Las demás van como subleased, que es el valor por omisión, así que
para ellas no necesitas mandar el campo.
curl -X POST "$MM/auth/register" \
-H "X-App-Token: $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"company_name": "Distribuidora Alamo",
"email": "admin@alamo.mx",
"password": "Alamo2026Seguro",
"full_name": "Ana Ruiz",
"relationship": "subleased"
}'
$res = $http->post('auth/register', ['json' => [
'company_name' => 'Distribuidora Alamo',
'email' => 'admin@alamo.mx',
'password' => 'Alamo2026Seguro',
'full_name' => 'Ana Ruiz',
]]);
const alta = await fetch(`${MM}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company_name: 'Distribuidora Alamo',
email: 'admin@alamo.mx',
password: 'Alamo2026Seguro',
full_name: 'Ana Ruiz',
}),
}).then((r) => r.json());
alta = requests.post(f"{MM}/auth/register", json={
"company_name": "Distribuidora Alamo",
"email": "admin@alamo.mx",
"password": "Alamo2026Seguro",
"full_name": "Ana Ruiz",
}, timeout=30).json()
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"company_id": 7,
"company_name": "Distribuidora Alamo",
"tenant": {
"application_slug": "fleet",
"relationship": "subleased"
},
"user": {
"id": 21,
"email": "admin@alamo.mx",
"full_name": "Ana Ruiz",
"role": "company_admin",
"company_id": 7,
"is_active": true,
"created_at": "2026-09-18T14:10:00Z",
"updated_at": "2026-09-18T14:10:00Z"
}
}
Cerrar sesión#
Revoca el access_token que envías en el encabezado. Deja de funcionar de inmediato, aunque
todavía le quedara vigencia, y en todos los procesos de la API a la vez.
curl -X POST "$MM/auth/logout" \
-H "X-App-Token: $APP_TOKEN" \
-H "Authorization: Bearer $TOKEN"
Es idempotente: llamarlo dos veces, o con un token ya caducado, responde igual. No tienes que comprobar nada antes.
Revoca ese token, no la sesión. El refresh_token sigue sirviendo, así
que quien lo tenga puede obtener uno nuevo. Para cortar de verdad a un usuario hay que desactivar su
cuenta, y eso se hace desde el panel: surte efecto en la siguiente llamada, porque la API comprueba la
cuenta en cada petición.
Si se te filtró un access_token, este es el corte inmediato. Llámalo y
después avisa a soporte para revisar si hay que desactivar la cuenta.
Plantillas#
Una plantilla es tu archivo .docx con los campos combinables incrustados. MailMerge la analiza
al subirla, extrae los nombres de los campos y te los devuelve: esa lista es el contrato entre tu documento
de Word y el JSON que enviarás después.
Cómo preparar el .docx
- Abre tu documento en Microsoft Word.
- Coloca el cursor donde debe ir el dato variable.
- Ve a Insertar → Elementos rápidos → Campo…, elige MergeField y escribe el
nombre del campo (por ejemplo
nombre_cliente). - Repite para cada dato. Puedes usar el mismo nombre las veces que quieras: se rellenará en todas.
- Guarda como
.docxy súbelo conPOST /templates.
Convenciones de nombres que activan funciones extra:
Un campo cuyo nombre empieza con img_, imagen_, image_,
foto_, photo_ o pic_ se trata como
imagen y espera una URL. Cualquier campo cuyo valor contenga etiquetas HTML se
renderiza como texto con formato. No hay que configurar nada más.
Subir una plantilla#
Envío multipart/form-data. Extensiones aceptadas: .docx y .doc; máximo 50 MB.
| Campo del formulario | Tipo | Descripción |
|---|---|---|
file requerido | archivo | El documento de Word que sirve de plantilla. |
ref_id requerido | string | Tu identificador de la plantilla. Úsalo para reconciliar con tu propia base de datos. |
name requerido | string | Nombre legible de la plantilla. |
template_type requerido | enum | contract (documento principal) o annex (anexo). |
curl -X POST "$MM/templates" \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@contrato.docx' \
-F 'ref_id=CONTRATO-BASE' \
-F 'name=Contrato de arrendamiento' \
-F 'template_type=contract'
$res = $http->post('templates', [
'headers' => $auth_header,
'multipart' => [
['name' => 'file', 'contents' => fopen('contrato.docx', 'r')],
['name' => 'ref_id', 'contents' => 'CONTRATO-BASE'],
['name' => 'name', 'contents' => 'Contrato de arrendamiento'],
['name' => 'template_type', 'contents' => 'contract'],
],
]);
$plantilla = json_decode((string) $res->getBody(), true);
const form = new FormData();
form.append('file', archivo, 'contrato.docx'); // File o Blob
form.append('ref_id', 'CONTRATO-BASE');
form.append('name', 'Contrato de arrendamiento');
form.append('template_type', 'contract');
const plantilla = await fetch(`${MM}/templates`, {
method: 'POST', headers: auth, body: form,
}).then((r) => r.json());
with open("contrato.docx", "rb") as f:
plantilla = requests.post(
f"{MM}/templates",
headers=auth,
files={"file": ("contrato.docx", f)},
data={
"ref_id": "CONTRATO-BASE",
"name": "Contrato de arrendamiento",
"template_type": "contract",
},
timeout=60,
).json()
{
"id": 550,
"company_id": 3,
"ref_id": "CONTRATO-BASE",
"name": "Contrato de arrendamiento",
"template_type": "contract",
"file_path": "/uploads/company_3/templates/8f2c…-contrato.docx",
"original_filename": "contrato.docx",
"file_size": 48213,
"merge_fields": [
"nombre_cliente",
"fecha_contrato",
"monto_total"
],
"version": 1,
"is_active": true,
"created_by": 12,
"created_at": "2026-09-18T14:21:03Z",
"updated_at": "2026-09-18T14:21:03Z"
}
Revisa siempre merge_fields. Es la verdad sobre lo que MailMerge encontró
dentro del .docx. Si esperabas monto_total y aparece MontoTotal,
el campo se insertó con otro nombre en Word: corrígelo ahí o ajusta tu JSON. Un campo que envías y no
existe en la plantilla se ignora en silencio.
Listar plantillas#
Devuelve un arreglo con las plantillas de tu empresa.
| Parámetro (query) | Tipo | Descripción |
|---|---|---|
template_type opcional | enum | Filtra por contract o annex. |
is_active opcional | boolean | Por omisión true: solo plantillas vigentes. |
curl "$MM/templates?template_type=contract&is_active=true" \
-H "Authorization: Bearer $TOKEN"
$res = $http->get('templates', [
'headers' => $auth_header,
'query' => ['template_type' => 'contract', 'is_active' => 'true'],
]);
$plantillas = json_decode((string) $res->getBody(), true);
const q = new URLSearchParams({ template_type: 'contract', is_active: 'true' });
const plantillas = await fetch(`${MM}/templates?${q}`, { headers: auth })
.then((r) => r.json());
plantillas = requests.get(f"{MM}/templates", headers=auth, params={
"template_type": "contract",
"is_active": True,
}, timeout=30).json()
Consultar una plantilla#
Devuelve la misma estructura que la respuesta de subida. Es la forma recomendada de recuperar
merge_fields antes de armar el JSON de generación.
curl "$MM/templates/550" -H "Authorization: Bearer $TOKEN"
Eliminar una plantilla#
Baja lógica: la plantilla queda como is_active: false y deja de aparecer en los listados,
pero los documentos ya generados con ella conservan su historial. Responde 204 No Content.
curl -X DELETE "$MM/templates/550" -H "Authorization: Bearer $TOKEN" -i
Documentos#
Aquí es donde tu aplicación pasa la mayor parte del tiempo: una llamada para generar, una para descargar.
Generar un documento#
Combina una plantilla con tus datos y deja el archivo listo para descargar.
| Campo | Tipo | Descripción |
|---|---|---|
template_id requerido | integer | El id que te devolvió la subida de la plantilla. |
ref_id requerido | string | Tu referencia del documento: número de contrato, folio, id de expediente… |
merge_data requerido | object | Los datos. Ver las tres claves siguientes. |
merge_data.fields opcional | object | Pares nombre_campo: valor para los campos sueltos. |
merge_data.tables opcional | array | Filas repetidas. Ver tablas. Máximo 50 tablas. |
merge_data.images opcional | object | Imágenes con dimensiones explícitas. Ver imágenes. |
output_format opcional | enum | pdf (por omisión) o docx. |
curl -X POST "$MM/documents" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"template_id": 550,
"ref_id": "CONTRATO-2026-0187",
"merge_data": {
"fields": {
"nombre_cliente": "Distribuidora Alamo S.A. de C.V.",
"fecha_contrato": "18/09/2026",
"monto_total": "$128,400.00"
}
},
"output_format": "pdf"
}'
$res = $http->post('documents', [
'headers' => $auth_header,
'json' => [
'template_id' => 550,
'ref_id' => 'CONTRATO-2026-0187',
'merge_data' => [
'fields' => [
'nombre_cliente' => 'Distribuidora Alamo S.A. de C.V.',
'fecha_contrato' => '18/09/2026',
'monto_total' => '$128,400.00',
],
],
'output_format' => 'pdf',
],
]);
$documento = json_decode((string) $res->getBody(), true);
const documento = await fetch(`${MM}/documents`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
template_id: 550,
ref_id: 'CONTRATO-2026-0187',
merge_data: {
fields: {
nombre_cliente: 'Distribuidora Alamo S.A. de C.V.',
fecha_contrato: '18/09/2026',
monto_total: '$128,400.00',
},
},
output_format: 'pdf',
}),
}).then((r) => r.json());
documento = requests.post(f"{MM}/documents", headers=auth, json={
"template_id": 550,
"ref_id": "CONTRATO-2026-0187",
"merge_data": {
"fields": {
"nombre_cliente": "Distribuidora Alamo S.A. de C.V.",
"fecha_contrato": "18/09/2026",
"monto_total": "$128,400.00",
},
},
"output_format": "pdf",
}, timeout=120).json()
{
"id": 1234,
"company_id": 3,
"template_id": 550,
"ref_id": "CONTRATO-2026-0187",
"status": "completed",
"file_path": "/downloads/company_3/1f9c…-contrato.pdf",
"file_size": 186342,
"download_url": "/api/v2/documents/1234/download",
"error_message": null,
"task_id": null,
"expires_at": "2026-09-25T14:31:44Z",
"created_at": "2026-09-18T14:31:40Z",
"completed_at": "2026-09-18T14:31:44Z"
}
Guarda el id, no la ruta. file_path es interno y puede cambiar;
la manera estable de recuperar el archivo es /api/v2/documents/{id}/download. Y ten presente
expires_at: el archivo se conserva 7 días. Si tu sistema necesita el
documento a largo plazo, guárdalo en tu propio almacenamiento al descargarlo.
Descargar un documento#
Devuelve el archivo binario, no JSON.
| Encabezado de respuesta | Valor |
|---|---|
Content-Type | application/pdf o application/vnd.openxmlformats-officedocument.wordprocessingml.document |
Content-Disposition | attachment; filename="CONTRATO-2026-0187.pdf" |
curl "$MM/documents/1234/download" \
-H "Authorization: Bearer $TOKEN" \
-o contrato-2026-0187.pdf
$http->get('documents/1234/download', [
'headers' => $auth_header,
'sink' => 'contrato-2026-0187.pdf',
]);
const res = await fetch(`${MM}/documents/1234/download`, { headers: auth });
const blob = await res.blob(); // en el navegador
// en Node: await writeFile('doc.pdf', Buffer.from(await res.arrayBuffer()));
pdf = requests.get(f"{MM}/documents/1234/download", headers=auth, timeout=120)
pdf.raise_for_status()
with open("contrato-2026-0187.pdf", "wb") as f:
f.write(pdf.content)
Si el documento no existe, no es de tu empresa o todavía no está terminado, la respuesta es
404 con {"error": "Document not found or not ready for download"}.
Consultar el estado#
Ligero y pensado para polling. En el flujo normal no lo necesitas, porque la generación termina
antes de que responda POST /documents. Úsalo si guardas el id y quieres confirmar
el resultado desde otro proceso.
{
"id": 1234,
"status": "completed",
"created_at": "2026-09-18T14:31:40Z",
"updated_at": "2026-09-18T14:31:44Z"
}
| Estado | Significado |
|---|---|
queued | Registrado, aún sin procesar. |
processing | Combinación y conversión en curso. |
completed | Listo para descargar. |
failed | Falló. El motivo viene en error_message al consultar el documento. |
Listar documentos#
| Parámetro (query) | Tipo | Descripción |
|---|---|---|
template_id opcional | integer | Solo documentos de esa plantilla. |
ref_id opcional | string | Búsqueda por tu referencia. |
status opcional | enum | queued, processing, completed o failed. |
limit opcional | integer | Por omisión 100, máximo 1000. |
offset opcional | integer | Desplazamiento para paginar. Por omisión 0. |
curl "$MM/documents?ref_id=CONTRATO-2026-0187&status=completed&limit=20" \
-H "Authorization: Bearer $TOKEN"
Consultar un documento#
Devuelve los metadatos completos del documento, con la misma forma que la respuesta de creación.
Es donde consultas error_message cuando un documento quedó en failed.
Tablas repetidas#
Para listas de longitud variable —conceptos de una cotización, pagos de una amortización, anexos— se usa
merge_data.tables. En Word, inserta una fila de tabla con los campos combinables de cada
columna; MailMerge la duplica tantas veces como filas envíes.
| Campo | Tipo | Descripción |
|---|---|---|
first_field requerido | string | Nombre del campo de la primera columna. Es la ancla que identifica la fila a repetir. |
rows requerido | array | Un objeto por fila, con una clave por columna. Máximo 1000 filas. |
{
"template_id": 551,
"ref_id": "COT-2026-0442",
"merge_data": {
"fields": {
"numero_cotizacion": "COT-2026-0442",
"cliente": "Distribuidora Alamo S.A. de C.V.",
"total": "$46,400.00"
},
"tables": [
{
"first_field": "concepto",
"rows": [
{ "concepto": "Instalación", "cantidad": "1", "precio": "$28,000.00" },
{ "concepto": "Capacitación", "cantidad": "2", "precio": "$12,000.00" },
{ "concepto": "Soporte anual", "cantidad": "12", "precio": "$6,400.00" }
]
}
]
},
"output_format": "pdf"
}
Todas las filas deben traer las mismas claves. Si una fila omite una columna, esa celda
queda vacía en el documento. Y first_field tiene que ser exactamente el campo de la primera
columna de la tabla en Word; si no coincide, la tabla no se repite y se rellena una sola fila.
Puedes enviar hasta 50 tablas en un mismo documento: agrega un objeto más al arreglo
tables, cada uno con su propio first_field.
Imágenes por URL#
MailMerge puede descargar una imagen de una URL pública e insertarla en el documento, conservando la proporción. Hay dos formas de pedirlo.
Por convención de nombre (lo habitual)
Si el campo de tu plantilla empieza con uno de estos prefijos, basta con enviar la URL como valor en
fields:
| Prefijo | Ejemplos de nombre de campo |
|---|---|
img_ | img_logo, img_firma |
imagen_ | imagen_producto, imagen_firma |
image_ | image_header, image_footer |
foto_ | foto_inmueble, foto_perfil |
photo_ | photo_product |
pic_ | pic_banner |
{
"fields": {
"cliente": "Distribuidora Alamo",
"img_logo_empresa": "https://cdn.tuempresa.mx/logos/alamo.png",
"imagen_producto": "https://cdn.tuempresa.mx/productos/8842.jpg",
"img_opcional": ""
}
}
Para una imagen que puede no existir —por ejemplo la foto de un activo en una cotización— envía
"", null o "N/A": el marcador se elimina limpiamente del documento
en lugar de dejar un hueco.
Con dimensiones explícitas
Cuando necesitas control del tamaño o la alineación, usa merge_data.images:
{
"merge_data": {
"fields": { "cliente": "Distribuidora Alamo" },
"images": {
"logo": {
"url": "https://cdn.tuempresa.mx/logos/alamo.png",
"max_width_inches": 2.0,
"max_height_inches": 1.5,
"fit_mode": "contain",
"position": "center",
"alt_text": "Logotipo de Distribuidora Alamo"
},
"firma": "https://cdn.tuempresa.mx/firmas/ana-ruiz.png"
}
}
}
| Propiedad | Tipo | Por omisión | Descripción |
|---|---|---|---|
url requerido | string | — | URL pública de la imagen. El servidor debe poder descargarla. |
max_width_inches | number | 6.0 | Ancho máximo en pulgadas (0.1–11). |
max_height_inches | number | 8.0 | Alto máximo en pulgadas (0.1–14). |
width_inches | number | — | Ancho exacto; tiene prioridad sobre max_width_inches. |
height_inches | number | — | Alto exacto; tiene prioridad sobre max_height_inches. |
fit_mode | enum | contain | contain, cover, stretch u original. |
position | enum | left | Alineación horizontal: left, center o right. |
alt_text | string | — | Texto alternativo, hasta 500 caracteres. |
Las URLs deben ser accesibles públicamente desde internet, sin autenticación. Si la descarga falla o tarda demasiado, el documento se genera sin esa imagen en lugar de fallar por completo.
Contenido HTML con formato#
Un campo puede traer HTML y MailMerge lo convierte a formato real de Word: listas numeradas, viñetas, negritas, tablas. Es la forma de insertar texto con estructura sin tener que prever cada variante en la plantilla. No hay que marcar nada: si el valor contiene etiquetas HTML, se procesa como tal.
{
"fields": {
"cliente": "Distribuidora Alamo",
"modo_pago": "<ol type=\"a\"><li>Anticipo: $64,224.14</li><li>Gestoria: $9,444.82</li></ol>",
"observaciones": "<p>Vigencia de <b>30 dias</b> naturales.</p>"
}
}
Etiquetas admitidas
| Grupo | Etiquetas |
|---|---|
| Listas | <ul>, <ol>, <li> (admiten anidamiento) |
| Tablas | <table>, <tr>, <td>, <th> |
| Formato | <b>, <strong>, <i>, <em>, <u>, <s> |
| Estructura | <p>, <br>, <h1>–<h6>, <hr> |
| Otros | <a>, <blockquote>, <pre>, <code> |
Numeración de listas ordenadas
El atributo type de <ol> controla el estilo de numeración:
type | Resultado |
|---|---|
1 (por omisión) | 1. 2. 3. |
a | a. b. c. |
A | A. B. C. |
i | i. ii. iii. |
I | I. II. III. |
Escapa el HTML dentro del JSON. Las comillas de los atributos tienen que ir como
\". Si armas el JSON con la librería de tu lenguaje (y no concatenando cadenas), esto se
resuelve solo.
Utilidades PDF#
Independientes de la combinación de correspondencia: sirven para unir archivos ya existentes en un solo PDF —por ejemplo el contrato firmado más sus anexos escaneados—.
Unir archivos en un PDF
Envías un ZIP con los archivos a unir, en multipart/form-data. Se combinan en
el orden alfabético de sus nombres dentro del ZIP, así que conviene prefijarlos: 01-contrato.pdf,
02-anexo.pdf…
| Parámetro | Dónde | Descripción |
|---|---|---|
file requerido | formulario | Archivo ZIP con los documentos o imágenes. |
output_filename opcional | formulario | Nombre del PDF resultante, sin la extensión. |
strict_mode opcional | query | false por omisión. En true, un archivo inválido aborta la operación. |
detailed opcional | query | false por omisión. En true devuelve JSON con el detalle en lugar del PDF. |
curl -X POST "$MM/pdf/merge?strict_mode=false" \
-H "Authorization: Bearer $TOKEN" \
-F 'file=@expediente.zip' \
-F 'output_filename=expediente-2026-0187' \
-o expediente-2026-0187.pdf
Validar archivos antes de unirlos
Recibe uno o más archivos (campo files, repetible) y responde con el diagnóstico de cada uno y
si el conjunto es unible —hacen falta al menos dos archivos válidos—. No genera nada.
Formatos admitidos
Devuelve la lista viva de extensiones y tipos MIME que aceptan los endpoints de PDF, junto con los límites vigentes. Consúltala desde tu aplicación en vez de codificar la lista a mano: incluye PDF, XPS, EPUB, CBZ, SVG e imágenes.
curl "$MM/pdf/formats"
Usuarios y roles#
Cada usuario pertenece a una empresa y solo ve los datos de esa empresa. La API expone el alta y gestión de usuarios para que tu aplicación pueda administrarlos sin entrar al panel.
| Rol | Puede |
|---|---|
user | Subir plantillas, generar y descargar documentos de su empresa. |
company_admin | Todo lo anterior, más crear, modificar y desactivar usuarios de su empresa y eliminar plantillas. |
super_admin | Administración de la plataforma. Reservado a 789. |
| Método | Ruta | Permiso | Descripción |
|---|---|---|---|
| POST | /api/v2/users | Administrador | Crea un usuario en tu empresa. Cuerpo: email, password, full_name, role. |
| GET | /api/v2/users | Administrador | Lista los usuarios. Filtros: is_active, role. |
| GET | /api/v2/users/{user_id} | Autenticado | Consulta un usuario. |
| PATCH | /api/v2/users/{user_id} | Administrador o el propio usuario | Actualiza datos del usuario. |
| DELETE | /api/v2/users/{user_id} | Administrador | Desactiva la cuenta (baja lógica). Nadie puede eliminarse a sí mismo. |
Salud y diagnóstico#
Dos son públicos y dos no. La diferencia importa: un monitor externo puede llamar a
/health sin credenciales, mientras que /api/v2/ping exige las dos y por eso
sirve para lo contrario — comprobar que tu integración está completa.
| Método | Ruta | Credenciales | Respuesta |
|---|---|---|---|
| GET | /health | Ninguna | {"status":"healthy", …} |
| GET | /openapi.json | Ninguna | Especificación OpenAPI 3.1, para generar clientes. |
| GET | /api/v2/ping | App + usuario | {"message":"pong"} |
| GET | /api/v2/ | App + usuario | Versión de la API y mapa de sus secciones. |
/api/v2/ping es tu primera llamada. Es la más barata que existe y
atraviesa todo: token de aplicación, allowlist de direcciones y token de usuario. Si responde
pong, lo demás es cuestión de armar bien los JSON.
Genera tu cliente automáticamente. Como la especificación está publicada, puedes producir un SDK tipado sin escribirlo a mano:
npx @openapitools/openapi-generator-cli generate -i https://mailmerge.789.com.mx/openapi.json -g typescript-fetch -o ./mailmerge-client
Todos los endpoints#
Referencia completa de la API v2. Todas las rutas van precedidas de
https://mailmerge.789.com.mx. La columna de credenciales dice qué hay que presentar:
app es el token de aplicación, usuario es el
Authorization: Bearer que devuelve el login.
| Método | Ruta | Credenciales | Qué hace |
|---|---|---|---|
| POST | /api/v2/auth/register | app | Registra una empresa con su administrador. |
| POST | /api/v2/auth/login | app | Entrega el par de tokens. |
| POST | /api/v2/auth/refresh | app | Renueva el token de acceso. |
| POST | /api/v2/auth/logout | app + usuario | Revoca el token de acceso presentado. |
| POST | /api/v2/templates | app + usuario | Sube una plantilla .docx y detecta sus campos. |
| GET | /api/v2/templates | app + usuario | Lista las plantillas de la empresa. |
| GET | /api/v2/templates/{template_id} | app + usuario | Consulta una plantilla y sus merge_fields. |
| DELETE | /api/v2/templates/{template_id} | app + usuario · admin | Da de baja lógica una plantilla. |
| POST | /api/v2/documents | app + usuario | Genera un documento a partir de una plantilla y datos. |
| GET | /api/v2/documents | app + usuario | Lista documentos con filtros y paginación. |
| GET | /api/v2/documents/{document_id} | app + usuario | Metadatos de un documento. |
| GET | /api/v2/documents/{document_id}/status | app + usuario | Estado de la generación. |
| GET | /api/v2/documents/{document_id}/download | app + usuario | Descarga el archivo generado. |
| POST | /api/v2/users | app + usuario · admin | Crea un usuario. |
| GET | /api/v2/users | app + usuario · admin | Lista usuarios. |
| GET | /api/v2/users/{user_id} | app + usuario | Consulta un usuario. |
| PATCH | /api/v2/users/{user_id} | app + usuario | Actualiza un usuario. |
| DELETE | /api/v2/users/{user_id} | app + usuario · admin | Desactiva un usuario. |
| POST | /api/v2/pdf/merge | app + usuario | Une los archivos de un ZIP en un solo PDF. |
| POST | /api/v2/pdf/validate | app + usuario | Valida archivos antes de unirlos. |
| GET | /api/v2/pdf/formats | app + usuario | Formatos y límites admitidos. |
| GET | /api/v2/ | app + usuario | Información de la API. |
| GET | /api/v2/ping | app + usuario | Prueba de conectividad. |
| GET | /health | ninguna | Estado del servicio. |
Errores#
La API responde con códigos HTTP estándar. Los errores traen un cuerpo JSON: detail con el
motivo —una cadena, o un arreglo cuando se trata de validación—.
| Código | Significado | Qué hacer |
|---|---|---|
200 / 201 | Todo bien. | — |
204 | Eliminación correcta, sin cuerpo. | — |
400 | Petición mal formada. | Revisa el cuerpo y los tipos de dato. |
401 | Falta el token, es inválido o caducó. | Renueva con /auth/refresh; si también falla, vuelve a iniciar sesión. |
403 | Sin permisos suficientes, o cuenta desactivada. | Verifica el rol del usuario. |
404 | No existe o es de otra empresa. | Confirma el identificador. El aislamiento entre empresas devuelve 404, no 403. |
409 | Conflicto: ya existe una plantilla con ese ref_id y tipo. | Reutiliza la plantilla existente o usa otro ref_id. |
422 | Error de validación del cuerpo. | Lee detail[].loc para saber qué campo falló. |
500 | Error del servidor. | Reintenta con espera exponencial. Si persiste, avisa a soporte con el ref_id. |
Ejemplos reales
{
"detail": "Missing or invalid authorization header"
}
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "password"],
"msg": "String should have at least 8 characters",
"ctx": { "min_length": 8 }
}
]
}
Un documento puede fallar sin que la petición falle. Si status queda en
failed, la llamada HTTP puede haber respondido correctamente. Revisa siempre
status en la respuesta y, cuando sea failed, lee error_message.
Límites y cuotas#
| Concepto | Límite |
|---|---|
| Tamaño de archivo subido | 50 MB |
| Extensiones de plantilla | .docx, .doc |
| Tablas por documento | 50 |
| Filas por tabla | 1000 |
| Conservación del documento generado | 7 días (expires_at) |
| Resultados por página en listados | 100 por omisión, 1000 máximo |
Vigencia del access_token | 8 horas en pruebas · 30 días en producción |
Vigencia del refresh_token | 7 días en pruebas · 182 días en producción |
| Dimensiones máximas de imagen | 11 × 14 pulgadas |
No hay un límite de peticiones por minuto publicado. Aun así, si vas a generar documentos en lote, hazlo de forma secuencial o con poca concurrencia: cada generación implica convertir un documento de Word, y las ráfagas grandes degradan el tiempo de respuesta para todos.
Buenas prácticas#
- Reutiliza el token y renueva por
401. Autenticarte en cada petición es innecesario y lento. Guarda el token del lado del servidor, y cuando una llamada devuelva401, renueva con/auth/refreshy reintenta esa llamada una sola vez. No calcules la caducidad tú: los plazos difieren entre pruebas y producción. - Guarda el
template_iden tu base de datos. Sube cada plantilla una vez y referénciala por suid. - Usa tu propio identificador en
ref_id. Es el puente entre el documento en MailMerge y el registro en tu sistema, y hace que los listados y el soporte sean mucho más fáciles. - Valida contra
merge_fieldsantes de generar. Comparar la lista de campos de la plantilla con las claves que vas a enviar evita documentos con huecos en producción. - Descarga y archiva. El documento vive 7 días en MailMerge. Si es parte de un expediente, cópialo a tu almacenamiento en cuanto lo generes.
- Da margen en los tiempos de espera. La conversión a PDF de un documento grande puede tardar varios segundos; configura al menos 60 s de timeout en tu cliente HTTP.
- Desarrolla contra
/api/v2/testy cambia a producción solo al final. - Reintenta con espera exponencial ante
500o errores de red, y nunca reintentes un422: el cuerpo está mal y volverá a fallar igual.
Soporte#
¿Necesitas credenciales, dar de alta tu empresa o resolver un caso concreto? Escríbenos a
soporte@789.mx. Para reportar un problema de generación, incluye el
ref_id del documento y la hora aproximada: con eso lo ubicamos en los registros.