Skip to main content

Campaigns API

Create, launch, and monitor campaigns programmatically.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Authentication​

All requests require API key authentication:

Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json

Your key must carry the scope each endpoint requires:

ScopeEndpoints
campaigns:readList, get, stats, recipients, versions, history
campaigns:writeCreate, update, duplicate, archive, tag, versions rollback
campaigns:sendSend, pause, resume, cancel, retry, test sends, transactional send
campaigns:deleteDelete, bulk delete

See API Keys for scope management.


Create Campaign​

Endpoint​

POST /campaigns

Request Body​

FieldTypeRequiredDescription
namestringYesCampaign name (max 255 chars)
descriptionstringNoDescription (max 1000 chars)
channelstringYesemail, sms, viber, push, webhook, whatsapp, in_app, or ai_optimized
variantsarrayYes*Message variants (required for every channel except in_app)
targetingobjectNoAudience: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference
sendTypestringNoimmediate, scheduled, triggered, intelligent, recurring, or ai_optimized
scheduledAtstringNoISO 8601 date for sendType: "scheduled"
scheduledTimezonestringNoIANA timezone the scheduled time is interpreted in (stored with the campaign), or recipient_local: each person at scheduledAt's wall-clock in their own timezone (the workspace's without one); the campaign is due when UTC+14 reaches it. Unknown names are refused with 400.
triggerConfigobjectNoTrigger rule for sendType: "triggered" (type, eventName, conditions, reEntry, cooldownHours)
recurringScheduleobjectNoFor sendType: "recurring": frequency (daily/weekly/monthly/custom), cron, dayOfWeek, dayOfMonth, timeOfDay, timezone, endDate, maxOccurrences. endCondition (never/date/after) keeps only the end it names: never drops endDate and maxOccurrences, date drops maxOccurrences, after drops endDate and stores maxOccurrences as 10 when omitted. Without dayOfWeek (0 = Sunday) a weekly schedule runs on Monday; without dayOfMonth a monthly one runs on the 1st. timeOfDay is HH:MM on the 24-hour clock (09:00, 17:30); any other value is refused with 400.
conversionTrackingobjectNoprimaryConversion / secondaryConversions (event name + property conditions), conversionWindowHours, attributionModel (first_touch/last_touch/linear)
emailConfigIdstringNoSaved email sender identity (email channel)
appIdstringPushTarget app for a push campaign - the app whose APNs/FCM credentials send it. Get ids from GET /apps. Resolved for you when the workspace has exactly one push-capable app; with more than one you must name it, and a send without it is refused.
subscriptionCategoryIdstringNoConsent category (subscription list) the campaign sends under
sendVolumeLimitobjectNoenabled, maxSends, cadence (lifetime/per_send)

Push campaigns need a target app​

A push campaign sends through one app's APNs/FCM credentials, so it needs an appId. List your apps with GET /apps (scope apps:read) and pass the id of the one you want.

If the workspace has exactly one push-capable app, appId is filled in for you - there is only one answer. With two or more - the usual case, an iOS app and an Android app - it is not guessed: choosing either one would silently send to half your audience. Name the app, or select platforms via channelConfig.platforms, which sends to every app on the platforms you list.

A push campaign that reaches send time with no target app is refused, with the candidate apps named in the error. It used to be accepted and queued onto a lane no worker consumes, which showed up as an active campaign with zero sent and no failure recorded.

In-app message content​

Each in-app variant carries its authored content on customContent. The mode decides which fields apply:

modeFieldsRendered as
nativetitle, body, imageUrl, buttons, closeButton, backdropDismissible, styleThe app's own components - no web view
html (default)html, css, maxWidthAuthor-supplied markup in a web view
drag_drophtml, css, grapejsData, maxWidthAs html; grapejsData is the visual editor's state

mode may be omitted, which means html.

