API de campañas
Crea, lanza y supervisa campañas mediante programación.
Todos los endpoints de esta página son relativos a la URL base: https://api-eu1.joryio.com/api. Consulta el Resumen de la API.
Autenticación
Todas las solicitudes requieren autenticación mediante clave de API:
Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json
Tu clave debe incluir el permiso que requiere cada endpoint:
| Permiso | Endpoints |
|---|---|
campaigns:read | Enumerar, obtener, estadísticas, destinatarios, versiones e historial |
campaigns:write | Crear, actualizar, duplicar, archivar, etiquetar y revertir versiones |
campaigns:send | Enviar, pausar, reanudar, cancelar, reintentar, envíos de prueba y envío transaccional |
campaigns:delete | Eliminar y eliminar en bloque |
Consulta Claves de API para gestionar los permisos.
Crear una campaña
Endpoint
POST /campaigns
Cuerpo de la solicitud
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
name | cadena | Sí | Nombre de campaña (máximo 255 caracteres) |
description | cadena | No | Descripción (máximo 1000 caracteres) |
channel | cadena | Sí | email, sms, viber, push, webhook, whatsapp, in_app o ai_optimized |
variants | matriz | Sí* | Variantes de mensaje (obligatorias para todos los canales salvo in_app) |
targeting | objeto | No | Audiencia: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference |
sendType | cadena | No | immediate, scheduled, triggered, intelligent, recurring o ai_optimized |
scheduledAt | cadena | No | Fecha ISO 8601 para sendType: "scheduled" |
scheduledTimezone | cadena | No | Zona horaria IANA en la que se interpreta la hora programada (se guarda con la campaña), o recipient_local: cada persona a la hora de scheduledAt en su propia zona (la del espacio de trabajo si no tiene); la campaña empieza cuando UTC+14 llega a ella. Los nombres desconocidos se rechazan con 400. |
triggerConfig | objeto | No | Regla de disparador para sendType: "triggered" (type, eventName, conditions, reEntry, cooldownHours) |
recurringSchedule | objeto | No | Para sendType: "recurring": frequency (daily/weekly/monthly/custom), cron, dayOfWeek, dayOfMonth, timeOfDay, timezone, endDate, maxOccurrences. endCondition (never/date/after) conserva solo el final que indica: never elimina endDate y maxOccurrences, date elimina maxOccurrences y after elimina endDate y guarda maxOccurrences como 10 si falta. Sin dayOfWeek (0 = domingo) una programación semanal se ejecuta el lunes; sin dayOfMonth, una mensual se ejecuta el día 1. timeOfDay es HH:MM en formato de 24 horas (09:00, 17:30); cualquier otro valor se rechaza con 400. |
conversionTracking | objeto | No | primaryConversion / secondaryConversions (nombre de evento + condiciones de propiedad), conversionWindowHours, attributionModel (first_touch/last_touch/linear) |
emailConfigId | cadena | No | Identidad de remitente de email guardada (canal de email) |
subscriptionCategoryId | cadena | No | Categoría de consentimiento (lista de suscripción) bajo la que se envía la campaña |
sendVolumeLimit | objeto | No | enabled, maxSends, cadence (lifetime/per_send) |
Contenido del mensaje in-app
Cada variante in-app lleva su contenido en customContent. El mode decide qué campos se aplican:
mode | Campos | Se renderiza como |
|---|---|---|
native | title, body, imageUrl, buttons, closeButton, backdropDismissible, style | Los propios componentes de la app, sin web view |
html (por defecto) | html, css, maxWidth | Marcado del autor dentro de un web view |
drag_drop | html, css, grapejsData, maxWidth | Como html; grapejsData es el estado del editor visual |
mode puede omitirse, lo que significa html.
maxWidth (modos HTML, solo en campañas modal y slideup) es el ancho máximo del mensaje en px: un número entero entre 240 y 1200. Omítelo para usar el valor predeterminado de la maquetación - 600 para un modal, 400 para un slide-up. El SDK da al mensaje el menor entre maxWidth y el ancho de la pantalla menos un margen antes de maquetar el contenido, así que las media queries del mensaje ven su ancho real. Consulta Ancho máximo y HTML adaptable.
{
"name": "Weekend offer",
"channel": "in_app",
"channelConfig": { "type": "modal", "triggers": [] },
"variants": [
{
"id": "v1",
"name": "Native",
"weight": 100,
"customContent": {
"mode": "native",
"title": "Weekend only",
"body": "Hi {{ firstName }}, members get 20% off through Sunday.",
"imageUrl": "https://cdn.example.com/weekend.png",
"buttons": [
{ "id": "cta", "text": "See offer", "action": "url", "url": "https://example.com/offer" },
{ "id": "later", "text": "Not now", "action": "dismiss" }
],
"closeButton": true,
"backdropDismissible": true,
"style": {
"backgroundColor": "#0A1240",
"textColor": "#FFFFFF",
"primaryButtonColor": "#00C8B7",
"cornerRadius": 18
}
}
}
]
}
Los campos nativos son texto, no marcado. Se entregan a la app sin escapar, porque la app los renderiza en vistas de texto, así que A & B llega como A & B y no como A & B. La personalización con Liquid funciona en title, body y en el text y la url de los botones.
buttons está limitado a 3. action es uno de dismiss, url o deep_link; para los dos últimos, url es obligatorio.
style - anulaciones de presentación opcionales
Todos los campos son opcionales y uno ausente significa heredar: el color de superficie de la aplicación, su color de texto, su acento y su tipografía. Esa herencia es el sentido del contenido nativo, así que envía un campo solo cuando la campaña lo necesite.
| Campo | Tipo | Se aplica en | Significado |
|---|---|---|---|
backgroundColor | string | web, iOS, Android | Fondo de la tarjeta |
textColor | string | web, iOS, Android | Titular y cuerpo (el cuerpo, algo más suave) |
primaryButtonColor | string | web, iOS, Android | Relleno del primer botón |
primaryButtonTextColor | string | web, iOS, Android | Su etiqueta. Si se omite, se elige negro o blanco según el contraste con el relleno |
cornerRadius | number | web, iOS, Android | 0-48. Se ignora en fullscreen, donde las esquinas redondeadas dejarían ver la aplicación |
fontSize | number | web, iOS, Android | 10-32. Tamaño del cuerpo; el titular se escala a partir de él. En móvil se aplica encima el ajuste de tamaño de texto del usuario |
titleWeight | string | web, iOS, Android | regular, medium, semibold, bold. Solo el TITULAR - el cuerpo se mantiene normal por legibilidad |
textAlign | string | web, iOS, Android | auto (por defecto), start, center, end. auto sigue el idioma del propio mensaje, así que el hebreo y el árabe se leen de derecha a izquierda dentro de una app en inglés |
fontFamily | string | web; en móvil, si se puede | En móvil se aplica solo si la aplicación incluye esa fuente (iOS: registrada; Android: res/font o una familia del sistema). Si falta, conserva la suya |
customCss | string | solo web | CSS escrito a mano, hasta 20000 caracteres. El SDK web reescribe cada selector para que quede dentro del mensaje antes de inyectarlo, así que ninguna regla llega a la página anfitriona, y @import se descarta. Los móviles no tienen motor CSS |
Los colores se pasan tal cual se escriben: hex, rgb() o una palabra clave CSS.
Un valor que el renderizador no pueda interpretar vuelve al heredado en lugar de
hacer fallar el mensaje.
En customCss puedes seleccionar la propia tarjeta, h2, p,
button.primary y button.secondary, y los campos anteriores también se
exponen como variables CSS: --joryio-inapp-bg, --joryio-inapp-fg,
--joryio-inapp-primary, --joryio-inapp-primary-fg, --joryio-inapp-radius
y --joryio-inapp-font.
El contenido HTML requiere que la app lo habilite. Los SDK móviles y web rechazan los mensajes in-app HTML salvo que la app defina allowHtmlJsInAppMessages en la inicialización, porque un mensaje así se muestra en un web view dentro de la app. Los scripts escritos en el mensaje no se ejecutan ahí; el propio puente del SDK gestiona sus botones y formularios. El contenido nativo siempre se muestra. Consulta las guías de Android, iOS y Web.
| sendRateLimit | objeto | No | enabled, maxPerMinute |
| quietTimeOverride | objeto | No | Anulación de horas de silencio por campaña |
| utmSettings | objeto | No | Anulación de UTM/etiquetado de enlaces por campaña |
| channelConfig | objeto | No | Configuración específica del canal (su estructura depende del canal) |
| stoConfig / abTestConfig | objeto | No | Configuración de optimización de hora de envío / prueba A-B |
| resendPolicy | cadena | No | Política de reenvío para «Enviar de nuevo» de una campaña puntual: only_new (predeterminado), everyone o cooldown |
| resendCooldownDays | número | No | Ventana de antigüedad (días) para resendPolicy: "cooldown"; con cooldown, un valor ausente o 0 se guarda como 30 |
| tags | cadena[] | No | Etiquetas |
| status | cadena | No | draft, scheduled, active, paused, completed o cancelled |
Estructura de variante
Cada entrada de variants:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
id | cadena | Sí | ID de variante |
name | cadena | Sí | Nombre de variante |
weight | número | Sí | Proporción de tráfico, 0-100 (los pesos deben sumar correctamente según las reglas del canal) |
message | objeto | No | Mensaje del canal. Email: subject, preheader, from, fromName, html o templateId, text. SMS: body, from, shortenLinks; en una cuenta de Delivyr (solo plantillas aprobadas), smsTemplateId y variableMapping en lugar de body - consulta Plantillas de SMS. Push: title, body, icon, image, data. WhatsApp: messageType (template/reply), templateId, wabaId, variableMapping, replyText. Webhook: url, method, headers, body, auth, bodyType |
isControlGroup | booleano | No | Marca esta variante como grupo de control retenido (permite medir la mejora incremental) |
Solicitud de ejemplo
curl -X POST https://api-eu1.joryio.com/api/campaigns \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "July Newsletter",
"channel": "email",
"sendType": "scheduled",
"scheduledAt": "2026-07-20T10:00:00.000Z",
"scheduledTimezone": "America/New_York",
"variants": [
{
"id": "variant-a",
"name": "Variant A",
"weight": 100,
"message": {
"subject": "Your July update",
"from": "[email protected]",
"fromName": "Example",
"html": "<h1>Hello {{ user.firstName }}</h1>"
}
}
],
"targeting": {
"filterGroups": [
{
"filters": [
{ "type": "attribute", "field": "plan", "operator": "equals", "value": "premium" }
],
"operator": "AND"
}
]
}
}'
Respuesta
Devuelve el objeto de campaña creado:
{
"id": "8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b",
"name": "July Newsletter",
"channel": "email",
"status": "scheduled",
"sendType": "scheduled",
"scheduledAt": "2026-07-20T14:00:00.000Z",
"variants": [ ... ],
"targeting": { ... },
"tags": [],
"createdAt": "2026-07-12T09:00:00.000Z",
"updatedAt": "2026-07-12T09:00:00.000Z"
}
Rendimiento de campañas
Qué campañas causaron realmente conversiones, medido frente al grupo de control de cada campaña: los destinatarios a los que deliberadamente no se envió.
Clasificado por conversiones adicionales, no por ingresos, para que tenga sentido en un espacio de trabajo que optimiza un objetivo de registro o activación en lugar de pedidos. Los ingresos se muestran al lado donde existen pedidos.
Endpoint
GET /campaigns/performance
Parámetros de consulta
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
sinceDays | number | 90 | Ventana de análisis para decidir qué campañas se incluyen |
Respuesta
| Campo | Tipo | Descripción |
|---|---|---|
ranked | array | Campañas cuya medición superó el umbral de significancia, las mejores primero |
stillCollecting | array | Medidas pero aún no concluyentes. Lista separada a propósito: no tienen veredicto, y colocarlas bajo las perdedoras diría que perdieron |
noHoldout | number | Campañas sin grupo de control. Su efecto no se puede medir, así que se cuentan en lugar de listarse |
noGoal | number | Campañas con grupo de control pero sin objetivo de conversión. No hay nada que comparar, así que se cuentan en lugar de listarse |
sinceDays | number | La ventana aplicada |
Qué es aquí una conversión. Cada fila cuenta el objetivo de conversión propio de la campaña (conversionGoal, el nombre del objetivo, por ejemplo email_clicked), el mismo incremento que muestra su página de campaña: a ambos grupos se les pregunta si la persona alcanzó el objetivo dentro de la ventana de conversión de la campaña después de que se le enviara (o se le habría enviado), sin modelo de atribución, porque el grupo de control no tiene mensaje al que dar crédito. incrementalRevenue proviene de pedidos (ingresos de compra en la ventana de cada grupo), sea cual sea el objetivo. Hasta el 2026-10-14 incrementalConversions, conversionRateLift y significance contaban compradores para cada campaña, fuera cual fuera su objetivo.
Estas cifras no se suman
No hay total, deliberadamente. Cada campaña se mide frente a un grupo de control que sí recibió todo lo demás que enviaste, así que los efectos se solapan: sumar las filas contaría la misma conversión bajo más de una campaña. La respuesta incluye totalsAreNotSummable: true.
Por qué importa aquí la significancia
Una campaña con un grupo de control pequeño puede mostrar una subida enorme por la compra de una sola persona. Solo se clasifican las campañas con al menos minConvertersPerArm conversiones en cada grupo. La prueba se ejecuta sobre la tasa de conversión, que es una proporción y puede contrastarse correctamente; los ingresos por destinatario tienen cola pesada y se informan como contexto, no como prueba.
curl -X GET "https://api-eu1.joryio.com/api/campaigns/performance?sinceDays=90" \
-H "Authorization: Bearer YOUR_API_KEY"
Enumerar campañas
Endpoint
GET /campaigns
Parámetros de consulta
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
status | cadena | Filtra por estado (separados por comas si hay varios) | |
channel | cadena | Filtra por canal (separados por comas si hay varios) | |
tags | cadena | Filtra por etiquetas (separadas por comas) | |
q | cadena | Búsqueda de texto libre | |
createdBy / editedBy | cadena | Filtra por creador / último editor (ID de usuario separados por comas) | |
createdFrom | cadena | Solo campañas creadas en o después de esta fecha ISO | |
page | número | 1 | Número de página |
limit | número | 20 | Resultados por página (máximo 100) |
Solicitud de ejemplo
curl -X GET "https://api-eu1.joryio.com/api/campaigns?status=active&channel=email&limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"
Respuesta
{
"data": [
{ "id": "8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b", "name": "July Newsletter", "channel": "email", "status": "active" }
],
"pagination": {
"total": 23,
"page": 1,
"limit": 50,
"offset": 0,
"totalPages": 1,
"hasMore": false
}
}
Obtener una campaña
GET /campaigns/:campaignId
curl -X GET https://api-eu1.joryio.com/api/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b \
-H "Authorization: Bearer jry_live_your_api_key"
Devuelve el objeto de campaña completo. El estado de la campaña es uno de estos: draft, scheduled, active, paused, completed, cancelled, archived o failed (error de envío permanente; consulta failureReason).
Actualizar una campaña
PUT /campaigns/:campaignId
El cuerpo acepta los mismos campos que Crear una campaña. Todos son opcionales y solo se actualizan los campos proporcionados.
curl -X PUT https://api-eu1.joryio.com/api/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "name": "July Newsletter v2" }'
Las campañas activadas y recurrentes permanecen active durante toda su vida. Editar una campaña active no cambia lo que se está enviando en ese momento: las ediciones se almacenan en búfer como borrador pendiente. Llama a POST /campaigns/:campaignId/publish para aplicarlas de forma atómica a la campaña en curso o a POST /campaigns/:campaignId/discard-draft para descartarlas. Las campañas en borrador se actualizan directamente (sin paso de publicación).
Eliminar una campaña
DELETE /campaigns/:campaignId
Devuelve 204 No Content. Solo se eliminan definitivamente los borradores que nunca se enviaron; las campañas con historial de envío deben archivarse (POST /campaigns/:campaignId/archive), lo que detiene los envíos y conserva la analítica.
Ciclo de vida de una campaña
| Método | Ruta | Descripción |
|---|---|---|
POST | /campaigns/:campaignId/send | Lanza la campaña (inicia el envío / activa una campaña activada) |
POST | /campaigns/:campaignId/pause | Pausa una campaña en ejecución |
POST | /campaigns/:campaignId/resume | Reanuda una campaña en pausa |
POST | /campaigns/:campaignId/publish | Aplica de forma atómica las ediciones almacenadas en búfer a una campaña activa (400 si no hay ninguna pendiente) |
POST | /campaigns/:campaignId/discard-draft | Descarta las ediciones almacenadas en búfer de una campaña activa (sin efecto si no hay ninguna) |
POST | /campaigns/:campaignId/cancel | Cancela una campaña |
POST | /campaigns/:campaignId/resend | Reenvía («Enviar de nuevo») una campaña puntual completada según su resendPolicy |
POST | /campaigns/:campaignId/retry | Reintenta una campaña failed (la devuelve a scheduled) |
POST | /campaigns/:campaignId/preview-launch | Resumen previo al lanzamiento (tamaño de audiencia y comprobaciones) sin enviar |
POST | /campaigns/:campaignId/duplicate | Duplica una campaña como borrador nuevo: se copian todos los ajustes (remitente, zona horaria de la programación, programación recurrente, disparador, ajustes de A/B) y se reinicia el estado de ejecución (estado, estadísticas, aprobaciones, scheduledAt). El campo duplicatedFrom de la copia contiene campaignId, name, scheduledAt, scheduledTimezone y scheduledLocalAt del origen |
POST | /campaigns/:campaignId/archive | Archiva (detiene los envíos y conserva el historial) |
POST | /campaigns/:campaignId/unarchive | Restaura a un estado sin envío (reanuda explícitamente para volver a enviar) |
POST | /campaigns/:campaignId/stop-recurring | Detiene futuras apariciones de una campaña recurrente |
curl -X POST https://api-eu1.joryio.com/api/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b/send \
-H "Authorization: Bearer jry_live_your_api_key"
Una campaña puntual completada puede reenviarse mediante POST /campaigns/:campaignId/resend. Quién la recibe depende de resendPolicy de la campaña:
only_new(predeterminado): omite a quien ya la recibió (solo la reciben usuarios nunca alcanzados).everyone: reenvía a toda la audiencia, incluidos los destinatarios anteriores.cooldown: reenvía a todos excepto a quienes recibieron un mensaje durante los últimosresendCooldownDaysdías.
El consentimiento y las supresiones se aplican siempre. Los límites de frecuencia siguen la marca ignoreTouchingRules de la campaña. Las campañas activadas usan triggerConfig.reEntry en su lugar y las recurrentes reenvían según su programación. Devuelve 400 para esos tipos, para in-app o para una campaña que aún no ha terminado de enviarse.
Estadísticas de campaña
GET /campaigns/:campaignId/stats
Parámetros de consulta: startDate, endDate (opcionales). Un valor con solo fecha (2026-07-20) significa el día completo en la zona horaria de tu espacio de trabajo; una fecha-hora ISO completa se toma como el instante exacto, que es lo que hace reales las ventanas de «primera 1 h / 6 h / 24 h» de la página de campaña.
Respuesta
{
"campaignId": "8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b",
"name": "July Newsletter",
"channel": "email",
"status": "completed",
"stats": {
"queued": 1200,
"recipients": 1180,
"sent": 1180,
"delivered": 1150,
"failed": 30,
"opened": 640,
"uniqueHumanOpened": 590,
"clicked": 210,
"uniqueHumanClicked": 180,
"bounced": 12,
"hardBounced": 9,
"softBounced": 3,
"otherBounced": 0,
"autoReplied": 7,
"unsubscribed": 4,
"deliveryRate": 97.46,
"openRate": 51.3,
"clickRate": 15.65,
"clickToOpenRate": 30.51,
"rateBasis": "recipients",
"rateOf": "delivered",
"messageOpens": null,
"messageClicks": null
},
"conversionStats": { ... },
"revenue": { ... },
"uplift": null,
"storeConnected": false,
"conversionTracking": { ... },
"startedAt": "2026-07-20T14:00:00.000Z",
"completedAt": "2026-07-20T14:12:00.000Z",
"createdAt": "2026-07-12T09:00:00.000Z"
}
storeConnected es true cuando el espacio de trabajo tiene una integración de tienda conectada (Shopify, WooCommerce o Magento), false cuando no la tiene y null cuando no se pudo determinar.
conversionStats y revenue se completan cuando se configura el seguimiento de conversiones; uplift se completa solo cuando una variante está marcada con isControlGroup.
Dos cosas que conviene leer con cuidado en stats:
- Una tasa es
null, nunca0, cuando el canal no puede medirla.openRateesnullensms,push,in_app,webhookywallet;clickRateyclickToOpenRatesonnullenwebhook.nullsignifica «no medido», que es una afirmación distinta de «nadie lo abrió»: represéntalo como tal. rateBasisnombra el denominador."recipients"(una campaña que envió una sola vez) divide personas únicas entre mensajes entregados."messages"(recurrente, disparada o reenviada, es decir, cualquier cosa enviada más de una vez) es la tasa por mensaje ponderada por envío, ymessageOpens/messageClicksllevan sus numeradores. Bajo"recipients"esos dos sonnullyuniqueHumanOpened/uniqueHumanClickedson los recuentos honestos.rateOfindica sobre qué se calculanopenRateyclickRate(añadido en octubre de 2026; para email las tasas no cambiaron). Toda tasa de apertura y clic es personas únicas sobredelivereden email, SMS, WhatsApp y Viber; sobresenten push, cuyos acuses de entrega vienen del dispositivo y se quedan cortos en iOS, y en cualquier campaña sin entregas informadas todavía. El panel, la página de la campaña y esta API usan la misma regla, así que una campaña tiene una sola tasa en todas partes: consulta Cómo se calculan las tasas de apertura y clic.hardBounced,softBouncedyotherBounceddividenbounced(añadido en octubre de 2026): rebotes conbounceTypeduro, suave y sin tipo registrado, contados con la misma regla quebounced, así que los tres suman ese valor.autoRepliedes el número de mensajes que recibieron una respuesta de fuera de la oficina u otra respuesta automática: correo entregado, nunca parte debouncedni de una tasa de rebote. Esnullcuando el tratamiento del correo devuelto está desactivado y la campaña no tiene ninguna - no medido, que es una afirmación distinta de0; un recuento registrado mientras estaba activado se sigue devolviendo.
Endpoints de estadísticas relacionados
| Método | Ruta | Descripción |
|---|---|---|
GET | /campaigns/:campaignId/variant-stats | Estadísticas A/B por variante con significación estadística (startDate/endDate) |
GET | /campaigns/:campaignId/conversion-timing | Cuándo ocurrieron las conversiones atribuidas (granularity = day u hour; startDate/endDate acotan el envío como en /stats). Mismo objetivo, ventana, modelo y consulta que conversionStats, repartidos por el día u hora de la conversión en la zona horaria del espacio de trabajo; los buckets suman el número de conversiones que devuelve /stats para el mismo rango. Devuelve { configured, granularity, total, buckets: [{ date, conversions }], majorityWithinHours } - majorityWithinHours es en cuántas horas desde el envío acreditado había convertido más de la mitad; configured: false cuando la campaña no tiene objetivo de conversión |
GET | /campaigns/:campaignId/engagement-timing | Envíos, aperturas y clics por day u hour (granularity) en la zona horaria del espacio de trabajo, para el rango de eventos startDate/endDate como en /stats. Se cuentan con las reglas de /stats: count=people (por defecto) pone a cada persona una vez en el intervalo de su primera apertura / primer clic, así que los intervalos suman las aperturas / los clics únicos; count=events cuenta cada apertura y cada clic. includeBots=true añade aperturas / clics de bots y escáneres (excluidos por defecto). Las claves de intervalo coinciden con conversion-timing. Devuelve { granularity, count, includeBots, buckets: [{ date, sent, opened, clicked }], totals: { sent, opened, clicked } } |
GET | /campaigns/:campaignId/recovered-carts | Los carritos abandonados que recuperó la campaña: la lista detrás de revenue.recoveredCarts en /stats, de la misma consulta, así que total es igual a ese recuento para los mismos startDate/endDate (carritos recuperados en el intervalo; un final solo con fecha incluye el día entero, zona horaria del espacio de trabajo). limit 1-100 (25 por defecto), offset; los más recientes primero. Filas: orderNumber (el de la tienda), placedAt, recoveredAt, match (recovery_link, order = mismo carrito, reported), cartValue/cartCurrency, creditedRevenue/creditedCurrency (neto de reembolsos), contact (el dueño del carrito) y buyer (solo cuando el pedido se guardó a nombre de otra persona); los nombres de contacto se ocultan sin pii:view. Devuelve { data, total, limit, offset, summary } |
GET | /campaigns/:campaignId/links | Estadísticas de clics en enlaces (startDate/endDate) - en una campaña in-app las filas son los clics con nombre del mensaje (action, url = URL pulsada o el nombre) por variante |
GET | /campaigns/:campaignId/failure-reasons | Errores de entrega agrupados por código de error DLR ([{ code, reason, count }]) |
GET | /campaigns/:campaignId/skip-reasons | Desglose No enviado: personas a las que el envío no mandó mensaje a propósito, una fila por persona según su último motivo de omisión ({ total, reasons: [{ reason, count }] }; motivos como control_group, global_control, frequency_cap, channel_unsubscribed, quiet_time, missing_contact). Lista las personas de un motivo con /campaigns/:campaignId/recipients?skipReason=<reason> |
GET | /campaigns/:campaignId/unsubscribe-reasons | Bajas por motivo: una fila por persona que se dio de baja con los enlaces de esta campaña, según el motivo de su última baja, sin espacios y en minúsculas ({ total, reasons: [{ reason, count }], others }). Se listan los 8 motivos principales y el resto se suma en others; reason: null significa que no se indicó motivo. Las bajas registradas antes de que el motivo se guardara en el evento de analítica (octubre de 2026) cuentan como null |
GET | /campaigns/:campaignId/recipients | Destinatarios paginados, una fila por persona con la etapa más lejana que alcanzó (furthestStatus). Filtra con status (sent, delivered, opened, clicked, failed, bounced, unsubscribed, skipped); pagina con limit (máximo 200) y offset. Ordena con sortBy - reached (etapa más lejana), lastActivity o events - y sortOrder (asc / desc, por defecto desc); sin sortBy va primero el primer evento más reciente, y todo orden termina en el id de la persona, así que las páginas nunca repiten ni saltan a nadie. includeBots=true cuenta como de la persona las aperturas y los clics de bots, escáneres y máquinas; por defecto quedan fuera de la etapa y de eventCount, la misma regla que las tasas de apertura y clic |
GET | /campaigns/:campaignId/recipients/:userId | Cronología de eventos de mensaje por usuario para esta campaña. En una campaña webhook también devuelve webhookResponse ({ status, contentType, body, truncated, at }) cuando la última llamada fallida a esta persona (no 2xx) tiene una respuesta guardada: con los secretos ocultos, los primeros 2 KB del cuerpo, conservada 30 días. Solo se devuelve a quien puede ver datos personales (pii:view; una clave de API solo con ese permiso). Ausente cuando no hay nada guardado o el equipo de Joryio desactivó el guardado de respuestas para la organización |
GET | /campaigns/:campaignId/in-app-stats | Estadísticas de visualización in-app (campañas in-app). Incluye displayFrequency - la distribución de impresiones por usuario como { tailBucket, buckets: [{ displays, users, impressions }], maxPerUser }. Los recuentos iguales o superiores a tailBucket se agrupan en un solo bucket, así que displays === tailBucket significa "esa cantidad o más"; para calcular una media usa el campo impressions de cada bucket (no displays * users), y maxPerUser para el usuario más expuesto |
GET | /campaigns/:campaignId/impressions | Lista de impresiones in-app (limit, offset, startDate, endDate) |
Enviar un mensaje transaccional
Envía un mensaje puntual a un único usuario sin crear una campaña.
POST /campaigns/transactional/send
Cuerpo de la solicitud
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
userId | cadena | Sí | ID de usuario objetivo |
channel | cadena | Sí | email, sms, push o viber |
message | objeto | Sí | subject (email), body (texto simple), html (email). Puede ser {} para viber: el cuerpo de plantilla aprobado es el mensaje |
viberTemplateId | cadena | Solo Viber | Plantilla aprobada del registro de plantillas de Viber. Rakuten Viber exige plantillas preaprobadas para mensajes transaccionales/OTP (desde julio de 2026); el contenido no aprobado se factura a la tarifa promocional, por lo que la API se niega a enviar sin una |
variables | objeto | No | Solo Viber: valores para los campos dinámicos de la plantilla (se combinan en el contexto de personalización) |
triggerData | objeto | No | Contexto disponible para la personalización (type, name, properties, metadata) |
idempotencyKey | cadena | No | Clave de deduplicación proporcionada por quien llama: un reintento con la misma clave no se entrega dos veces |
curl -X POST https://api-eu1.joryio.com/api/campaigns/transactional/send \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"channel": "email",
"message": {
"subject": "Your receipt",
"html": "<p>Thanks for your order, {{ user.firstName }}.</p>"
},
"idempotencyKey": "order-98421-receipt"
}'
Endpoints adicionales
| Método | Ruta | Descripción |
|---|---|---|
POST | /campaigns/preview | Previsualiza usuarios que cumplen los criterios de audiencia (cuerpo: targeting, limit opcional máx. 500, channel, webAppId, platforms para push - el channelConfig.platforms de la campaña; targeting.tags son las etiquetas de la campaña, que deciden las exenciones de listas de supresión como en el envío) |
POST | /campaigns/spam-check | Comprueba el contenido de email en busca de spam antes de enviarlo |
GET | /campaigns/:campaignId/versions | Enumera instantáneas de versiones guardadas |
GET | /campaigns/:campaignId/versions/compare?v1=&v2= | Compara dos versiones |
GET | /campaigns/:campaignId/versions/:versionId | Obtiene una instantánea de versión |
POST | /campaigns/:campaignId/versions/:versionId/rollback | Revierte a una versión |
GET | /campaigns/:campaignId/history | Historial de registro de auditoría (limit, máximo 200) |
POST | /campaigns/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchive | Acciones masivas; cuerpo { "ids": [...] }, devuelve { succeeded, failed } |
POST | /campaigns/bulk-tag | Etiquetado masivo; cuerpo { "ids": [...], "tags": [...] } |
POST | /campaigns/:campaignId/send-test-whatsapp / send-test-sms / send-test-push | Envía pruebas a un teléfono/usuario antes del lanzamiento |
POST | /campaigns/:campaignId/render-for-contact | Renderiza la campaña para un contacto y devuelve { html, css }. No envía nada: los mensajes in-app son de tipo pull, así que no hay dispositivo al que entregar. Sustituye a send-test-in-app, que sigue funcionando |
GET | /campaigns/:campaignId/sto-coverage | Cobertura de optimización de hora de envío para la audiencia |
GET | /campaigns/:campaignId/recurring-status | Estado de la campaña recurrente |
POST | /campaigns/:campaignId/retest-ab | Restablece la ganadora A/B de una campaña recurrente de variante ganadora |
POST | /campaigns/:campaignId/launch-rl | Lanza en modo optimizado por IA (RL) |
GET | /campaigns/:campaignId/rl-stats | Estadísticas del dashboard de campaña optimizada por IA |
POST | /campaigns/:campaignId/pause-rl / resume-rl | Pausa / reanuda una campaña optimizada por IA |
POST | /campaigns/ml-path-warmth | Estado de calentamiento de personalización para ID de rutas/variantes |
Revisión de lanzamiento
La superficie de comprobación previa al envío (ver la guía de Revisión de lanzamiento). La misma revisión se ejecuta dentro del envío, así que lo que devuelve es con lo que se juzga el envío.
GET /campaigns/{campaignId}/launch-review
Alcance: campaigns:read.
{
"rulesVersion": "lr1",
"checkedAt": "2026-09-21T10:00:00.000Z",
"requireClean": true,
"blockers": [
{
"code": "merge_field_broken",
"severity": "blocker",
"scope": { "kind": "variant", "id": "v1", "name": "Variant A", "field": "subject" },
"params": { "field": "subject", "error": "output \"{{ user.firstName }\" not closed" },
"fix": { "action": "edit_content", "variantId": "v1", "field": "subject" }
}
],
"recommendations": [
{ "code": "subject_long", "severity": "recommendation", "scope": { "kind": "variant", "id": "v1", "name": "Variant A", "field": "subject" }, "params": { "length": 92, "max": 78 }, "fix": { "action": "edit_content", "variantId": "v1", "field": "subject" } }
],
"skipped": []
}
Códigos: bloqueos unsubscribe_missing, merge_field_broken, sender_not_verified, sender_not_found, link_unusable (bloqueo solo en un envío con objetivo de conversión); recomendaciones subject_long, subject_discount_words, subject_hype_adjective, preheader_repeats_subject, experiment_opportunity, plain_text_missing, audience_overlap, at_message_limit (personas en su límite de frecuencia: params.count, sampled, sharePct, bypassed, partial), quiet_hours_now (personas dentro de una ventana de silencio en el instante del envío: params.count, sampled, sharePct, behaviour, until, evaluatedAt, at, partial), quiet_hours_ignored, consent_bypassed, node_unreachable y model_note (una nota de la revisión de IA; params.title y params.detail). La respuesta también lleva ai: { status: 'ok' | 'skipped' | 'off', reason?, version, cached? }: la pasada de IA solo se ejecuta en esta ruta, nunca dentro de la puerta de envío, y una pasada fallida es un motivo skipped, no un error. fix.action es uno de edit_content, edit_audience, edit_delivery, edit_node, choose_sender, verify_domain, open_subscription_settings, open_quiet_time, open_frequency_rules, edit_journey.
Cada hallazgo lleva una key, estable entre revisiones (un model_note se identifica por su ámbito y su título). Cuando un hallazgo apunta a algo exacto también lleva locator: images - cada imagen como { src, index, bytes?, id? }, siendo src el valor COMPLETO del atributo (el params.src de una frase se corta a 120 caracteres), index su posición entre las imágenes de contenido del email y bytes su peso medido; words (las palabras que la voz de la marca evita y que usa el mensaje) con fields (subject, preheader, html) donde aparecen; o text, el bloque al que se refiere un hallazgo de diseño. Un model_note sobre una parte del mensaje la nombra en scope.field y fix.field. Las sugerencias de IA (model_note, experiment_opportunity) que alguien descartó en el panel no aparecen en esta respuesta; nada más se puede descartar. Tampoco aparece un model_note que cubren las preferencias de revisión del espacio de trabajo (creadas con "No volver a sugerir esto"; listadas en Ajustes → Revisión de lanzamiento → Preferencias de revisión), y un model_note ya descartado en tres o más campañas o recorridos distintos en los últimos 90 días lleva params.dismissedOn, ese número. Ver Revisión de lanzamiento.
requireClean refleja el ajuste del espacio de trabajo settings.launchReview.requireClean (por defecto true). Cuando es true y blockers no está vacío, POST /campaigns/{campaignId}/send responde:
{ "statusCode": 409, "code": "launch_review_blocked", "message": "Launch Review: 1 blocker: merge_field_broken (Variant A: subject)", "blockers": [ ... ] }
Una campaña programada rechazada a su hora de envío queda en estado failed con ese mensaje como failureReason.
Respuestas de error
Todos los errores comparten la estructura estándar. Consulta Respuesta de error en el Resumen de la API para ver el formato y la lista completa de códigos de estado.
{
"statusCode": 404,
"message": "Campaign not found",
"timestamp": "2026-07-12T09:00:00.000Z",
"path": "/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b"
}
| Estado | Cuándo |
|---|---|
400 | Error de validación (por ejemplo, channel no válido o falta weight de una variante); el cuerpo añade una matriz errors con un mensaje por campo no válido |
401 | Falta la clave de API o no es válida |
403 | La clave de API no tiene el permiso campaigns:* necesario |
404 | No se encontró la campaña en este espacio de trabajo |
409 | Conflicto de ciclo de vida; por ejemplo, reanudar una campaña que ya no está en pausa ("Campaign is no longer paused.") o enviar una que ya no se puede enviar |
Relacionado
- Crear campañas
- Analítica de campañas
- API de segmentos: crea las audiencias a las que se dirigen las campañas.
- API de Canvas: recorridos de varios pasos.
La aprobación requiere un usuario con sesión iniciada. Registra quién revisó la campaña, y la separación de funciones se juzga comparando al aprobador con quien la envió: ninguna de las dos cosas es posible para una clave. Una petición autenticada con clave de API se rechaza con 403.