Saltar al contenido principal

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:

PermisoEndpoints
campaigns:readEnumerar, obtener, estadísticas, destinatarios, versiones e historial
campaigns:writeCrear, actualizar, duplicar, archivar, etiquetar y revertir versiones
campaigns:sendEnviar, pausar, reanudar, cancelar, reintentar, envíos de prueba y envío transaccional
campaigns:deleteEliminar y eliminar en bloque

Consulta Claves de API para gestionar los permisos.


Crear una campaña​

Endpoint​

POST /campaigns

Cuerpo de la solicitud​

CampoTipoObligatorioDescripción
namecadenaSíNombre de campaña (máximo 255 caracteres)
descriptioncadenaNoDescripción (máximo 1000 caracteres)
channelcadenaSíemail, sms, viber, push, webhook, whatsapp, in_app o ai_optimized
variantsmatrizSí*Variantes de mensaje (obligatorias para todos los canales salvo in_app)
targetingobjetoNoAudiencia: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference
sendTypecadenaNoimmediate, scheduled, triggered, intelligent, recurring o ai_optimized
scheduledAtcadenaNoFecha ISO 8601 para sendType: "scheduled"
scheduledTimezonecadenaNoZona 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.
triggerConfigobjetoNoRegla de disparador para sendType: "triggered" (type, eventName, conditions, reEntry, cooldownHours)
recurringScheduleobjetoNoPara 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.
conversionTrackingobjetoNoprimaryConversion / secondaryConversions (nombre de evento + condiciones de propiedad), conversionWindowHours, attributionModel (first_touch/last_touch/linear)
emailConfigIdcadenaNoIdentidad de remitente de email guardada (canal de email)
subscriptionCategoryIdcadenaNoCategoría de consentimiento (lista de suscripción) bajo la que se envía la campaña
sendVolumeLimitobjetoNoenabled, 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:

modeCamposSe renderiza como
nativetitle, body, imageUrl, buttons, closeButton, backdropDismissible, styleLos propios componentes de la app, sin web view
html (por defecto)html, css, maxWidthMarcado del autor dentro de un web view
drag_drophtml, css, grapejsData, maxWidthComo 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.