maxWidth (HTML modes, modal and slideup campaigns only) is the widest the message may be, in px: a whole number from 240 to 1200. Omit it for the layout default - 600 for a modal, 400 for a slide-up. The SDK gives the message the smaller of maxWidth and the screen width minus a margin before the content lays out, so the message's media queries see its real width. See Max width and responsive HTML.

{
"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
}
}
}
]
}

Native fields are text, not markup. They are delivered to the app unescaped because the app renders them into text views, so A & B arrives as A & B rather than A & B. Liquid personalization works in title, body, and button text and url.

buttons is capped at 3. action is one of dismiss, url, or deep_link; url is required for the latter two.

style - optional presentation overrides​

Every field is optional, and an absent one means inherit - the app's surface colour, its text colour, its accent, its typeface. That inheritance is the point of native content, so send a field only when the campaign needs it.

FieldTypeApplies onMeaning
backgroundColorstringweb, iOS, AndroidCard background
textColorstringweb, iOS, AndroidHeadline and body (body is softened)
primaryButtonColorstringweb, iOS, AndroidFill of the first button
primaryButtonTextColorstringweb, iOS, AndroidIts label. Omit and the renderer picks black or white by contrast against the fill
cornerRadiusnumberweb, iOS, Android0-48. Ignored for fullscreen, where rounded corners would leak the app through the corners
fontSizenumberweb, iOS, Android10-32. Body size; the headline scales from it. Mobile still applies the reader's text-size setting on top
titleWeightstringweb, iOS, Androidregular, medium, semibold, bold. The HEADLINE only - body stays regular for legibility
textAlignstringweb, iOS, Androidauto (default), start, center, end. auto follows the MESSAGE's own language, so Hebrew and Arabic read right-to-left inside an English app. start/end are direction-relative
fontFamilystringweb; mobile best effortApplied on mobile only if the app ships that font (iOS: registered; Android: res/font or a system family). When it is missing the app keeps its own typeface rather than substituting
customCssstringweb onlyHand-written CSS, max 20000 chars. The web SDK rewrites every selector to sit inside the message container before injecting it, so a rule cannot reach the host page, and @import is dropped. Phones have no CSS engine and ignore it

Colours are passed through as written - hex, rgb() or a CSS keyword. A value a renderer cannot parse falls back to the inherited one rather than failing the message.

In customCss you can target the card itself, h2, p, button.primary and button.secondary, and the fields above are also exposed as the CSS variables --joryio-inapp-bg, --joryio-inapp-fg, --joryio-inapp-primary, --joryio-inapp-primary-fg, --joryio-inapp-radius and --joryio-inapp-font.

HTML content requires the app to opt in. The mobile and web SDKs refuse HTML in-app messages unless the app sets allowHtmlJsInAppMessages at init, because such a message is shown in a web view inside the app. Scripts written into the message do not run there; the SDK's own bridge handles its buttons and forms. Native content is always displayed. See the Android, iOS and Web SDK guides.

| sendRateLimit | object | No | enabled, maxPerMinute | | quietTimeOverride | object | No | Per-campaign quiet-time override | | utmSettings | object | No | Per-campaign UTM/link-tagging override | | channelConfig | object | No | Channel-specific configuration (shape depends on channel) | | stoConfig / abTestConfig | object | No | Send-time optimization / A-B test configuration | | resendPolicy | string | No | Re-send policy for a one-time campaign's "Send again": only_new (default), everyone, or cooldown | | resendCooldownDays | number | No | Recency window (days) for resendPolicy: "cooldown"; with cooldown, an omitted or 0 value is stored as 30 | | tags | string[] | No | Tags | | status | string | No | draft, scheduled, active, paused, completed, or cancelled |

Variant Structure​

Each entry in variants:

FieldTypeRequiredDescription
idstringYesVariant id
namestringYesVariant name
weightnumberYesTraffic share, 0-100 (weights must sum correctly per channel rules)
messageobjectNoChannel message. Email: subject, preheader, from, fromName, html or templateId, text. SMS: body, from, shortenLinks; on a Delivyr account (approved templates only) smsTemplateId and variableMapping instead of body - see SMS templates. Push: title, body, icon, image, data. WhatsApp: messageType (template/reply), templateId, wabaId, variableMapping, replyText. Webhook: url, method, headers, body, auth, bodyType
isControlGroupbooleanNoMarks this variant as a held-out control group (enables uplift measurement)

