Skip to main content

Events API

Track user events and behavior programmatically via REST API.

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

The purchase event​

Revenue reporting, attribution, CLV and predictive models all read one event. If you send purchases under a different name, they are not counted - anywhere.

Event nameOrder Completed
Required propertiestotal, currency
Recommendedtotal_base, base_currency, orderId
{
"eventName": "Order Completed",
"userId": "user_123",
"properties": {
"total": 149.90,
"currency": "EUR",
"total_base": 162.35,
"base_currency": "USD",
"orderId": "1024"
}
}

Why only one name​

Other platforms use other names - Placed Order, purchase (GA4). We deliberately do not accept them, because accepting several names means adding them together: a store running a GA4 tag alongside our connector would fire one sale under two names and see its revenue doubled. A revenue figure that is silently 2x is far harder to notice than one that is obviously 0.

So the rule is a single published contract. If your orders are not appearing, the cause is visible immediately at onboarding - and fixable - rather than quietly wrong for months.

About the amounts​

total and total_base are two different figures, not alternatives:

  • total - what the customer paid, in the currency they paid in (presentment).
  • total_base + base_currency - the same order converted to your reporting currency. Send these if you sell in more than one currency, so totals across currencies add up correctly.

Send total alone if you have a single currency. orderId is optional but recommended: it de-duplicates an order that arrives more than once (a retry, a page refresh at checkout).

If you are already sending another name​

Your historical events are kept, but they are not treated as orders. Switch new orders to Order Completed, and revenue reporting starts from that point. We do not rewrite past events, so nothing is silently re-interpreted underneath you.

Coupon codes on the event​

When the order comes from a connected store or the Orders API, Order Completed also carries:

PropertyTypeDescription
coupon_codesstring[]Every coupon code on the order, lower-cased (["summer10", "vip"]); [] when none
coupon_countnumberHow many codes the order used

A journey can start on Order Completed where coupon_codes contains summer10 - the match ignores letter case and tests each code.

Store and sales channel on the event​

Order Completed, Order Refunded and Order Cancelled carry where the order was sold and its store:

PropertyTypeDescription
sales_channelstringonline, pos, app, phone, marketplace or other - always present
store_idstringThe store the order belongs to (a connected store's id, or the storeId your API order sent) - only when it has one

An app may send them on its own Order Completed: sales_channel (also accepted as salesChannel; a value outside the list is stored as other), store_id and store_name. Without sales_channel, an order tracked from an iOS or Android app is app and one from a website is online. A journey can start on Order Completed where sales_channel is pos. See Store and sales channel.

Sending events​

Events are ingested through POST /users/track, which takes attributes, events and purchases in one call.

curl -X POST https://api-eu1.joryio.com/api/users/track \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"events": {
"externalId": "user_123",
"name": "Product Viewed",
"properties": { "productId": "SKU-456", "price": 49.99 }
}
}'

events takes a single object or an array of up to 500. See Track for the full field reference, identity rules and response shape.

Order Completed is not sent as an event

It is emitted automatically from a purchase. Send purchases in the purchases array instead - see The purchase event above for why sending it by hand either records revenue with no order behind it, or counts the sale twice.

Query Events​

Retrieve events with filtering and pagination. There is no fetch-by-event-ID endpoint - filter this query instead.

Endpoint​

GET /events/query

Query Parameters​

ParameterTypeDefaultDescription
userIdstring-Filter by user ID
eventNamestring-Filter by event name
startDatestring-Filter events after this date (ISO 8601)
endDatestring-Filter events before this date (ISO 8601)
limitnumber100Results per page (max 1000)
offsetnumber0Number of events to skip

Example Request​

# Get all "Order Completed" events in January 2024
curl -X GET "https://api-eu1.joryio.com/api/events/query?eventName=Order+Completed&startDate=2024-01-01T00:00:00Z&endDate=2024-02-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

A bare JSON array, newest first. Event fields use snake_case (they come from the analytics store):

[
{
"event_id": "9b2f6c1e-4a8d-4f0b-9c3d-2e1f5a6b7c8d",
"user_id": "665f1e2a9b3c4d5e6f7a8b9c",
"anonymous_id": "",
"event_name": "Order Completed",
"properties": {
"orderId": "order_456",
"total": 99.99
},
"timestamp": "2024-01-20 14:30:00",
"session_id": "",
"device_id": ""
},
{
"event_id": "1c3e5a7b-9d2f-4b6c-8e0a-3f5d7b9c1e2a",
"user_id": "665f1e2a9b3c4d5e6f7a8b9d",
"anonymous_id": "",
"event_name": "Order Completed",
"properties": {
"orderId": "order_789",
"total": 149.99
},
"timestamp": "2024-01-19 10:15:00",
"session_id": "",
"device_id": ""
}
]

