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:
| 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 |
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 |
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) |
subscriptionCategoryId | string | No | Consent category (subscription list) the campaign sends under |
sendVolumeLimit | object | No | enabled, 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:
mode | Fields | Rendered as |
|---|---|---|
native | title, body, imageUrl, buttons, closeButton, backdropDismissible, style | The app's own components - no web view |
html (default) | html, css | Author-supplied markup in a web view |
drag_drop | html, css, grapejsData | As 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.
| 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 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:
| 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. 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/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
| 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/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" }'
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 |
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/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 (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.
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) |
GET | /campaigns/:campaignId/failure-reasons | Delivery failures grouped by DLR error code ([{ code, reason, count }]) |
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/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 with last message status (status, limit max 200, offset) |
GET | /campaigns/:campaignId/recipients/:userId | Per-user message event timeline for this campaign |
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/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) |
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 / send-test-in-app | Test sends to a phone/user before launch |
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 |
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