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 - 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
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
conversionTrackingobjectNoprimaryConversion / secondaryConversions (event name + property conditions), conversionWindowHours, attributionModel (first_touch/last_touch/linear)
emailConfigIdstringNoSaved email sender identity (email channel)
subscriptionCategoryIdstringNoConsent category (subscription list) the campaign sends under
sendVolumeLimitobjectNoenabled, maxSends, cadence (lifetime/per_send)

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, cssAuthor-supplied markup in a web view
drag_drophtml, css, grapejsDataAs html; grapejsData is the visual editor's state

mode may be omitted, which means 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 runs author-supplied JavaScript inside the app. 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" | | 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. 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/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": "news@example.com",
"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"
}

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/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/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/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
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/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 (ISO 8601, optional).

Response

{
"campaignId": "8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b",
"name": "July Newsletter",
"channel": "email",
"status": "completed",
"stats": {
"queued": 1200,
"sent": 1180,
"delivered": 1150,
"failed": 30,
"opened": 640,
"clicked": 210
},
"conversionStats": { ... },
"revenue": { ... },
"uplift": null,
"conversionTracking": { ... },
"startedAt": "2026-07-20T14:00:00.000Z",
"completedAt": "2026-07-20T14:12:00.000Z",
"createdAt": "2026-07-12T09:00:00.000Z"
}

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

MethodPathDescription
GET/campaigns/:campaignId/variant-statsPer-variant A/B stats with statistical significance (startDate/endDate)
GET/campaigns/:campaignId/linksLink click stats (startDate/endDate)
GET/campaigns/:campaignId/failure-reasonsDelivery failures grouped by DLR error code ([{ code, reason, count }])
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/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 with last message status (status, limit max 200, offset)
GET/campaigns/:campaignId/recipients/:userIdPer-user message event timeline for this campaign
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/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)
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-push / send-test-in-appTest sends to a phone/user before launch
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

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