Event Aggregation​

Get aggregated event statistics with grouping and metrics.

Endpoint​

POST /events/aggregate

Request Body​

FieldTypeRequiredDescription
eventNamestringYesName of the event to aggregate
startDatestringNoStart date (ISO 8601)
endDatestringNoEnd date (ISO 8601)
groupBystringNoTime grouping: hour, day, week, month (default: day)
metricsarrayNoMetrics to calculate: count, sum, avg, min, max (default: ['count'])
sumFieldstringConditionalName of the properties key to sum/aggregate (e.g., total). Required when metrics includes sum, min or max
avgFieldstringConditionalName of the properties key to average. Required when metrics includes avg
A metric with no field is rejected

An unknown metric name, or a metric whose field is missing (sum without sumField), returns 400. Both used to return 200 with the metric simply absent from the result, which is indistinguishable from a genuine zero.

| eventProperties | object | No | Filter by event properties |

Example Request​

curl -X POST https://api-eu1.joryio.com/api/events/aggregate \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"eventName": "Order Completed",
"startDate": "2024-01-01T00:00:00Z",
"endDate": "2024-02-01T00:00:00Z",
"groupBy": "day",
"metrics": ["count", "sum"],
"sumField": "total"
}'

Response​

{
"success": true,
"data": {
"eventName": "Order Completed",
"groupBy": "day",
"metrics": ["count", "sum"],
"results": [
{
"period": "2024-01-01T00:00:00Z",
"count": 45,
"sum_value": 4567.89
},
{
"period": "2024-01-02T00:00:00Z",
"count": 52,
"sum_value": 5123.45
},
{
"period": "2024-01-03T00:00:00Z",
"count": 38,
"sum_value": 3890.12
}
],
"total": 3
}
}

Supported Metrics​

  • count: Total number of events
  • sum: Sum of specified field values
  • avg: Average of specified field values
  • min: Minimum value of specified field
  • max: Maximum value of specified field

Example: Revenue Analysis​

# Get daily revenue from purchases
curl -X POST https://api-eu1.joryio.com/api/events/aggregate \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"eventName": "Order Completed",
"startDate": "2024-01-01T00:00:00Z",
"endDate": "2024-01-31T00:00:00Z",
"groupBy": "day",
"metrics": ["count", "sum", "avg"],
"sumField": "total"
}'

Example: Feature Usage by Hour​

# Track feature usage patterns by hour
curl -X POST https://api-eu1.joryio.com/api/events/aggregate \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"eventName": "Feature Used",
"startDate": "2024-01-20T00:00:00Z",
"endDate": "2024-01-21T00:00:00Z",
"groupBy": "hour",
"metrics": ["count"]
}'

Discovering what a workspace sends​

Before you build against a workspace's events, ask it what it actually receives. These four routes are how an integration finds out rather than guessing.

List Event Names​

GET /events/names

Every event name seen in this workspace, as a bare JSON array sorted A-Z. This is the real vocabulary, not the one anybody intended.

Response:

["Order Completed", "Page Viewed", "Product Viewed"]

Event Statistics​

GET /events/stats?startDate=2026-08-20T00:00:00Z&endDate=2026-08-27T00:00:00Z

Volume per event over a window - the fastest way to confirm an integration is still delivering, and to notice when one has quietly stopped. startDate and endDate are optional ISO 8601 timestamps (there is no days parameter); without them the whole history is counted.

Response - a JSON array, busiest events first, at most 1,000 rows. count is distinct events, unique_users distinct people:

[
{ "event_name": "Page Viewed", "count": 1204331, "unique_users": 88012 },
{ "event_name": "Order Completed", "count": 48210, "unique_users": 31877 }
]

Event Schemas​

GET /events/schema?limit=50&offset=0
GET /events/schema/Order%20Completed

The list route pages through every event the workspace has received (limit default 50, max 1000; offset default 0) with counts but without properties. The per-event route takes the event name in the path, URL-encoded - there is no ?event= query parameter - and returns the properties actually observed on it: the type each resolved to and where that type came from (typeSource is declared, canonical or inferred), how many events carry it (occurrences), sample values, and whether the values look like an identifier. There is no fillRate field - divide occurrences by the event's totalCount from the list route. A property declared and never sent is the usual cause of a segment that matches nobody.

List response:

{
"data": [
{ "name": "Order Completed", "totalCount": 48210, "uniqueUsers": 31877,
"lastSeen": "2026-08-27T09:12:00.000Z", "propertyCount": 6,
"description": "A purchase was completed", "descriptionSource": "platform", "isPlatform": true }
],
"pagination": { "total": 42, "limit": 50, "offset": 0, "hasMore": false }
}