Example Request​

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"
}
]
}
}'

Response​

Returns the created campaign object:

{
"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"
}

Campaign Performance​

Which campaigns actually caused conversions, measured against each campaign's own control group - the recipients it deliberately did not message.

Ranked by incremental conversions, not revenue, so it is meaningful for a workspace optimising for a signup or activation goal rather than orders. Revenue rides along where the workspace has orders.

Endpoint​

GET /campaigns/performance

Query Parameters​

ParameterTypeDefaultDescription
sinceDaysnumber90Look-back window for which campaigns to include

Response​

FieldTypeDescription
rankedarrayCampaigns whose measurement cleared the significance floor, best first
stillCollectingarrayMeasured, but not yet conclusive. A separate list on purpose - these have no verdict, and placing them below the losers would say they lost
noHoldoutnumberCampaigns with no control group. Their effect cannot be measured, so they are counted rather than listed
noGoalnumberCampaigns with a control group but no conversion goal. There is nothing to compare, so they are counted rather than listed
sinceDaysnumberThe window applied

Each row carries campaignId, name, channel, status, conversionGoal, treatmentRecipients, controlRecipients, incrementalConversions, conversionRateLift, incrementalRevenue, currency and significance.

significance is { controlRate, treatmentRate, pValue, significant, reason, minConvertersPerArm }. reason is significant, not_enough_conversions (wait for more data) or no_difference_detected (the test ran and the difference did not hold up). Those last two are different advice and are reported separately.

What a conversion is here. Each row counts the campaign's own conversion goal (conversionGoal, the goal name, for example email_clicked) - the same uplift its campaign page shows: both groups are asked whether the person reached the goal within the campaign's conversion window after they were sent (or would have been), with no attribution model, because the control group has no message to credit. incrementalRevenue is from orders - purchase revenue in each group's window - whatever the goal. Until 2026-10-14 incrementalConversions, conversionRateLift and significance counted buyers for every campaign, whatever its goal.

These figures do not sum​

There is deliberately no total. Each campaign is measured against a control group that still received everything else you sent, so the effects overlap - adding the rows together counts the same conversion under more than one campaign. The response carries totalsAreNotSummable: true so the shape itself says so.

Why significance matters here​

A campaign with a small control group can show a very large uplift from a single buyer's purchase. Only campaigns with at least minConvertersPerArm conversions in each arm are ranked; the rest are returned under stillCollecting with no number attached. The test is run on the conversion rate, which is a proportion and can be tested properly; revenue per recipient is heavy-tailed and is reported alongside as context rather than as proof.

Example Request​

curl -X GET "https://api-eu1.joryio.com/api/campaigns/performance?sinceDays=90" \
-H "Authorization: Bearer YOUR_API_KEY"

List Campaigns​

Endpoint​

GET /campaigns

Query Parameters​

ParameterTypeDefaultDescription
statusstring-Filter by status (comma-separated for multiple)
channelstring-Filter by channel (comma-separated for multiple)
tagsstring-Filter by tags (comma-separated)
qstring-Free-text search
createdBy / editedBystring-Filter by creator / last editor (comma-separated user ids)
createdFromstring-Only campaigns created on/after this ISO date
pagenumber1Page number
limitnumber20Results per page (max 100)

Example Request​

curl -X GET "https://api-eu1.joryio.com/api/campaigns?status=active&channel=email&limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"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
}
}

Get Campaign​

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"

Returns the full campaign object. Campaign status is one of: draft, scheduled, active, paused, completed, cancelled, archived, failed (permanent send error - see failureReason).


Update Campaign​

PUT /campaigns/:campaignId

