Analytics API
The Analytics API provides programmatic access to product analytics features including event queries, funnels, retention analysis, and path analysis.
All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.
Authentication
All endpoints require authentication via API key:
Authorization: Bearer YOUR_API_KEY
X-Workspace-Id: YOUR_WORKSPACE_ID
Event Explorer
List Events
Get a summary of all tracked events.
GET /analytics/events
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| startDate | string | Start date (YYYY-MM-DD) |
| endDate | string | End date (YYYY-MM-DD) |
| search | string | Filter by event name |
| limit | number | Max results (default: 100) |
| channels | string | Optional. Count message and in-app events only on these channels, comma-separated - see Channel filter |
Response:
{
"events": [
{
"eventName": "signup_completed",
"totalCount": 15420,
"uniqueUsers": 12350,
"lastSeen": "2024-01-15T14:30:00Z"
}
],
"total": 45
}
Channel filter
GET /analytics/events, POST /analytics/events/trend and POST /analytics/events/query take an optional channels filter. It applies only to message events (message.*) and in-app events (in_app.*): those are counted only when their channel is in the list. Events with no channel - purchases, sessions, custom events - are always counted, whatever the list says.
- Channels:
email,sms,whatsapp,push,viber,webhook,wallet,in_app,connector. Any other value is rejected with400. GETtakes a comma-separated list (channels=email,sms); thePOSTroutes take a JSON array ("channels": ["email", "sms"]).- Omitted: no filter - the same numbers as before the parameter existed. Empty (
channels=or[]): no message or in-app events at all.
When channels is sent, GET /analytics/events also returns hiddenByChannel: the message and in-app events the filter left out, per channel.
{
"events": [],
"total": 45,
"hiddenByChannel": [{ "channel": "webhook", "count": 4012 }]
}
Get Event Trend
Get event counts over time.
POST /analytics/events/trend
Accepts the optional channels filter - see Channel filter.
Request Body:
{
"eventNames": ["signup_completed", "purchase_completed"],
"startDate": "2024-01-01",
"endDate": "2024-01-31",
"timeGranularity": "day"
}
Response:
[
{
"date": "2024-01-01",
"count": 523,
"uniqueUsers": 498
}
]
Get Event Properties
List properties for a specific event.
GET /analytics/events/:eventName/properties
Response:
[
{
"propertyName": "plan_type",
"valueCount": 4
},
{
"propertyName": "source",
"valueCount": 12
}
]
Get Property Breakdown
Get value distribution for an event property.
POST /analytics/events/property-breakdown
Request Body:
{
"eventName": "purchase_completed",
"property": "payment_method",
"startDate": "2024-01-01",
"endDate": "2024-01-31"
}
Response:
{
"total": 5420,
"values": [
{ "value": "credit_card", "count": 3250, "percentage": 59.96 },
{ "value": "paypal", "count": 1520, "percentage": 28.04 },
{ "value": "bank_transfer", "count": 650, "percentage": 11.99 }
]
}
Funnel Analysis
Create Funnel
Save a new funnel definition.
POST /analytics/funnels
Request Body:
{
"name": "Signup to Purchase",
"steps": [
{
"id": "step_1",
"order": 0,
"eventName": "signup_completed",
"filters": []
},
{
"id": "step_2",
"order": 1,
"eventName": "onboarding_completed",
"filters": []
},
{
"id": "step_3",
"order": 2,
"eventName": "purchase_completed",
"filters": []
}
],
"conversionWindowDays": 7
}
List Funnels
GET /analytics/funnels
Analyze Funnel
Run analysis on a saved funnel.
POST /analytics/funnels/:id/analyze
Request Body:
{
"startDate": "2024-01-01",
"endDate": "2024-01-31",
"breakdown": {
"type": "event_property",
"property": "utm_source"
}
}
Response:
{
"totalUsers": 10000,
"overallConversion": 12.5,
"steps": [
{
"id": "step_1",
"order": 0,
"eventName": "signup_completed",
"enteredCount": 10000,
"conversionRate": 100,
"dropOffRate": 0
},
{
"id": "step_2",
"order": 1,
"eventName": "onboarding_completed",
"enteredCount": 6500,
"conversionRate": 65,
"dropOffRate": 35
},
{
"id": "step_3",
"order": 2,
"eventName": "purchase_completed",
"enteredCount": 1250,
"conversionRate": 19.23,
"dropOffRate": 80.77
}
],
"breakdown": [
{
"breakdownValue": "google",
"overallConversion": 15.2,
"steps": [...]
}
]
}
Quick Funnel Analysis
Analyze without saving.
POST /analytics/funnels/quick-analysis
Retention Analysis
Analyze Retention
POST /analytics/retention/analyze
Request Body:
{
"startEvent": "signup_completed",
"returnEvent": "session_started",
"startDate": "2024-01-01",
"endDate": "2024-01-31",
"timeUnit": "week",
"periods": 8
}
Response:
{
"timeUnit": "week",
"cohorts": [
{
"cohortDate": "2024-01-01",
"cohortSize": 1250,
"periods": [
{ "period": 0, "retained": 1250, "retentionRate": 100 },
{ "period": 1, "retained": 562, "retentionRate": 44.96 },
{ "period": 2, "retained": 375, "retentionRate": 30.0 }
]
}
],
"overallRetention": [100, 45.2, 31.5, 25.8, 22.1, 19.5, 17.8, 16.2]
}
Export Retention
POST /analytics/retention/export
Returns CSV data.
Compare Segment Retention
Runs the same retention analysis over 1 to 10 saved segments and returns one row per segment, so their curves can be compared. This replaced POST /analytics/retention/compare-cohorts, which no longer exists.
POST /analytics/retention/compare-segments
Request Body:
{
"segmentIds": ["seg_9f2a", "seg_71bc"],
"startDate": "2024-01-01",
"endDate": "2024-01-31",
"entryEvent": "signup_completed",
"returnEvent": "session_started",
"timeUnit": "week",
"periods": 8
}
| Field | Required | Description |
|---|---|---|
segmentIds | yes | 1 to 10 segment ids. |
startDate, endDate | yes | The window entry events are read from. |
entryEvent | no | The action that puts a user into a cohort. |
returnEvent | no | The action that counts as coming back. |
timeUnit | no | day, week or month. |
periods | no | How many periods to track. |
Response:
[
{
"segmentId": "seg_9f2a",
"segmentName": "Power users",
"retention": { "timeUnit": "week", "cohorts": [], "overallRetention": [] },
"resolvedUsers": 4820,
"analyzedUsers": 4820,
"truncated": false
},
{
"segmentId": "seg_71bc",
"segmentName": "All customers",
"retention": { "timeUnit": "week", "cohorts": [], "overallRetention": [] },
"resolvedUsers": 480000,
"analyzedUsers": 50000,
"truncated": true
}
]
| Field | Description |
|---|---|
resolvedUsers | The segment's true size - every member it resolved to. |
analyzedUsers | How many members retention was computed from. Equal to resolvedUsers unless the segment was capped. |
truncated | true when the segment was over the 50,000-member cap, so retention describes a deterministic random sample of analyzedUsers members - never the first N, which would be biased. Disclose both numbers wherever you show the rates. |
error | Present when this one segment could not be analysed. The other rows are still returned, and the failed row carries no rates - do not render it as 0%. |
Path Analysis
Analyze Paths
POST /analytics/paths/analyze
Request Body:
{
"startDate": "2024-01-01",
"endDate": "2024-01-31",
"startEvent": "landing_page_view",
"direction": "forward",
"maxSteps": 5,
"minPathCount": 10,
"minPathPercent": 0.5,
"maxBranchesPerStep": 5,
"excludeEvents": ["heartbeat", "scroll"]
}
minPathCount (absolute users) and minPathPercent (percent of qualifying users, 0-100) set the frequency threshold - a path must clear the larger of the two, so the percentage keeps it portable across workspaces of any size. maxBranchesPerStep (1-50, default 8) caps the flow diagram to the top next events per step so it stays readable at high volume.
Response:
{
"totalUsers": 25000,
"totalPaths": 342,
"paths": [
{
"path": ["landing_page_view", "signup_started", "signup_completed"],
"count": 3250,
"percentage": 13.0
}
],
"sankey": {
"nodes": [
{ "id": "landing_page_view_0", "name": "landing_page_view" }
],
"links": [
{ "source": "landing_page_view_0", "target": "signup_started_1", "value": 5200 }
]
}
}
Dashboards
Create Dashboard
POST /analytics/dashboards
Request Body:
{
"name": "Executive Dashboard",
"description": "Key metrics overview",
"widgets": [
{
"id": "widget-1",
"type": "metric",
"title": "Active Users",
"config": {
"eventName": "session_started",
"metric": "unique_users"
},
"layout": { "x": 0, "y": 0, "width": 3, "height": 2 }
}
]
}
List Dashboards
GET /analytics/dashboards
Get Dashboard
GET /analytics/dashboards/:id
Update Dashboard
PUT /analytics/dashboards/:id
Delete Dashboard
DELETE /analytics/dashboards/:id
Real-time Analytics
Get Active Users
GET /analytics/realtime/active-users
Response:
{
"count": 1523,
"trend": 5.2
}
Live Events Stream (SSE)
GET /analytics/realtime/events/stream
Returns a Server-Sent Events stream of live events.
Incrementality
Two endpoints answer questions the ordinary reports cannot: did the messages cause anything, and am I sending too much. Both are built on the global control group - a randomly chosen slice of contacts who are held back from all marketing so there is something honest to compare against.
Global Holdout Uplift
GET /analytics/global-holdout?startDate=2026-08-01&endDate=2026-08-31
Conversion and revenue for everyone who received marketing in the period against the randomly held-back group.
The uplift is wrapped in an uplift envelope. It is { "uplift": null } when no
global control group is running - there is nothing to compare against, and an
unmeasured result is not a zero.
Response:
{
"uplift": {
"treatmentRecipients": 41280,
"treatmentConverters": 1650,
"treatmentRevenue": 82560,
"treatmentRevenuePerRecipient": 2.0,
"controlRecipients": 4600,
"controlConverters": 138,
"controlRevenue": 6900,
"controlRevenuePerRecipient": 1.5,
"incrementalRevenue": 20640,
"upliftPct": 33.3,
"significance": {
"controlRate": 0.03,
"treatmentRate": 0.04,
"pValue": 0.002,
"significant": true,
"controlInterval": [0.0255, 0.0353],
"treatmentInterval": [0.0381, 0.0419],
"reason": "significant",
"minConvertersPerArm": 10
},
"currency": "EUR",
"mixedCurrency": false,
"windowHours": 168
}
}
significance.significant tests the conversion RATE, not revenue: revenue per
recipient is continuous and heavy-tailed, and a proportion test on it emits
confident-looking numbers that mean nothing. Treat incrementalRevenue
((treatmentRevenuePerRecipient - controlRevenuePerRecipient) x treatmentRecipients)
as an estimate whose credibility comes from significance.significant, not from
its own precision. significance is null when either arm has no recipients;
reason says why a result is not significant yet (not_enough_conversions,
no_difference_detected). upliftPct is null when the control arm earned
nothing. windowHours is the behavioural window both arms were measured over
(default 168), and mixedCurrency is true when the purchases span more than
one currency - no FX is applied.
Message Frequency
GET /analytics/message-frequency?startDate=2026-08-01&endDate=2026-08-31
Conversion by how many messages a person received, split by whether they were already engaged before the period.
Response:
{
"buckets": [
{ "bucket": "0", "tier": "engaged", "people": 2300, "buyers": 69, "purchaseRate": 3.0, "converters": 69, "conversionRate": 3.0,
"revenue": 3450, "revenuePerPerson": 1.5, "randomised": true },
{ "bucket": "1", "tier": "engaged", "people": 8100, "buyers": 405, "purchaseRate": 5.0, "converters": 405, "conversionRate": 5.0,
"revenue": 20250, "revenuePerPerson": 2.5 },
{ "bucket": "6-9", "tier": "engaged", "people": 1200, "buyers": 48, "purchaseRate": 4.0, "converters": 48, "conversionRate": 4.0,
"revenue": 4800, "revenuePerPerson": 4.0 }
],
"anchored": true,
"currency": "EUR",
"mixedCurrency": false,
"priorWindowDays": 90
}
Buckets are 0, 1, 2, 3, 4-5, 6-9, 10+. tier is engaged or
quiet, decided by opens and clicks in the priorWindowDays before the
period - never inside it, which would be circular.
Two fields decide how the numbers may be read:
randomisedappears only on a0bucket built from the global control group. A zero built any other way is people who happened to receive nothing, and they received nothing for reasons that also make them less likely to buy.anchoredistruewhen such a zero exists. When it isfalsethere is no0bucket at all, deliberately: an invented baseline would sit at the exact point every other number is measured against.
Read the curve knowing the bias runs one way. People who receive more messages are more engaged to begin with, so the line is pushed upward whatever the truth is. A rising line therefore proves nothing, while a line that flattens or falls at higher frequencies is credible - it appeared despite a bias working against it.
converters counts orders in every currency; revenue sums only the dominant
one, named in currency. When mixedCurrency is true the money column is one
slice rather than an FX-sum.
buyers and purchaseRate are purchases (orders), not conversions: the curve covers the whole workspace, so there is no campaign or journey goal to count. converters and conversionRate are the same two numbers under their old names, kept for existing clients.
Content Performance
GET /analytics/content-performance?startDate=2026-08-01&endDate=2026-08-31&channel=email
Open, click, goal-conversion and post-send purchase rates by what the message is - its
tone, offer type, call to action, length, personalization, imagery and
topic - across every template, campaign variant and journey message.
channel is optional.
Response:
{
"startDate": "2026-08-01",
"endDate": "2026-08-31",
"channel": null,
"minReached": 100,
"conversionWindowHours": 168,
"taxonomyVersion": "t1",
"totals": { "sent": 18400, "reached": 12100, "labelledSent": 17900, "unlabelledSent": 500, "identities": 42, "labelledIdentities": 40, "noGoalSent": 2300 },
"fields": {
"tone": [
{ "value": "friendly", "sent": 9100, "reached": 6000, "opened": 2520, "clicked": 420, "converted": 150, "goalReached": 5000, "bought": 180, "openRate": 42.0, "clickRate": 7.0, "conversionRate": 3.0, "purchaseRate": 3.0 },
{ "value": "urgent", "sent": 2200, "reached": 1500, "opened": 720, "clicked": 150, "converted": 0, "goalReached": 0, "bought": 60, "openRate": 48.0, "clickRate": 10.0, "conversionRate": null, "purchaseRate": 4.0 },
{ "value": "premium", "sent": 90, "reached": 80, "opened": 30, "clicked": 4, "converted": 1, "goalReached": 80, "bought": 1, "openRate": null, "clickRate": null, "conversionRate": null, "purchaseRate": null }
],
"offerType": [ "..." ],
"ctaType": [ "..." ]
},
"mergeFields": {
"version": "m2",
"measuredSent": 17400,
"pendingSent": 500,
"buckets": [
{
"field": "user.first_name", "placement": "subject", "usage": "merge", "channels": ["email"],
"with": { "sent": 6100, "reached": 4200, "opened": 1932, "clicked": 315, "converted": 120, "goalReached": 3800, "bought": 130, "openRate": 46.0, "clickRate": 7.5, "conversionRate": 3.16, "purchaseRate": 3.1 },
"without": { "sent": 8800, "reached": 5900, "opened": 2242, "clicked": 354, "converted": 140, "goalReached": 5200, "bought": 160, "openRate": 38.0, "clickRate": 6.0, "conversionRate": 2.69, "purchaseRate": 2.71 }
}
],
"other": null
}
}
fields has one list per label (tone, urgency, offerType, ctaType,
lengthBand, personalizationDepth, imageryPresent, primaryTopic),
sorted by people reached. Rates count unique people, bots excluded; openRate
and clickRate are a share of the people the message was delivered to (push:
of reached; in-app: of displayed) - the same rule as campaign rates (see
How open and click rates are calculated),
so they can be higher than the same counts over reached. A rate
is null below minReached people, and on a channel that cannot measure
it (push has no opens). conversionRate and purchaseRate are described
below. totals.unlabelledSent is traffic whose content has no
label yet; POST /ai/content/backfill (dashboard) labels existing content.
conversionRate and converted are each message's own conversion goal - the goal of its campaign or journey, counted with that goal's window and attribution model and credited to the variant or journey message the model credits - over goalReached, the people reached by messages that have a goal. A row where no message has a goal has conversionRate: null, and a template sent on its own never counts as converting. totals.noGoalSent is the traffic with no goal. purchaseRate and bought are the orders figure: a purchase by the same person within conversionWindowHours after the send, a plain window whatever the goal. Until 2026-10-14 conversionRate was that purchase rate.
mergeFields compares content by merge field: one bucket per field name, placement (subject, preheader, title, body, cta, link - a link address; WhatsApp {{n}} placeholders count as the field they are bound to) and usage (merge = printed, conditional = only decides what shows), with the same counts and rates for the labelled sends that use it (with) and for the labelled sends on the same channels that do not (without). The minReached floor applies to each side. Field names only, never values. Up to 100 combinations are listed, most people reached first; the rest are summed into other, never dropped. pendingSent is labelled traffic whose merge fields have not been read yet; it is on neither side. The comparison is correlation, not cause.
Error Responses
All endpoints return standard error responses:
{
"statusCode": 400,
"message": "Invalid date range",
"error": "Bad Request"
}
| Status Code | Description |
|---|---|
| 400 | Bad request / validation error |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Resource not found |
| 500 | Internal server error |