Per-event response:

{
"eventName": "Order Completed",
"properties": [
{ "name": "total", "type": "number", "typeSource": "canonical", "typeDeclared": false,
"typeMixed": false, "typeCounts": { "string": 0, "number": 48210, "boolean": 0, "array": 0, "object": 0 },
"typeReason": "single", "occurrences": 48210, "exampleValues": [129.9, 42.5],
"cardinalityVerdict": "high_cardinality", "uniquenessRatio": 0.31, "looksLikeIdentifier": false,
"pii": false },
{ "name": "coupon", "type": "string", "typeSource": "inferred",
"typeMixed": false, "typeCounts": { "string": 3857, "number": 0, "boolean": 0, "array": 0, "object": 0 },
"typeReason": "single", "occurrences": 3857, "exampleValues": ["SPRING10"],
"cardinalityVerdict": "low_cardinality", "uniquenessRatio": 0.004, "looksLikeIdentifier": false,
"pii": false }
]
}

exampleValues is empty for a PII property when the caller may not view PII (pii says which). typeMixed with its typeCounts flags a property whose values arrive as more than one type.

Declare an Event Schema​

POST /events/schema

Records an event's description. Events stay schema-less: the description lands in the workspace's data dictionary, which the property pickers and the AI assistant read - nothing is validated or rejected, and no properties are declared here.

Request body:

{ "name": "Order Completed", "description": "A purchase was completed" }

Response:

{ "success": true, "message": "Event schema defined successfully",
"data": { "name": "Order Completed", "description": "A purchase was completed" } }

Property descriptions are written with PUT /events/schema/:eventName ({ "description"?, "properties"?: [{ "name", "description" }] }). POST /events/schema/:eventName/properties ({ "name", "type", "description"? }) declares a property's type - string, number, boolean or date - in the same data dictionary; the per-event route then reports it with typeSource: "declared" and the segment filters compare it that way. An unknown type, and a standard property whose type the platform fixes (total, price...), are refused with 400.

Platform Event Catalogue​

GET /events/platform-catalog

The canonical events Joryio understands natively, with the properties each one expects. Match these names and the built-in reports, triggers and e-commerce features work with no mapping; use your own names and they will not.

Common Events​

E-commerce Events​

// Product Viewed
POST /users/track
{
"userId": "user_123",
"eventName": "Product Viewed",
"properties": {
"productId": "prod_456",
"productName": "Premium Plan",
"category": "Subscription",
"price": 99.99,
"currency": "USD"
}
}

// Added To Cart
POST /users/track
{
"userId": "user_123",
"eventName": "Added To Cart",
"properties": {
"productId": "prod_456",
"quantity": 1,
"price": 99.99
}
}

// Order Completed
POST /users/track
{
"userId": "user_123",
"eventName": "Order Completed",
"properties": {
"orderId": "order_789",
"total": 249.99,
"currency": "USD",
"itemCount": 3,
"discount": 25.00,
"paymentMethod": "credit_card"
}
}

User Lifecycle Events​

// Signup Completed
POST /users/track
{
"userId": "user_123",
"eventName": "Signup Completed",
"properties": {
"method": "email",
"source": "homepage_cta"
}
}

// Onboarding Completed
POST /users/track
{
"userId": "user_123",
"eventName": "Onboarding Completed",
"properties": {
"stepsCompleted": 5,
"timeSpent": "8m 30s"
}
}

// Trial Started
POST /users/track
{
"userId": "user_123",
"eventName": "Trial Started",
"properties": {
"plan": "premium",
"trialDays": 14
}
}

Engagement Events​

// Feature Used
POST /users/track
{
"userId": "user_123",
"eventName": "Feature Used",
"properties": {
"featureName": "export",
"exportFormat": "csv",
"recordCount": 1500
}
}

// Page Viewed
POST /users/track
{
"userId": "user_123",
"eventName": "Page Viewed",
"properties": {
"page": "/pricing",
"category": "Marketing",
"referrer": "google"
}
}

Event Properties​

Best Practices​

Use descriptive property names:

Good:

{
"properties": {
"productId": "prod_123",
"productName": "Premium Plan",
"price": 99.99,
"currency": "USD"
}
}

Bad:

{
"properties": {
"pid": "prod_123",
"n": "Premium Plan",
"p": 99.99
}
}

Supported Data Types​

{
"properties": {
"string": "value",
"number": 99.99,
"integer": 5,
"boolean": true,
"date": "2024-01-15T10:30:00Z",
"array": ["tag1", "tag2"],
"object": {
"nested": "value",
"deep": {
"property": "value"
}
}
}
}

Reserved Properties​