The body accepts the same fields as Create Campaign - all optional; only the provided fields are updated.

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" }'
Editing a live campaign

Triggered and recurring campaigns stay active for their whole life. Editing an active campaign does not change what is currently sending - the edits are buffered as a pending draft. Call POST /campaigns/:campaignId/publish to apply them to the running campaign atomically, or POST /campaigns/:campaignId/discard-draft to throw them away. Draft campaigns are updated in place (no publish step).


Delete Campaign​

DELETE /campaigns/:campaignId

Returns 204 No Content. Only never-run drafts are hard-deleted; campaigns with send history should be archived instead (POST /campaigns/:campaignId/archive), which halts sending while preserving analytics.


Campaign Lifecycle​

MethodPathDescription
POST/campaigns/:campaignId/sendLaunch the campaign (starts sending / activates a triggered campaign)
POST/campaigns/:campaignId/pausePause a running campaign
POST/campaigns/:campaignId/resumeResume a paused campaign
POST/campaigns/:campaignId/publishApply buffered edits to a live campaign atomically (400 if none pending)
POST/campaigns/:campaignId/discard-draftDiscard buffered edits to a live campaign (no-op if none)
POST/campaigns/:campaignId/cancelCancel a campaign
POST/campaigns/:campaignId/resendRe-send ("Send again") a completed one-time campaign per its resendPolicy
POST/campaigns/:campaignId/retryRetry a failed campaign (moves it back to scheduled)
POST/campaigns/:campaignId/preview-launchPre-launch summary (audience size, checks) without sending
POST/campaigns/:campaignId/duplicateDuplicate a campaign as a new draft: every setting is copied (sender, schedule zone, recurring schedule, trigger, A/B settings), run state is reset (status, stats, approvals, scheduledAt). The copy's duplicatedFrom holds the source's campaignId, name, scheduledAt, scheduledTimezone and scheduledLocalAt
POST/campaigns/:campaignId/archiveArchive (halts sending, keeps history)
POST/campaigns/:campaignId/unarchiveRestore to a non-sending state (resume explicitly to send again)
POST/campaigns/:campaignId/stop-recurringStop future occurrences of a recurring campaign
curl -X POST https://api-eu1.joryio.com/api/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b/send \
-H "Authorization: Bearer jry_live_your_api_key"
Send again (re-send policy)

A completed one-time campaign can be re-sent with POST /campaigns/:campaignId/resend. Who receives it is governed by the campaign's resendPolicy:

  • only_new (default) - skip anyone who already received it (only never-reached users get it).
  • everyone - re-send to the whole audience, including prior recipients.
  • cooldown - re-send to everyone except those messaged in the last resendCooldownDays days.

Consent and suppression are always enforced. Frequency caps follow the campaign's ignoreTouchingRules flag. Triggered campaigns use triggerConfig.reEntry instead; recurring campaigns re-send on their schedule. Returns 400 for those types, in-app, or a campaign that hasn't finished sending.


Campaign Stats​

GET /campaigns/:campaignId/stats

Query parameters: startDate, endDate (optional). A date-only value (2026-07-20) means the whole day in your workspace timezone; a full ISO datetime is taken as the instant itself, which is what makes the "first 1h / 6h / 24h" windows on the campaign page real.

Response​

