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:
| Scope | Endpoints |
|---|---|
campaigns:read | List, get, stats, recipients, versions, history |
campaigns:write | Create, update, duplicate, archive, tag, versions rollback |
campaigns:send | Send, pause, resume, cancel, retry, test sends, transactional send |
campaigns:delete | Delete, bulk delete |
See API Keys for scope management.
Create Campaign
Endpoint
POST /campaigns
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name (max 255 chars) |
description | string | No | Description (max 1000 chars) |
channel | string | Yes | email, sms, viber, push, webhook, whatsapp, in_app, or ai_optimized |
variants | array | Yes* | Message variants (required for every channel except in_app) |
targeting | object | No | Audience: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference |
sendType | string | No | immediate, scheduled, triggered, intelligent, recurring, or ai_optimized |
scheduledAt | string | No | ISO 8601 date for sendType: "scheduled" |
scheduledTimezone | string | No | IANA 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. |
triggerConfig | object | No | Trigger rule for sendType: "triggered" (type, eventName, conditions, reEntry, cooldownHours) |
recurringSchedule | object | No | For 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. |
conversionTracking | object | No | primaryConversion / secondaryConversions (event name + property conditions), conversionWindowHours, attributionModel (first_touch/last_touch/linear) |
emailConfigId | string | No | Saved email sender identity (email channel) |
appId | string | Push | Target 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. |
subscriptionCategoryId | string | No | Consent category (subscription list) the campaign sends under |
sendVolumeLimit | object | No | enabled, 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:
mode | Fields | Rendered as |
|---|---|---|
native | title, body, imageUrl, buttons, closeButton, backdropDismissible, style | The app's own components - no web view |
html (default) | html, css, maxWidth | Author-supplied markup in a web view |
drag_drop | html, css, grapejsData, maxWidth | As 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.
| Field | Type | Applies on | Meaning |
|---|---|---|---|
backgroundColor | string | web, iOS, Android | Card background |
textColor | string | web, iOS, Android | Headline and body (body is softened) |
primaryButtonColor | string | web, iOS, Android | Fill of the first button |
primaryButtonTextColor | string | web, iOS, Android | Its label. Omit and the renderer picks black or white by contrast against the fill |
cornerRadius | number | web, iOS, Android | 0-48. Ignored for fullscreen, where rounded corners would leak the app through the corners |
fontSize | number | web, iOS, Android | 10-32. Body size; the headline scales from it. Mobile still applies the reader's text-size setting on top |
titleWeight | string | web, iOS, Android | regular, medium, semibold, bold. The HEADLINE only - body stays regular for legibility |
textAlign | string | web, iOS, Android | auto (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 |
fontFamily | string | web; mobile best effort | Applied 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 |
customCss | string | web only | Hand-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:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Variant id |
name | string | Yes | Variant name |
weight | number | Yes | Traffic share, 0-100 (weights must sum correctly per channel rules) |
message | object | No | Channel 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 |
isControlGroup | boolean | No | Marks 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
| Parameter | Type | Default | Description |
|---|---|---|---|
sinceDays | number | 90 | Look-back window for which campaigns to include |
Response
| Field | Type | Description |
|---|---|---|
ranked | array | Campaigns whose measurement cleared the significance floor, best first |
stillCollecting | array | Measured, but not yet conclusive. A separate list on purpose - these have no verdict, and placing them below the losers would say they lost |
noHoldout | number | Campaigns with no control group. Their effect cannot be measured, so they are counted rather than listed |
noGoal | number | Campaigns with a control group but no conversion goal. There is nothing to compare, so they are counted rather than listed |
sinceDays | number | The 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
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | - | Filter by status (comma-separated for multiple) |
channel | string | - | Filter by channel (comma-separated for multiple) |
tags | string | - | Filter by tags (comma-separated) |
q | string | - | Free-text search |
createdBy / editedBy | string | - | Filter by creator / last editor (comma-separated user ids) |
createdFrom | string | - | Only campaigns created on/after this ISO date |
page | number | 1 | Page number |
limit | number | 20 | Results 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" }'
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
| Method | Path | Description |
|---|---|---|
POST | /campaigns/:campaignId/send | Launch the campaign (starts sending / activates a triggered campaign) |
POST | /campaigns/:campaignId/pause | Pause a running campaign |
POST | /campaigns/:campaignId/resume | Resume a paused campaign |
POST | /campaigns/:campaignId/publish | Apply buffered edits to a live campaign atomically (400 if none pending) |
POST | /campaigns/:campaignId/discard-draft | Discard buffered edits to a live campaign (no-op if none) |
POST | /campaigns/:campaignId/cancel | Cancel a campaign |
POST | /campaigns/:campaignId/resend | Re-send ("Send again") a completed one-time campaign per its resendPolicy |
POST | /campaigns/:campaignId/retry | Retry a failed campaign (moves it back to scheduled) |
POST | /campaigns/:campaignId/preview-launch | Pre-launch summary (audience size, checks) without sending |
POST | /campaigns/:campaignId/duplicate | Duplicate 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/archive | Archive (halts sending, keeps history) |
POST | /campaigns/:campaignId/unarchive | Restore to a non-sending state (resume explicitly to send again) |
POST | /campaigns/:campaignId/stop-recurring | Stop 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"
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 lastresendCooldownDaysdays.
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, never0, when the channel cannot measure it.openRateisnullonsms,push,in_app,webhookandwallet;clickRateandclickToOpenRatearenullonwebhook.nullmeans "not measured", which is a different claim from "nobody opened it" - render it as such. rateBasisnames 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, andmessageOpens/messageClickscarry its numerators. Under"recipients"those two arenullanduniqueHumanOpened/uniqueHumanClickedare the honest counts.rateOfnames whatopenRateandclickRateare a share of (added October 2026; the rates did not change for email). Every open and click rate is unique people overdeliveredfor email, SMS, WhatsApp and Viber; oversentfor 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,otherBouncedsplitbounced(added October 2026): bounces withbounceTypehard, soft, and none recorded, counted by the same rule asbounced, so the three add up to it.autoRepliedis the number of messages answered by an out-of-office or other automatic reply: delivered mail, never part ofbouncedor a bounce rate. It isnullwhen returned-mail handling is off and the campaign has none - not measured, which is a different claim from0; a count recorded while it was on is still returned.
Related stats endpoints
| Method | Path | Description |
|---|---|---|
GET | /campaigns/:campaignId/variant-stats | Per-variant A/B stats with statistical significance (startDate/endDate) |
GET | /campaigns/:campaignId/links | Link 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-reasons | Delivery failures grouped by DLR error code ([{ code, reason, count }]) |
GET | /campaigns/:campaignId/skip-reasons | Not 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-reasons | Unsubscribes 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-engage | How 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-timing | When 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-timing | Sends, 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-carts | The 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-occurrences | Per-send breakdown for a recurring campaign, keyed on send date - engagement is attributed to the send that preceded it |
GET | /campaigns/:campaignId/recipients | Paginated 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/:userId | Per-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-stats | In-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/impressions | In-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
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Target user id |
channel | string | Yes | email, sms, push, or viber |
message | object | Yes | subject (email), body (plain text), html (email). May be {} for viber - the approved template body is the message |
viberTemplateId | string | Viber only | An 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 |
variables | object | No | Viber only - values for the template's dynamic fields (merged into the personalization context) |
triggerData | object | No | Context available to personalization (type, name, properties, metadata) |
idempotencyKey | string | No | Caller-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
| Method | Path | Description |
|---|---|---|
POST | /campaigns/preview | Preview 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-check | Spam-check email content before sending |
GET | /campaigns/:campaignId/versions | List saved version snapshots |
GET | /campaigns/:campaignId/versions/compare?v1=&v2= | Diff two versions |
GET | /campaigns/:campaignId/versions/:versionId | Get a version snapshot |
POST | /campaigns/:campaignId/versions/:versionId/rollback | Roll back to a version |
GET | /campaigns/:campaignId/history | Audit-log history (limit, max 200) |
POST | /campaigns/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchive | Bulk actions; body { "ids": [...] }, returns { succeeded, failed } |
POST | /campaigns/bulk-tag | Bulk tagging; body { "ids": [...], "tags": [...] } |
POST | /campaigns/:campaignId/send-test-whatsapp / send-test-sms / send-test-push | Test sends to a phone/user before launch |
POST | /campaigns/:campaignId/render-for-contact | Renders 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-coverage | Send-time-optimization coverage for the audience |
GET | /campaigns/:campaignId/recurring-status | Recurring campaign status |
POST | /campaigns/:campaignId/retest-ab | Reset the A/B winner on a recurring winning-variant campaign |
POST | /campaigns/:campaignId/launch-rl | Launch in AI-optimized (RL) mode |
GET | /campaigns/:campaignId/rl-stats | AI-optimized campaign dashboard stats |
POST | /campaigns/:campaignId/pause-rl / resume-rl | Pause / resume an AI-optimized campaign |
POST | /campaigns/ml-path-warmth | Personalization 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"
}
| Status | When |
|---|---|
400 | Validation failed (e.g. invalid channel, missing variant weight) - the body adds an errors array with one message per failed field |
401 | Missing or invalid API key |
403 | API key lacks the required campaigns:* scope |
404 | Campaign not found in this workspace |
409 | Lifecycle 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 |
Related
- Creating Campaigns
- Campaign Analytics
- Segments API - build the audiences campaigns target
- Canvas API - multi-step journeys
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.