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/api - see API Overview.
Authentication
All requests require API key authentication:
Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json
Track
The one ingest door: who someone is, what they did, and what they bought - in a single call.
Every field takes a single object or an array, so one request can cover one person or a thousand.
Endpoint
POST /users/track
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
attributes | object or array | No | Profile fields. These persist on the person |
events | object or array | No | Things that happened. Appended to the timeline |
purchases | object or array | No | Orders, in the same shape as POST /orders |
At least one of the three is required. Caps per request: 1000 attributes, 500 events, 500 purchases. A request over a cap is rejected rather than truncated - silently processing the first 500 of 5000 would lose the rest with nothing to show for it.
Within one request, attributes are written before events and purchases, so an event may reference a person created in the same call. "Create the user and record their signup event" works as a single request.
Identity
The same three fields identify a person in all three arrays:
| Field | Description |
|---|---|
externalId | Your identifier, and the only create-or-update key |
userId | Joryio's internal 24-hex id. Update-only - never creates, never matches an externalId |
anonymousId | A pre-identification session. Promote it later with POST /users/identify |
userId is ours, externalId is yoursThese are not aliases. Sending a userId that is not 24-hex is rejected with 400. To create or update by your own identifier - whatever its shape - use externalId.
Matching by email
An attributes element with an email but no externalId and no userId is matched by email (lowercased, trimmed), within the workspace. An email address is not unique - a couple or a family can share one, and one person can exist under two IDs - so:
- No contact has the email: a new contact is created.
- Exactly one has it: that contact is updated.
- Several have it: exactly one is updated - the most recently active, the contact whose profile changed most recently (on a tie, the most recently created). The same request picks the same contact every time. The others are left as they are: Joryio never merges contacts and never updates all of them.
- A list unsubscribe in the element still applies to every contact with that email. A list subscribe applies to the updated contact only.
An element that matched several contacts is listed in attributes.matchedSeveral - a notice, not an error: the write succeeded.
{
"attributes": {
"processed": 1,
"failed": [],
"matchedSeveral": [
{ "index": 0, "userId": "6650f1c2a9e4b3d2c1f0e9a8", "count": 2 }
]
}
}
userId is the contact that was updated, count how many contacts share the address. matchedSeveral is present only when it happened. To update one exact contact, send externalId (matched by externalId only, even when the email is shared) or the contact's userId. File imports and warehouse syncs match the same way.
Attributes
| Field | Type | Description |
|---|---|---|
email | string | |
phone | string | |
attributes | object | Custom fields (max 200 keys, 50KB, nesting depth 5) |
subscriptions | array | List subscriptions - see List subscriptions |
Events
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Event name (max 255 chars) |
properties | object | No | Properties of this occurrence. They do not update the profile - send those in attributes |
timestamp | string | No | ISO 8601. When it actually happened. Defaults to now |
time | string | No | Deprecated alias for timestamp |
sessionId | string | No | Client session this event belongs to |
deviceId | string | No | Device this event came from |
clientEventId | string | No | Your id for this event, so a retry is deduplicated |
Order Completed as an eventThat event is emitted automatically when you send a purchase, together with the currency normalization revenue reporting needs. Sending it here is rejected with a message pointing at purchases, because the two ways it can go wrong are both silent: on its own it records revenue with no order behind it, so the Orders page, RFM and lifetime value never see the sale; alongside a real purchase it counts the sale twice.
Purchases
The same shape POST /orders accepts - orderId, total, currency, items[] and the rest - and it goes through the same pipeline, so the Orders page, RFM and revenue attribution all see it. Re-sending an orderId updates that order rather than creating a second one.
List subscriptions
An attributes element can also set the person's list subscriptions, so one request creates or updates the person, records their events and subscribes them to a list.
| Field | Type | Required | Description |
|---|---|---|---|
subscriptions[].listId | string | Yes | The subscription list id (Subscriptions API) |
subscriptions[].channel | string | No | email (default), sms, whatsapp, push or viber |
subscriptions[].status | string | No | subscribed (default) or unsubscribed |
Up to 100 per element. They are applied after the person is written, through the same consent path as POST /subscriptions/contacts/:userId/lists/:listId, and recorded with source api in the consent history.
- An existing opt-out is never resurrected. If the person already unsubscribed from that list on that channel, a
subscribedrequest is skipped and reported. Re-subscribing them is a deliberate call to the subscribe endpoint.status: "unsubscribed"is always applied. - The list must belong to this workspace. An unknown or archived list id, or one from another workspace, is skipped and reported, never written.
- No confirmation email. The API records the subscription directly, even on a list that requires double opt-in - that confirmation step belongs to sign-up forms. Send through the API only consent you have already collected.
- Reported by element index. When any element sends
subscriptions, theattributesresult addssubscriptions: { applied, skipped[] }, and each skip names the elementindex, thelistId, thechanneland thereason. A skipped subscription does not fail the element - the person is still created or updated.
{
"attributes": {
"processed": 1,
"failed": [],
"subscriptions": {
"applied": 0,
"skipped": [
{
"index": 0,
"listId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"channel": "email",
"reason": "Existing opt-out is not resurrected by user creation; use POST /subscriptions/contacts/:userId/lists/:listId for an explicit re-opt-in"
}
]
}
}
}
Example Request
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 '{
"attributes": {
"externalId": "user_123",
"email": "[email protected]",
"phone": "+1234567890",
"attributes": { "firstName": "John", "plan": "premium" }
},
"events": {
"externalId": "user_123",
"name": "Product Viewed",
"properties": { "productId": "SKU-456", "price": 49.99 }
},
"purchases": {
"orderId": "ord_789",
"externalId": "user_123",
"total": 129.90,
"currency": "USD",
"items": [
{ "productId": "SKU-456", "name": "Blue T-Shirt", "quantity": 1, "price": 129.90, "total": 129.90 }
]
}
}'
This is not the same as sending anonymousId on /users/track. Tracking upserts a person; identify links the anonymous record to a known person. On a first sign-in - no profile has that externalId yet - the anonymous profile itself becomes the known one, keeping its id and history. When the person already has a profile, the anonymous one is merged into it and its history reassigned. Recording anonymousId on a track call does not merge anything.
Response
Each array reports independently. One bad element never fails the batch - failures are itemized by request index, and everything valid still processes.
{
"attributes": { "processed": 1, "failed": [] },
"events": { "processed": 1, "failed": [] },
"purchases": { "processed": 1, "failed": [] }
}
A partial failure looks like this:
{
"attributes": { "processed": 2, "failed": [] },
"events": {
"processed": 1,
"failed": [
{ "index": 1, "reason": "name should not be empty" }
]
},
"purchases": { "processed": 0, "failed": [] }
}
A misspelled key - emial, evenName - is reported by index rather than silently dropped. Accepting it with a 200 would leave an integration looking healthy while the field never updates.
Warnings: accepted, but not reachable
A malformed email address is stored, not rejected - this endpoint is reached by fire-and-forget integrations, where a 400 nobody reads means losing the record entirely. The address is flagged internally and excluded from every email send, so it cannot cause a hard bounce.
Because that is invisible in the stored value, the response says so:
{
"attributes": {
"processed": 2,
"failed": [],
"warnings": [
{
"index": 1,
"field": "email",
"reason": "Stored, but flagged undeliverable and excluded from email sends. Correct the address to make this contact reachable by email."
}
]
}
}
warnings is present only when there is something to report. A contact with a warning is created and updated normally, and is reachable on every other channel - it is the email address that will never receive anything.
Phone numbers
Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled
Send phone in international format (+972501234567). A number written without a country code (0501234567) is read in the workspace's default country for phone numbers - with Israel it is stored as +972501234567. Joryio never guesses a country: with no default country set, such a number is not converted.
A phone that is not a valid number is kept, not dropped: it is stored in a separate contact field, phoneUnverified, and the contact's existing phone is not changed. An unverified number is never used for messages, suppression, matching contacts or segment phone filters. Sending a valid phone later replaces it. Digits that include the country code without the + (972501234567) are accepted when valid - except a 10-digit string, which could be a local or a US number and is kept unverified.
With a default country set, digits that are a valid number both ways - as a national number of that country and as another country's number with its code (13123456789 with China is a Chinese mobile and also +1 312...) - are ambiguous: Joryio never picks one, and keeps them unverified. Send such numbers with +.
The response reports it in the same warnings array:
{
"attributes": {
"processed": 1,
"failed": [],
"warnings": [
{
"index": 0,
"field": "phone",
"reason": "Phone saved as unverified and not used for messages - the number is not valid as written. Add the country code (for example +972501234567), or set a default country for phone numbers in workspace settings."
}
]
}
}
Contacts returned by the API carry phoneUnverified when they have one. File imports and warehouse syncs follow the same rules.
On the contact page, an unverified number can be corrected (saved as the phone - if the contact already has a phone, it is replaced, and the page says so first) or discarded. Through the API, PUT /users/{userId} with {"clearPhoneUnverified": true} removes the unverified number and leaves phone as it is.
Identify
Attach behaviour recorded against an anonymous session to a known person.
Endpoint
POST /users/identify
| Field | Type | Required | Description |
|---|---|---|---|
anonymousId | string | Yes | The session the behaviour was recorded against |
externalId | string | Yes | Your identifier to attach it to |
attributes | object | No | Profile fields to set while identifying |
curl -X POST https://api-eu1.joryio.com/api/users/identify \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "anonymousId": "anon_abc123", "externalId": "user_123" }'
Push devices
Attach push tokens in bulk - the path for migrating from another vendor.
POST /api/users/track
Content-Type: application/json
{
"devices": [
{ "externalId": "user-123", "platform": "ios", "pushToken": "a1b2c3…", "appId": "<ios-app-id>",
"deviceInfo": { "model": "iPhone15,2" } },
{ "externalId": "user-456", "platform": "android", "pushToken": "d4e5f6…", "appId": "<android-app-id>" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | Yes | Your identifier for the person. Send userId instead if you have Joryio's internal id |
platform | ios | android | web | Yes | |
pushToken | string | Yes | The token exactly as your previous provider recorded it |
appId | string | No* | Which of your apps the token belongs to. Campaign sends filter on this, so a token imported without one is held but never used. Optional when the workspace has exactly one app on that platform - it is resolved for you. Required when you have several. Get the ids from GET /apps |
deviceInfo | object | No | Model, OS version, app version - free-form |
Up to 1000 devices per request. Devices are applied after attributes, so one call can create the people and attach their tokens.
A contact that does not exist fails that element rather than being created - a device with no profile can receive a push and belongs to nobody. Send them in attributes in the same request.
What actually survives a migration
Not all of it, and this decides the plan more than the API does:
- iOS / APNs - tokens are issued per (bundle id, environment), not per vendor. Same bundle id and your own
.p8, and they carry over. - Android / FCM - tokens are scoped to a Firebase sender. Same project and they work; a different project and every one of them was already dead.
- Web push - a subscription is bound to the VAPID key that created it. It cannot transfer. Those users have to be prompted again, whatever any API promises.
A token that already belongs to someone else
It moves. A push token identifies a device, not a person, and reinstalls, resold handsets and shared tablets are all normal - so the person holding the device is the one who receives the push.
The move is recorded on both sides. The previous contact's device is marked deactivatedReason: "reassigned" with reassignedToUserId and deactivatedAt, so "why did this person stop getting push?" has an answer instead of an inactive row that could equally mean an uninstall.
Consent and verification
Registering a token never grants push consent. Holding a token from a previous vendor is not evidence of permission, so the subscription rules are unchanged - suppression still applies, and granting stays a compliance action. This is the same rule file import follows.
Tokens are taken as given and not probed. One is presumed valid until a send bounces, at which point delivery deactivates it.
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/api/users/by-user-id/user_123 \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"phone": "+1234567890",
"attributes": {
"firstName": "John",
"lastName": "Doe",
"plan": "premium",
"lifetimeValue": 1250.50
},
"segments": [],
"rndBucket": 4821,
"createdVia": "woocommerce",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-20T14:20:00.000Z"
}
List Users
List all users with pagination, optionally narrowed to one person by exact match. Results are ordered by most recently updated first. There is no attribute-filter or sort syntax beyond the parameters below - use Segments to slice by attributes, and GET /users/search?query=... for partial or prefix matching on name/email/phone/ID.
Endpoint
GET /users
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (max 1000) |
offset | number | 0 | Number of users to skip |
email | string | - | Exact match, case-insensitive |
externalId | string | - | Exact match on your identifier. GET /users/by-user-id/:userId returns that one record directly |
userId | string | - | Exact match on Joryio's internal 24-hex id |
q | string | - | Partial match: prefix on email, externalId and name; digits anywhere in the phone; whole-value on a 24-hex id or UUID anonymousId. Ignored when an exact parameter is present. See Find a person |
Example Request
curl -X GET "https://api-eu1.joryio.com/api/users?limit=50&offset=0" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"data": [
{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"attributes": {
"plan": "premium"
}
},
{
"id": "665f1e2a9b3c4d5e6f7a8b9d",
"userId": "665f1e2a9b3c4d5e6f7a8b9d",
"externalId": "user_456",
"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/api/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/api/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/api/users/deleted?limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"total": 1,
"items": [
{
"originalId": "665f1e2a9b3c4d5e6f7a8b9c",
"phone": "+15551234567",
"externalId": "usr_1001",
"deletedAt": "2026-08-09T03:00:30.000Z",
"deletedVia": "api",
"deletedReason": "duplicate",
"restoredAt": null
}
]
}
Bulk Operations (array body)
There is no separate bulk endpoint, and no route that takes a bare array of users. Batches go through POST /users/track: each of its fields - attributes, events, purchases, devices - takes either a single object or an array, so one request can carry many people, their activity and their list subscriptions.
Endpoint
POST /users/track
Content-Type: application/json
{ "attributes": [ { ... }, { ... } ], "events": [ { ... } ] }
Request Body
The body must be a JSON object with at least one of attributes, events, purchases, devices. A bare array ([ {...}, {...} ]) is refused with 400 "Body must be an object with any of: attributes, events, purchases, devices.", and so is an empty object.
Each element of an array has the same shape as the single-object form described under Track. Every element is validated on its own, and unknown keys are rejected rather than ignored.
Example Request
Create or update two people, subscribe the first to a list, and record a signup event for each, in one call. attributes are applied before events, so the events can reference the people created in the same request.
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 '{
"attributes": [
{
"externalId": "user_001",
"email": "[email protected]",
"attributes": { "firstName": "John", "plan": "free" },
"subscriptions": [
{ "listId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d", "channel": "email" }
]
},
{
"externalId": "user_002",
"email": "[email protected]",
"attributes": { "firstName": "Jane", "plan": "premium" }
}
],
"events": [
{ "externalId": "user_001", "name": "Signed Up", "properties": { "source": "landing_page" } },
{ "externalId": "user_002", "name": "Signed Up", "properties": { "source": "referral" } }
]
}'
Response
One summary per array. processed counts the elements applied; failed lists the rest by their index in that array. warnings appears only when an element was stored but needs attention (see Track).
{
"attributes": {
"processed": 2,
"failed": [],
"subscriptions": { "applied": 1, "skipped": [] }
},
"events": { "processed": 2, "failed": [] },
"purchases": { "processed": 0, "failed": [] },
"devices": { "processed": 0, "failed": [] }
}
Response with Failures
One malformed element never fails the batch - everything valid still processes:
{
"attributes": {
"processed": 1,
"failed": [
{ "index": 1, "reason": "property emial should not exist" }
],
"subscriptions": {
"applied": 0,
"skipped": [
{
"index": 0,
"listId": "3f9d2c1e-7a54-4b2e-9c1d-8e6f5a4b3c2d",
"channel": "email",
"reason": "Existing opt-out is not resurrected by user creation; use POST /subscriptions/contacts/:userId/lists/:listId for an explicit re-opt-in"
}
]
}
},
"events": {
"processed": 1,
"failed": [
{ "index": 0, "reason": "name should not be empty" }
]
},
"purchases": { "processed": 0, "failed": [] },
"devices": { "processed": 0, "failed": [] }
}
Limits
- Per request: 1000
attributes, 500events, 500purchases, 1000devices - A request over a cap is rejected with 400 (for example "
eventsaccepts at most 500 items per request (received 600). Split the batch.") rather than truncated - nothing in it is processed - For larger batches, split into consecutive requests of at most the cap per field. Retrying is safe:
/users/trackis an upsert byexternalId, purchases are keyed byorderId, and events can carry aclientEventIdfor deduplication
Each attributes element may carry subscriptions (see List subscriptions). In the response above, element 0 of attributes was subscribed to the list; in the one with failures, its existing opt-out was kept and reported, while the person was still updated. To change a subscription later, use POST /subscriptions/contacts/:userId/lists/:listId.
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 |
includeJourneySteps | boolean | true | false leaves out journey step events (canvas.step.entered / canvas.step.left) from the events and the total |
Example Request
curl -X GET "https://api-eu1.joryio.com/api/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
}
Contact Statistics
Totals
GET /users/stats
Counts for reconciling an integration against your own source of truth - if your store has 40,000 customers and this says 31,000, something is not arriving.
Response:
{
"total": 31204,
"thisWeek": 1840,
"lastWeek": 2110,
"growthPercent": -12.8,
"growthType": "down",
"lifetimeSessions": 96420,
"activeUsers30d": 7310,
"activeUsersPrev30d": 6980
}
total is the contact count, crawler profiles excluded. Every other figure counts people who DID something - being sent a message is not activity - and is null rather than 0 when the analytics store is unavailable, so a gap is never reported as zero. Weeks are workspace-local and start on Sunday; thisWeek is a partial week by construction, so growthPercent reads low early in a week. lifetimeSessions counts distinct sessions, not session-start events.
Growth
GET /users/stats/growth?days=30
Active and new contacts per day - activeUsers is the people who did something
that day, newUsers the people whose first activity was that day. Days are
workspace-local, oldest first, one entry per day (zero-filled). days defaults
to 30 and is clamped to 1-365; there is no granularity parameter - the series
is always daily.
Response - a JSON array:
[
{ "date": "2026-08-25", "activeUsers": 1210, "newUsers": 143 },
{ "date": "2026-08-26", "activeUsers": 1288, "newUsers": 168 }
]
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/track Is an Upsert
POST /users/track is keyed on your externalId - 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/api/users/track \
-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
Send phone numbers in E.164 - +, the country code, then the number. See Phone numbers for how a number without + is handled.
Good: "+972501234567", "+12125551234"
Avoid: "050-123-4567", "(212) 555-1234" - without + they depend on the workspace's default country
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
Re-detect attribute types
Endpoint
POST /attributes/rescan-types
Detected attribute types are decided once, from a sample of your contacts, and then kept - so a type does not drift between reads as new data arrives. The price is that a decision can go stale when the data genuinely changes. This endpoint throws the kept decisions away so they are made again from current data.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
eventName | string | No | Limit the rescan to one event's properties. Omitted, the workspace's contact attributes are rescanned |
Types your team has set are never touched - a person's decision is not
stale because the data moved. Standard fields (price, total,
lastEventTime, ...) are typed by the platform and are not affected either.
Response
{ "success": true, "message": "Types will be detected again from current data" }
Rarely. The kept decision is the point - it is what makes a segment mean the same thing today as it did yesterday. Call this when a property's meaning has actually changed at the source, not on a schedule.