{
"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 is true when the workspace has a connected store integration (Shopify, WooCommerce or Magento), false when it has none, and null when that could not be determined.

conversionStats and revenue are populated when conversion tracking is configured; uplift is populated only when a variant is flagged isControlGroup.

Two things to read carefully in stats:

  • A rate is null, never 0, when the channel cannot measure it. openRate is null on sms, push, in_app, webhook and wallet; clickRate and clickToOpenRate are null on webhook. null means "not measured", which is a different claim from "nobody opened it" - render it as such.
  • rateBasis names the denominator. "recipients" (a campaign that sent once) divides unique people by messages delivered. "messages" (recurring, triggered or resent - anything that sent more than once) is the send-weighted per-message rate, and messageOpens / messageClicks carry its numerators. Under "recipients" those two are null and uniqueHumanOpened / uniqueHumanClicked are the honest counts.
  • rateOf names what openRate and clickRate are a share of (added October 2026; the rates did not change for email). Every open and click rate is unique people over delivered for email, SMS, WhatsApp and Viber; over sent for push, whose delivery receipts come from the device and undercount on iOS, and for any campaign with nothing reported delivered yet. The dashboard, the campaign page and this API use the same rule, so one campaign has one rate everywhere - see How open and click rates are calculated.
  • hardBounced, softBounced, otherBounced split bounced (added October 2026): bounces with bounceType hard, soft, and none recorded, counted by the same rule as bounced, so the three add up to it. autoReplied is the number of messages answered by an out-of-office or other automatic reply: delivered mail, never part of bounced or a bounce rate. It is null when returned-mail handling is off and the campaign has none - not measured, which is a different claim from 0; a count recorded while it was on is still returned.
MethodPathDescription
GET/campaigns/:campaignId/variant-statsPer-variant A/B stats with statistical significance (startDate/endDate)
GET/campaigns/:campaignId/linksLink click stats (startDate/endDate) - for an in-app campaign the rows are the message's named clicks (action, url = clicked URL or the name) per variant
GET/campaigns/:campaignId/failure-reasonsDelivery failures grouped by DLR error code ([{ code, reason, count }])
GET/campaigns/:campaignId/skip-reasonsNot sent breakdown: people the send deliberately did not message, one row per person by their latest skip reason ({ total, reasons: [{ reason, count }] }; reasons such as control_group, global_control, frequency_cap, channel_unsubscribed, quiet_time, missing_contact). List the people behind one reason with /campaigns/:campaignId/recipients?skipReason=<reason>
GET/campaigns/:campaignId/unsubscribe-reasonsUnsubscribes by reason: one row per person who unsubscribed through this campaign's links, by the reason on their latest unsubscribe, trimmed and lower-cased ({ total, reasons: [{ reason, count }], others }). The top 8 reasons are listed and the rest are summed in others; reason: null means no reason was given. Unsubscribes recorded before the reason was stored on the analytics event (October 2026) count as null
GET/campaigns/:campaignId/time-to-engageHow long recipients took to first open or click, bucketed - measured per recipient from their OWN send, so it is meaningful for triggered campaigns
GET/campaigns/:campaignId/conversion-timingWhen the attributed conversions happened (granularity = day or hour; startDate/endDate gate the send as on /stats). Same goal, window, model and query as conversionStats, split by the conversion's calendar day or hour in the workspace timezone; the buckets add up to the conversion count /stats returns for the same range. Returns { configured, granularity, total, buckets: [{ date, conversions }], majorityWithinHours } - majorityWithinHours is how many hours after the credited send more than half had converted; configured: false when the campaign has no conversion goal
GET/campaigns/:campaignId/engagement-timingSends, opens and clicks per day or hour (granularity) in the workspace timezone, for the event range startDate/endDate as on /stats. Counted with the /stats rules: count=people (default) puts each person once in the bucket of their first open / click, so the buckets add up to the unique opened / clicked; count=events counts every open and click. includeBots=true adds bot and scanner opens / clicks (default left out). Bucket keys match conversion-timing. Returns { granularity, count, includeBots, buckets: [{ date, sent, opened, clicked }], totals: { sent, opened, clicked } }
GET/campaigns/:campaignId/recovered-cartsThe abandoned carts this campaign recovered - the list behind revenue.recoveredCarts on /stats, from the same query, so total equals that count for the same startDate/endDate (carts recovered in the range; a date-only end includes the whole day, workspace timezone). limit 1-100 (default 25), offset; newest first. Rows: orderNumber (the store's), placedAt, recoveredAt, match (recovery_link, order = same cart, reported), cartValue/cartCurrency, creditedRevenue/creditedCurrency (net of refunds), contact (the cart's owner) and buyer (only when the order was stored under someone else); contact names are masked without pii:view. Returns { data, total, limit, offset, summary }
GET/campaigns/:campaignId/send-occurrencesPer-send breakdown for a recurring campaign, keyed on send date - engagement is attributed to the send that preceded it
GET/campaigns/:campaignId/recipientsPaginated recipients, one row per person with the furthest stage they reached (furthestStatus). Filter with status (sent, delivered, opened, clicked, failed, bounced, unsubscribed, skipped); page with limit (max 200) and offset. Sort with sortBy - reached (furthest stage), lastActivity or events - and sortOrder (asc / desc, default desc); without sortBy the newest first event leads, and every order ends in the person's id so pages never repeat or skip anyone. includeBots=true counts bot, scanner and machine opens and clicks as the person's; by default they are left out of the stage and of eventCount, the same rule as the open and click rates
GET/campaigns/:campaignId/recipients/:userIdPer-user message event timeline for this campaign. For a webhook campaign it also returns webhookResponse ({ status, contentType, body, truncated, at }) when the latest failed call to this person (non-2xx) has a stored response: masked, the first 2 KB of the body, kept 30 days. Returned only to a caller who may see personal data (pii:view; an API key only with that scope). Absent when nothing is stored or Joryio staff turned response storage off for the organization
GET/campaigns/:campaignId/in-app-statsIn-app display stats (in-app campaigns). Includes displayFrequency - the per-user impression distribution as { tailBucket, buckets: [{ displays, users, impressions }], maxPerUser }. Counts at or above tailBucket are collapsed into one bucket, so displays === tailBucket means "that many or more"; use each bucket's impressions (not displays * users) to compute an average, and maxPerUser for the single most-interrupted user
GET/campaigns/:campaignId/impressionsIn-app impressions list (limit, offset, startDate, endDate)

Send Transactional Message​

Send a one-off message to a single user without creating a campaign.

POST /campaigns/transactional/send

Request Body​

FieldTypeRequiredDescription
userIdstringYesTarget user id
channelstringYesemail, sms, push, or viber
messageobjectYessubject (email), body (plain text), html (email). May be {} for viber - the approved template body is the message
viberTemplateIdstringViber onlyAn approved template from the Viber template registry. Rakuten Viber mandates pre-approved templates for transactional/OTP messages (since July 2026); unapproved content bills at the promotional rate, so the API refuses to send without one
variablesobjectNoViber only - values for the template's dynamic fields (merged into the personalization context)
triggerDataobjectNoContext available to personalization (type, name, properties, metadata)
idempotencyKeystringNoCaller-supplied dedup key - a retry with the same key is not delivered twice
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"
}'

