Skip to main content

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

FieldTypeRequiredDescription
externalIdstringOne of externalId / emailYour unique user identifier (max 255 chars) - the create-or-update key
emailstringOne of externalId / emailUser's email address
userIdstringNoJoryio's internal user id (24-hex, from API responses) - update-only, never creates (see note below). Mutually exclusive with externalId
phonestringNoUser's phone number (max 20 chars)
attributesobjectNoCustom user attributes (bounded: max 200 keys, 50KB, nesting depth 5)
subscriptionsarrayNoSubscription-list memberships to apply in the same call (max 100). See Inline subscriptions
eventsarrayNoEvents to ingest in the same call (max 25). See Inline events
userId means Joryio's internal id and is update-only

userId 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:

FieldTypeRequiredDescription
listIdstringYesSubscription list id
channelstringNoemail, sms, whatsapp, push, or viber. Defaults to email
statusstringNosubscribed (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/:listId call.
  • 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:

FieldTypeRequiredDescription
namestringYesEvent name (max 255 chars). Prefer canonical names such as Order Completed
propertiesobjectNoEvent properties (same bounds as the track endpoint: max keys, 50KB, limited nesting)
timestampstring or numberNoISO 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 null stores null as its value - it does not delete the key
  • Email and phone are automatically indexed for segmentation
  • Inline subscriptions / events are 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 (the externalId you created the user with). This is the recommended route for integrations.
  • GET /users/:userId - look up by Joryio's internal ID (the 24-hex id returned in responses).

Endpoint

GET /users/by-user-id/:userId

Path Parameters

ParameterTypeDescription
userIdstringYour 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

ParameterTypeDefaultDescription
limitnumber50Results per page (max 200)
offsetnumber0Number 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

FieldTypeRequiredDescription
emailstringNoNew email address
phonestringNoNew phone number
attributesobjectNoAttributes 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

ParameterDescription
queryOptional. Match by email/externalId (prefix), phone, or original user id.
limitMax results (default 50, max 200).
offsetNumber 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 failed itemizes 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

ParameterTypeDefaultDescription
limitnumber50Number of events to return (max 1000)
offsetnumber0Pagination offset
startDatestring-Filter events after this date (ISO 8601)
endDatestring-Filter events before this date (ISO 8601)
eventNamestring-Filter by event name
groupBySessionbooleanfalseAlso 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:

AttributeTypeDescription
firstNamestringUser's first name (used in search and display)
lastNamestringUser's last name (used in search and display)
languagestringPreferred language (set automatically by the SDKs when available)
timezonestringUser's timezone, IANA format (drives quiet hours and local-time journey delivery)
countrystringCountry 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.


Next Steps