CampoTipoSe aplica enSignificado
backgroundColorstringweb, iOS, AndroidFondo de la tarjeta
textColorstringweb, iOS, AndroidTitular y cuerpo (el cuerpo, algo más suave)
primaryButtonColorstringweb, iOS, AndroidRelleno del primer botón
primaryButtonTextColorstringweb, iOS, AndroidSu etiqueta. Si se omite, se elige negro o blanco según el contraste con el relleno
cornerRadiusnumberweb, iOS, Android0-48. Se ignora en fullscreen, donde las esquinas redondeadas dejarían ver la aplicación
fontSizenumberweb, iOS, Android10-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
titleWeightstringweb, iOS, Androidregular, medium, semibold, bold. Solo el TITULAR - el cuerpo se mantiene normal por legibilidad
textAlignstringweb, iOS, Androidauto (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
fontFamilystringweb; en móvil, si se puedeEn 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
customCssstringsolo webCSS 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:

CampoTipoObligatorioDescripción
idcadenaSíID de variante
namecadenaSíNombre de variante
weightnúmeroSíProporción de tráfico, 0-100 (los pesos deben sumar correctamente según las reglas del canal)
messageobjetoNoMensaje 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
isControlGroupbooleanoNoMarca 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ámetroTipoPredeterminadoDescripción
sinceDaysnumber90Ventana de análisis para decidir qué campañas se incluyen

Respuesta​

CampoTipoDescripción
rankedarrayCampañas cuya medición superó el umbral de significancia, las mejores primero
stillCollectingarrayMedidas pero aún no concluyentes. Lista separada a propósito: no tienen veredicto, y colocarlas bajo las perdedoras diría que perdieron
noHoldoutnumberCampañas sin grupo de control. Su efecto no se puede medir, así que se cuentan en lugar de listarse
noGoalnumberCampañas con grupo de control pero sin objetivo de conversión. No hay nada que comparar, así que se cuentan en lugar de listarse
sinceDaysnumberLa 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ámetroTipoPredeterminadoDescripción
statuscadenaFiltra por estado (separados por comas si hay varios)
channelcadenaFiltra por canal (separados por comas si hay varios)
tagscadenaFiltra por etiquetas (separadas por comas)
qcadenaBúsqueda de texto libre
createdBy / editedBycadenaFiltra por creador / último editor (ID de usuario separados por comas)
createdFromcadenaSolo campañas creadas en o después de esta fecha ISO
pagenúmero1Número de página
limitnúmero20Resultados 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" }'
Editar una campaña activa

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étodoRutaDescripción
POST/campaigns/:campaignId/sendLanza la campaña (inicia el envío / activa una campaña activada)
POST/campaigns/:campaignId/pausePausa una campaña en ejecución
POST/campaigns/:campaignId/resumeReanuda una campaña en pausa
POST/campaigns/:campaignId/publishAplica 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-draftDescarta las ediciones almacenadas en búfer de una campaña activa (sin efecto si no hay ninguna)
POST/campaigns/:campaignId/cancelCancela una campaña
POST/campaigns/:campaignId/resendReenvía («Enviar de nuevo») una campaña puntual completada según su resendPolicy
POST/campaigns/:campaignId/retryReintenta una campaña failed (la devuelve a scheduled)
POST/campaigns/:campaignId/preview-launchResumen previo al lanzamiento (tamaño de audiencia y comprobaciones) sin enviar
POST/campaigns/:campaignId/duplicateDuplica 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/archiveArchiva (detiene los envíos y conserva el historial)
POST/campaigns/:campaignId/unarchiveRestaura a un estado sin envío (reanuda explícitamente para volver a enviar)
POST/campaigns/:campaignId/stop-recurringDetiene 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"
Enviar de nuevo (política de reenvío)

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 últimos resendCooldownDays dí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, nunca 0, cuando el canal no puede medirla. openRate es null en sms, push, in_app, webhook y wallet; clickRate y clickToOpenRate son null en webhook. null significa «no medido», que es una afirmación distinta de «nadie lo abrió»: represéntalo como tal.
  • rateBasis nombra 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, y messageOpens / messageClicks llevan sus numeradores. Bajo "recipients" esos dos son null y uniqueHumanOpened / uniqueHumanClicked son los recuentos honestos.
  • rateOf indica sobre qué se calculan openRate y clickRate (añadido en octubre de 2026; para email las tasas no cambiaron). Toda tasa de apertura y clic es personas únicas sobre delivered en email, SMS, WhatsApp y Viber; sobre sent en 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, softBounced y otherBounced dividen bounced (añadido en octubre de 2026): rebotes con bounceType duro, suave y sin tipo registrado, contados con la misma regla que bounced, así que los tres suman ese valor. autoReplied es el número de mensajes que recibieron una respuesta de fuera de la oficina u otra respuesta automática: correo entregado, nunca parte de bounced ni de una tasa de rebote. Es null cuando el tratamiento del correo devuelto está desactivado y la campaña no tiene ninguna - no medido, que es una afirmación distinta de 0; un recuento registrado mientras estaba activado se sigue devolviendo.

Endpoints de estadísticas relacionados​

MétodoRutaDescripción
GET/campaigns/:campaignId/variant-statsEstadísticas A/B por variante con significación estadística (startDate/endDate)
GET/campaigns/:campaignId/conversion-timingCuá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-timingEnví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-cartsLos 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/linksEstadí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-reasonsErrores de entrega agrupados por código de error DLR ([{ code, reason, count }])
GET/campaigns/:campaignId/skip-reasonsDesglose 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-reasonsBajas 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/recipientsDestinatarios 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/:userIdCronologí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-statsEstadí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/impressionsLista 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​

CampoTipoObligatorioDescripción
userIdcadenaSíID de usuario objetivo
channelcadenaSíemail, sms, push o viber
messageobjetoSísubject (email), body (texto simple), html (email). Puede ser {} para viber: el cuerpo de plantilla aprobado es el mensaje
viberTemplateIdcadenaSolo ViberPlantilla 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
variablesobjetoNoSolo Viber: valores para los campos dinámicos de la plantilla (se combinan en el contexto de personalización)
triggerDataobjetoNoContexto disponible para la personalización (type, name, properties, metadata)
idempotencyKeycadenaNoClave 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étodoRutaDescripción
POST/campaigns/previewPrevisualiza 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-checkComprueba el contenido de email en busca de spam antes de enviarlo
GET/campaigns/:campaignId/versionsEnumera instantáneas de versiones guardadas
GET/campaigns/:campaignId/versions/compare?v1=&v2=Compara dos versiones
GET/campaigns/:campaignId/versions/:versionIdObtiene una instantánea de versión
POST/campaigns/:campaignId/versions/:versionId/rollbackRevierte a una versión
GET/campaigns/:campaignId/historyHistorial de registro de auditoría (limit, máximo 200)
POST/campaigns/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchiveAcciones masivas; cuerpo { "ids": [...] }, devuelve { succeeded, failed }
POST/campaigns/bulk-tagEtiquetado masivo; cuerpo { "ids": [...], "tags": [...] }
POST/campaigns/:campaignId/send-test-whatsapp / send-test-sms / send-test-pushEnvía pruebas a un teléfono/usuario antes del lanzamiento
POST/campaigns/:campaignId/render-for-contactRenderiza 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-coverageCobertura de optimización de hora de envío para la audiencia
GET/campaigns/:campaignId/recurring-statusEstado de la campaña recurrente
POST/campaigns/:campaignId/retest-abRestablece la ganadora A/B de una campaña recurrente de variante ganadora
POST/campaigns/:campaignId/launch-rlLanza en modo optimizado por IA (RL)
GET/campaigns/:campaignId/rl-statsEstadísticas del dashboard de campaña optimizada por IA
POST/campaigns/:campaignId/pause-rl / resume-rlPausa / reanuda una campaña optimizada por IA
POST/campaigns/ml-path-warmthEstado 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"
}
EstadoCuándo
400Error 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
401Falta la clave de API o no es válida
403La clave de API no tiene el permiso campaigns:* necesario
404No se encontró la campaña en este espacio de trabajo
409Conflicto 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​

Una clave de API no puede aprobar

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.