Additional Endpoints​

MethodPathDescription
POST/campaigns/previewPreview users matching targeting criteria (body: targeting, optional limit max 500, channel, webAppId, platforms for push - the campaign's channelConfig.platforms; targeting.tags are the campaign's tags, which decide suppression-list exemptions as at send time)
POST/campaigns/spam-checkSpam-check email content before sending
GET/campaigns/:campaignId/versionsList saved version snapshots
GET/campaigns/:campaignId/versions/compare?v1=&v2=Diff two versions
GET/campaigns/:campaignId/versions/:versionIdGet a version snapshot
POST/campaigns/:campaignId/versions/:versionId/rollbackRoll back to a version
GET/campaigns/:campaignId/historyAudit-log history (limit, max 200)
POST/campaigns/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchiveBulk actions; body { "ids": [...] }, returns { succeeded, failed }
POST/campaigns/bulk-tagBulk tagging; body { "ids": [...], "tags": [...] }
POST/campaigns/:campaignId/send-test-whatsapp / send-test-sms / send-test-pushTest sends to a phone/user before launch
POST/campaigns/:campaignId/render-for-contactRenders the campaign for one contact and returns { html, css }. Sends nothing - in-app is pull-based, so there is no device to deliver to. Replaces send-test-in-app, which still works
GET/campaigns/:campaignId/sto-coverageSend-time-optimization coverage for the audience
GET/campaigns/:campaignId/recurring-statusRecurring campaign status
POST/campaigns/:campaignId/retest-abReset the A/B winner on a recurring winning-variant campaign
POST/campaigns/:campaignId/launch-rlLaunch in AI-optimized (RL) mode
GET/campaigns/:campaignId/rl-statsAI-optimized campaign dashboard stats
POST/campaigns/:campaignId/pause-rl / resume-rlPause / resume an AI-optimized campaign
POST/campaigns/ml-path-warmthPersonalization warm-up status for path/variant ids

