Skip to main content

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:

ParameterTypeDescription
startDatestringStart date (YYYY-MM-DD)
endDatestringEnd date (YYYY-MM-DD)
searchstringFilter by event name
limitnumberMax results (default: 100)
channelsstringOptional. 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 with 400.
  • GET takes a comma-separated list (channels=email,sms); the POST routes 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
}
FieldRequiredDescription
segmentIdsyes1 to 10 segment ids.
startDate, endDateyesThe window entry events are read from.
entryEventnoThe action that puts a user into a cohort.
returnEventnoThe action that counts as coming back.
timeUnitnoday, week or month.
periodsnoHow 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
}
]
FieldDescription
resolvedUsersThe segment's true size - every member it resolved to.
analyzedUsersHow many members retention was computed from. Equal to resolvedUsers unless the segment was capped.
truncatedtrue 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.
errorPresent 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:

  • randomised appears only on a 0 bucket 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.
  • anchored is true when such a zero exists. When it is false there is no 0 bucket 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 CodeDescription
400Bad request / validation error
401Unauthorized
403Forbidden
404Resource not found
500Internal server error