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 name | Order Completed |
| Required properties | total, currency |
| Recommended | total_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:
| Property | Type | Description |
|---|---|---|
coupon_codes | string[] | Every coupon code on the order, lower-cased (["summer10", "vip"]); [] when none |
coupon_count | number | How 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:
| Property | Type | Description |
|---|---|---|
sales_channel | string | online, pos, app, phone, marketplace or other - always present |
store_id | string | The 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 eventIt 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
| Parameter | Type | Default | Description |
|---|---|---|---|
userId | string | - | Filter by user ID |
eventName | string | - | Filter by event name |
startDate | string | - | Filter events after this date (ISO 8601) |
endDate | string | - | Filter events before this date (ISO 8601) |
limit | number | 100 | Results per page (max 1000) |
offset | number | 0 | Number 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
| Field | Type | Required | Description |
|---|---|---|---|
eventName | string | Yes | Name of the event to aggregate |
startDate | string | No | Start date (ISO 8601) |
endDate | string | No | End date (ISO 8601) |
groupBy | string | No | Time grouping: hour, day, week, month (default: day) |
metrics | array | No | Metrics to calculate: count, sum, avg, min, max (default: ['count']) |
sumField | string | Conditional | Name of the properties key to sum/aggregate (e.g., total). Required when metrics includes sum, min or max |
avgField | string | Conditional | Name of the properties key to average. Required when metrics includes avg |
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
| Limit | Value |
|---|---|
| Max event name length | 255 characters |
| Max identifier length (userId, anonymousId, sessionId, deviceId, clientEventId) | 255 characters |
| Max top-level property keys per event | 200 |
| Max properties size (JSON) | 50 KB |
| Max property nesting depth | 5 levels |
| Max events per array-body request | 500 |
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
- Go to Users → Find user
- Click Activity tab
- 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:
- Web SDK: Integration Guide
- iOS SDK: Swift Package / CocoaPods
- Android SDK: Gradle
- React Native SDK: npm install @joryio/react-native-sdk