Launch Review​

The pre-send check surface (see the Launch Review guide). The same review runs inside send, so what this returns is what the send is judged on.

GET /campaigns/{campaignId}/launch-review

Scope: 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": []
}

Codes: blockers unsubscribe_missing, merge_field_broken, sender_not_verified, sender_not_found, link_unusable (a blocker only on a send with a conversion goal); recommendations subject_long, subject_discount_words, subject_hype_adjective, preheader_repeats_subject, experiment_opportunity, plain_text_missing, audience_overlap, at_message_limit (people at their frequency cap: params.count, sampled, sharePct, bypassed, partial), quiet_hours_now (people inside a quiet window at the send instant: params.count, sampled, sharePct, behaviour, until, evaluatedAt, at, partial), quiet_hours_ignored, consent_bypassed, node_unreachable, and model_note (a note from the AI review; params.title and params.detail). The response also carries ai: { status: 'ok' | 'skipped' | 'off', reason?, version, cached? } - the AI pass runs only on this route, never inside the send gate, and a failed pass is a skipped reason, not an error. fix.action is one of edit_content, edit_audience, edit_delivery, edit_node, choose_sender, verify_domain, open_subscription_settings, open_quiet_time, open_frequency_rules, edit_journey.

Every finding carries a key, stable across reviews (a model_note is keyed by its scope and title). Where a finding points at something exact it also carries locator: images - each image as { src, index, bytes?, id? }, src being the FULL attribute value (the params.src of a sentence is cut to 120 characters), index its place among the email's content images, bytes its measured weight; words (the brand vocabulary to avoid that the message uses) with fields (subject, preheader, html) where they sit; or text, the block a design finding is about. A model_note about one part of the message names it in scope.field and fix.field. AI suggestions (model_note, experiment_opportunity) a person dismissed in the dashboard are left out of this response; nothing else can be dismissed. A model_note the workspace's review preferences cover (made with "Don't suggest this again"; listed in Settings → Launch review → Review preferences) is left out too, and a model_note already dismissed on three or more different campaigns or journeys in the last 90 days carries params.dismissedOn, that count. See Launch Review.

requireClean mirrors the workspace setting settings.launchReview.requireClean (default true). When it is true and blockers is not empty, POST /campaigns/{campaignId}/send answers:

{ "statusCode": 409, "code": "launch_review_blocked", "message": "Launch Review: 1 blocker: merge_field_broken (Variant A: subject)", "blockers": [ ... ] }

A scheduled campaign refused at its send time lands in status failed with that message as failureReason.

Error Responses​

All errors share the standard shape - see Error Response in the API Overview for the format and full status-code list.

{
"statusCode": 404,
"message": "Campaign not found",
"timestamp": "2026-07-12T09:00:00.000Z",
"path": "/campaigns/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b"
}
StatusWhen
400Validation failed (e.g. invalid channel, missing variant weight) - the body adds an errors array with one message per failed field
401Missing or invalid API key
403API key lacks the required campaigns:* scope
404Campaign not found in this workspace
409Lifecycle conflict - e.g. resuming a campaign that is no longer paused ("Campaign is no longer paused.") or sending one that is no longer in a sendable state

An API key cannot approve

Approval requires a signed-in user. An approval records who reviewed the campaign, and separation of duties is judged by comparing the approver against the submitter - neither is possible for a key. A request authenticated with an API key is refused with 403.