Properties starting with $ are reserved for system use:

  • $app_id - App identifier
  • $app_name - App name
  • $platform - Platform (web, ios, android)
  • $session_id - Session identifier
  • $anonymous_id - Anonymous user ID

Don't use these names for custom properties.

Reserved event names​

Event names starting with canvas. are reserved for Joryio journey events - canvas.step.entered, canvas.step.left, canvas.experiment.entered and any other canvas. name are recorded only by the journey engine (platform events). An event you send with a name starting with canvas. is refused (rename yours) - listed as failed, with the reason, in the POST /users/track response, a permanent rejection (rejectedIndices) in an SDK batch, and a skipped row with the reason in a CSV import or warehouse sync. The $emitted_by property is also removed from every event you send.

Names starting with message. or in_app. are reserved the same way, for Joryio messaging events - sends, deliveries, opens and clicks (message.sent, message.opened, message.clicked, ...) and in-app message impressions, clicks and dismissals (in_app.displayed, in_app.clicked, ...) are recorded only by Joryio, so a custom event is never counted as one of them in reports, segment filters or journey triggers. An event you send with such a name is refused the same way, including rows in a historical CSV import or warehouse sync - rename yours (for example Newsletter Opened). The check ignores case. Push receipts and clicks reported by the SDKs, and in-app events reported through the SDKs' in-app endpoints, are Joryio's own events and keep working.


Event Limits​

Size Limits​

LimitValue
Max event name length255 characters
Max identifier length (userId, anonymousId, sessionId, deviceId, clientEventId)255 characters
Max top-level property keys per event200
Max properties size (JSON)50 KB
Max property nesting depth5 levels
Max events per array-body request500

Rate Limits​

The Events API has no fixed per-endpoint rate limits today - see API Overview: Rate Limiting.


Error Responses​

All errors use the standard error body - see API Overview: Error Response.

400 Bad Request - Validation Failure​

{
"statusCode": 400,
"message": "Bad Request Exception",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/api/users/track",
"errors": [
"eventName should not be empty"
]
}

400 Bad Request - Oversized Properties​

{
"statusCode": 400,
"message": "Event properties exceed maximum size of 50KB (received 63KB)",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/api/users/track"
}

Best Practices​

1. Batch Events When Possible​

Good - Batch multiple events with an array body:

await fetch('/api/users/track', {
method: 'POST',
body: JSON.stringify([event1, event2, event3])
});

Bad - Individual requests:

await fetch('/api/users/track', { method: 'POST', body: JSON.stringify(event1) });
await fetch('/api/users/track', { method: 'POST', body: JSON.stringify(event2) });
await fetch('/api/users/track', { method: 'POST', body: JSON.stringify(event3) });

2. Use Consistent Event Naming​

Follow "Object + Past Tense Verb" pattern:

Good: Product Viewed, Order Completed, Trial Started Bad: view_product, clicked, user_action_123

3. Include Timestamps​

For historical events, always include accurate timestamps:

{
"userId": "user_123",
"eventName": "Order Completed",
"timestamp": "2024-01-15T10:30:00.000Z", // Actual event time
"properties": { ... }
}

4. Keep Properties Lean​

Only include relevant properties:

Good:

{
"eventName": "Order Completed",
"properties": {
"orderId": "order_123",
"total": 99.99,
"currency": "USD"
}
}

Bad:

{
"eventName": "Order Completed",
"properties": {
"orderId": "order_123",
"total": 99.99,
"currency": "USD",
"userAgent": "Mozilla/5.0...", // Too much detail
"sessionData": { /* large object */ },
"cookies": [ /* array of cookies */ ]
}
}

5. Handle Errors Gracefully​

Implement retry logic with exponential backoff:

async function trackWithRetry(event, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch('/api/users/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(event)
});

if (response.ok) return await response.json();

if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || Math.pow(2, i);
await sleep(retryAfter * 1000);
continue;
}

throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === maxRetries - 1) throw error;
await sleep(Math.pow(2, i) * 1000); // 1s, 2s, 4s
}
}
}

Debugging​

Enable Debug Mode (SDK)​

When using the Web SDK:

import JoryioSDK from '@joryio/web-sdk';

const joryio = new JoryioSDK({
sdkKey: 'jry_sdk_web_...',
enableDebug: true // Log all events to console
});

Verify Events in Dashboard​

  1. Go to Users → Find user
  2. Click Activity tab
  3. See all tracked events

Common Issues​

Events not appearing:

  • Verify API key is correct
  • Check user is identified
  • Ensure event name and properties are valid
  • Check rate limits

Properties not showing:

  • Verify property names are correct
  • Check data types are supported
  • Avoid reserved property names ($prefix)

SDKs​

For easier integration, use official SDKs:


Next Steps​