Users API
Create, update, and manage user profiles 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
Create or Update User
Create a new user or update an existing user's attributes.
This endpoint accepts two body forms: a single user object (documented here - returns the full user payload with itemized onboarding summaries) or a bare JSON array of user objects for bulk operations (returns an aggregate summary) - see Bulk Operations (array body).
Endpoint
POST /users
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | One of externalId / email | Your unique user identifier (max 255 chars) - the create-or-update key |
email | string | One of externalId / email | User's email address |
userId | string | No | Joryio's internal user id (24-hex, from API responses) - update-only, never creates (see note below). Mutually exclusive with externalId |
phone | string | No | User's phone number (max 20 chars) |
attributes | object | No | Custom user attributes (bounded: max 200 keys, 50KB, nesting depth 5) |
subscriptions | array | No | Subscription-list memberships to apply in the same call (max 100). See Inline subscriptions |
events | array | No | Events to ingest in the same call (max 25). See Inline events |
userId means Joryio's internal id and is update-onlyuserId and externalId are not aliases. userId is the internal 24-hex id that only Joryio mints (returned as id / userId in responses): sending it updates that exact user, or returns 404 if it doesn't exist - it never creates a user and never matches an externalId. A non-24-hex userId is rejected with 400, and sending both userId and externalId in one body is rejected with 400. To create or update a user by your own identifier - whatever its shape - use externalId.
Inline subscriptions
One-call onboarding: instead of a separate POST /subscriptions/contacts/:userId/lists/:listId call per list, pass memberships directly. Each item:
| Field | Type | Required | Description |
|---|---|---|---|
listId | string | Yes | Subscription list id |
channel | string | No | email, sms, whatsapp, push, or viber. Defaults to email |
status | string | No | subscribed (default) or unsubscribed |
Consent rows are written through the same audited path as the standalone subscribe endpoint, with source api. Two guarantees:
- An existing explicit opt-out for that list/channel is never silently resurrected - the item is skipped and reported. Re-opting-in an unsubscribed contact requires an explicit
POST /subscriptions/contacts/:userId/lists/:listIdcall. - Per-item failures (for example an unknown
listId) are reported in the response summary and never roll back the profile upsert.
Inline events
Each item is ingested through the standard events pipeline - journey triggers, segments, and analytics fire exactly as for POST /events/track:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Event name (max 255 chars). Prefer canonical names such as Order Completed |
properties | object | No | Event properties (same bounds as the track endpoint: max keys, 50KB, limited nesting) |
timestamp | string or number | No | ISO 8601 string or epoch milliseconds. Defaults to now |
Example Request
curl -X POST https://api-eu1.joryio.com/users \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_123",
"email": "john.doe@example.com",
"phone": "+1234567890",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"plan": "premium",
"signupDate": "2024-01-15T10:30:00Z",
"customField": "value"
},
"subscriptions": [
{ "listId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d", "channel": "email" },
{ "listId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "channel": "sms" }
],
"events": [
{
"name": "Order Completed",
"properties": { "orderId": "ord_789", "total": 129.90, "currency": "USD" },
"timestamp": "2024-01-15T10:29:45Z"
},
{ "name": "Product Viewed", "properties": { "productId": "sku_42" } }
]
}'
Response
The user object is returned directly (no wrapper envelope). id / userId are Joryio's internal identifier; the externalId you sent is echoed back:
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"email": "john.doe@example.com",
"phone": "+1234567890",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"plan": "premium",
"signupDate": "2024-01-15T10:30:00Z"
},
"segments": [],
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"subscriptions": { "applied": 2, "skipped": [] },
"events": { "accepted": 2, "rejected": [] }
}
The subscriptions and events summary fields appear only when the corresponding inputs were provided. Skipped subscriptions are objects of { listId, channel, reason }; rejected events are { name, reason }.
Notes
- If user exists, attributes are merged (existing attributes not in request are preserved)
- Setting an attribute to
nullstoresnullas its value - it does not delete the key - Email and phone are automatically indexed for segmentation
- Inline
subscriptions/eventsare applied after the profile upsert; per-item failures are reported in the summary fields and never fail the call or roll back the user
Get User by ID
Retrieve a user's profile and attributes. Two lookup routes exist:
GET /users/by-user-id/:userId- look up by your identifier (theexternalIdyou created the user with). This is the recommended route for integrations.GET /users/:userId- look up by Joryio's internal ID (the 24-hexidreturned in responses).
Endpoint
GET /users/by-user-id/:userId
Path Parameters
| Parameter | Type | Description |
|---|---|---|
userId | string | Your external identifier (externalId) |
Example Request
curl -X GET https://api-eu1.joryio.com/users/by-user-id/user_123 \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"email": "john.doe@example.com",
"phone": "+1234567890",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"plan": "premium",
"lifetimeValue": 1250.50
},
"segments": [],
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-20T14:20:00.000Z"
}
List Users
List all users with pagination. Results are ordered by most recently updated first - there is no attribute-filter or sort query syntax on this endpoint (use Segments to slice by attributes, or GET /users/search?query=... for name/email/phone/ID lookup).
Endpoint
GET /users
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (max 200) |
offset | number | 0 | Number of users to skip |
Example Request
curl -X GET "https://api-eu1.joryio.com/users?limit=50&offset=0" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"data": [
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"email": "user1@example.com",
"attributes": {
"plan": "premium"
}
},
{
"id": "665f1e2a9b3c4d5e6f7a8b9d",
"userId": "665f1e2a9b3c4d5e6f7a8b9d",
"externalId": "user_456",
"email": "user2@example.com",
"attributes": {
"plan": "premium"
}
}
],
"pagination": {
"total": 156,
"limit": 50,
"offset": 0,
"hasMore": true
}
}
Update User
Update a user's email, phone, or attributes without replacing the entire profile. Attributes are merged per key. Uses PUT (there is no PATCH route):
PUT /users/by-user-id/:userId- update by your identifier.PUT /users/:userId- update by Joryio's internal ID.
Endpoint
PUT /users/by-user-id/:userId
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | No | New email address |
phone | string | No | New phone number |
attributes | object | No | Attributes to merge (per-key update) |
Example Request
curl -X PUT https://api-eu1.joryio.com/users/by-user-id/user_123 \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"attributes": {
"plan": "enterprise",
"mrr": 499
}
}'
Response
The updated user object is returned directly:
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"attributes": {
"firstName": "John",
"plan": "enterprise",
"mrr": 499,
"signupDate": "2024-01-15T10:30:00Z"
},
"updatedAt": "2024-01-20T15:30:00.000Z"
}
Delete User
Delete a user. The user is moved to a recycle bin (archived with an audit) and can be restored - it is not permanently erased.
Endpoint
DELETE /users/:userId
The path parameter is Joryio's internal user ID (look it up first via GET /users/by-user-id/:userId if you only have your own identifier). Requires the users:delete scope. Pass an optional ?reason= to record why, in the delete audit.
Example Request
curl -X DELETE "https://api-eu1.joryio.com/users/665f1e2a9b3c4d5e6f7a8b9c?reason=duplicate" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
204 No Content - the response body is empty.
Notes
- The user is moved to a recycle bin and can be restored - see List Deleted Users. Every delete is recorded with a who / when / how / why audit. (Restore is currently performed via an internal endpoint - contact support to restore a deleted contact.)
- The user is removed from segments and active campaigns stop targeting it immediately.
- Event history is retained, so a restored user keeps its full history.
- To permanently erase a user and all their data (GDPR right to erasure), use the data-erasure (DSR) flow instead - that is irreversible and is a different operation.
List Deleted Users
List the recycle bin - users that were deleted, with the delete audit (who, when, how, why).
Endpoint
GET /users/deleted
Requires the users:read scope.
Query Parameters
| Parameter | Description |
|---|---|
query | Optional. Match by email/externalId (prefix), phone, or original user id. |
limit | Max results (default 50, max 200). |
offset | Number to skip (default 0). |
Example Request
curl "https://api-eu1.joryio.com/users/deleted?limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"total": 1,
"items": [
{
"originalId": "665f1e2a9b3c4d5e6f7a8b9c",
"email": "sarah@example.com",
"phone": "+15551234567",
"externalId": "usr_1001",
"deletedAt": "2026-08-09T03:00:30.000Z",
"deletedBy": "you@example.com",
"deletedVia": "api",
"deletedReason": "duplicate",
"restoredAt": null
}
]
}
Bulk Operations (array body)
There is no separate bulk endpoint: POST /users accepts either a single user object or a bare JSON array of user objects (no wrapper object). The array form creates or updates up to 1000 users in one request.
Endpoint
POST /users
Content-Type: application/json
[ { ...user }, { ...user } ]
Request Body
A JSON array (max 1000 elements). Each element follows the same format as the single-object form, including the optional inline subscriptions and events fields. Limits apply per element: max 100 subscriptions and max 25 events each (so a 1000-element request can carry at most 25 events per element - the cap is not amplified by batching).
Every element receives full validation. An invalid element is reported in failed by its array index - it is never silently accepted - and the remaining valid elements are still processed.
Example Request
curl -X POST https://api-eu1.joryio.com/users \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '[
{
"externalId": "user_001",
"email": "user1@example.com",
"attributes": { "firstName": "John", "plan": "free" },
"subscriptions": [
{ "listId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d", "channel": "email" }
],
"events": [
{ "name": "Order Completed", "properties": { "orderId": "ord_789", "total": 129.90 } }
]
},
{
"externalId": "user_002",
"email": "user2@example.com",
"attributes": { "firstName": "Jane", "plan": "premium" }
}
]'
Response
Unlike the object form (which returns the full user payload with itemized onboarding summaries), the array form returns an aggregate summary:
{
"processed": 2,
"created": 1,
"updated": 1,
"failed": [],
"subscriptions": { "applied": 1, "skipped": 0 },
"events": { "accepted": 1, "rejected": 0 }
}
Response with Failures
{
"processed": 2,
"created": 1,
"updated": 1,
"failed": [
{
"index": 2,
"userId": "user_003",
"reason": "property hacker should not exist"
}
],
"subscriptions": { "applied": 0, "skipped": 0 },
"events": { "accepted": 0, "rejected": 0 }
}
Limits
- Max 1000 users per request
- Each element gets the same validation as the single-object form (including nested
subscriptions/events) - Per-element caps: 100 subscriptions, 25 events
- Request-level totals: at most 500 inline events and 1000 inline subscriptions summed across the whole array - beyond that, send events to
POST /events/track(array body) and list memberships to the subscription-list bulk-members endpoint - Processing is done in parallel batches of 10 for optimal performance
- Partial failures are allowed - valid elements are processed even if some fail, and
faileditemizes every failure by array index - Onboarding results (
subscriptions/events) aggregate as counts; use the single-object form when you need itemized skip/reject reasons
The array form is for programmatic batches from your backend. For files and full migrations, don't hand-roll batching loops - use Bulk Import in the dashboard (CSV/JSON with column mapping, dedup, consent rules, and an error report) or Warehouse sync for recurring loads.
User Events
Get User's Events
Retrieve all events for a specific user.
Endpoint
GET /users/:userId/events
The path parameter is Joryio's internal user ID.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Number of events to return (max 1000) |
offset | number | 0 | Pagination offset |
startDate | string | - | Filter events after this date (ISO 8601) |
endDate | string | - | Filter events before this date (ISO 8601) |
eventName | string | - | Filter by event name |
groupBySession | boolean | false | Also return events grouped by session |
Example Request
curl -X GET "https://api-eu1.joryio.com/users/665f1e2a9b3c4d5e6f7a8b9c/events?limit=20" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
Event fields use snake_case (they come from the analytics store):
{
"data": [
{
"event_id": "9b2f6c1e-4a8d-4f0b-9c3d-2e1f5a6b7c8d",
"event_name": "Order Completed",
"properties": {
"orderId": "order_456",
"total": 99.99
},
"timestamp": "2024-01-20 14:30:00"
},
{
"event_id": "1c3e5a7b-9d2f-4b6c-8e0a-3f5d7b9c1e2a",
"event_name": "Page Viewed",
"properties": {
"page": "/pricing"
},
"timestamp": "2024-01-20 14:25:00"
}
],
"total": 156,
"limit": 20,
"offset": 0
}
Common Attributes
Special Fields and Attributes
email and phone are top-level profile fields (not attributes) - send them at the top level of the request body.
These attributes have special meaning in Joryio:
| Attribute | Type | Description |
|---|---|---|
firstName | string | User's first name (used in search and display) |
lastName | string | User's last name (used in search and display) |
language | string | Preferred language (set automatically by the SDKs when available) |
timezone | string | User's timezone, IANA format (drives quiet hours and local-time journey delivery) |
country | string | Country code (indexed for segmentation) |
Custom Attributes
You can add unlimited custom attributes:
{
"attributes": {
"plan": "premium",
"mrr": 99,
"signupSource": "google_ads",
"lifetimeValue": 1250.50,
"tags": ["vip", "early-adopter"],
"preferences": {
"emailNotifications": true,
"smsNotifications": false
}
}
}
Supported Data Types
- String:
"premium" - Number:
99.99 - Boolean:
true/false - Date:
"2024-01-15T10:30:00Z"(ISO 8601) - Array:
["tag1", "tag2"] - Object:
{ "nested": "value" }
Error Responses
All errors use the standard error body - see API Overview: Error Response.
400 Bad Request
{
"statusCode": 400,
"message": "Cannot create or update a user without a valid identifier (externalId or email to create; userId only updates an existing user)",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/users"
}
401 Unauthorized
{
"statusCode": 401,
"message": "Invalid or expired API key",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/users"
}
404 Not Found
{
"statusCode": 404,
"message": "User with userId 'user_123' not found",
"timestamp": "2026-01-15T10:30:00.000Z",
"path": "/users/by-user-id/user_123"
}
Best Practices
1. Retries Are Safe - POST /users Is an Upsert
POST /users is keyed on your userId - retrying the same request updates the same profile instead of creating a duplicate. There is no Idempotency-Key header; just retry the request as-is on network failures:
curl -X POST https://api-eu1.joryio.com/users \
-H "Authorization: Bearer jry_live_your_api_key" \
-d '{...}'
2. Attribute Naming
Use consistent, descriptive attribute names:
Good:
{
"signupDate": "2024-01-15",
"lifetimeValue": 1250.50,
"plan": "premium"
}
Bad:
{
"sd": "2024-01-15",
"ltv": 1250.50,
"p": "premium"
}
3. Phone Number Format
Always use E.164 format for phone numbers:
Good: "+1234567890"
Bad: "(123) 456-7890", "123-456-7890"
Rate Limits
The Users API has no fixed per-endpoint rate limits today - see API Overview: Rate Limiting for the platform-wide behavior and how to handle 429 responses.