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/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​

FieldTypeRequiredDescription
attributesobject or arrayNoProfile fields. These persist on the person
eventsobject or arrayNoThings that happened. Appended to the timeline
purchasesobject or arrayNoOrders, 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.

Attributes are applied first

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:

FieldDescription
externalIdYour identifier, and the only create-or-update key
userIdJoryio's internal 24-hex id. Update-only - never creates, never matches an externalId
anonymousIdA pre-identification session. Promote it later with POST /users/identify
userId is ours, externalId is yours

These 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​

FieldTypeDescription
emailstring
phonestring
attributesobjectCustom fields (max 200 keys, 50KB, nesting depth 5)
subscriptionsarrayList subscriptions - see List subscriptions

Events​

FieldTypeRequiredDescription
namestringYesEvent name (max 255 chars)
propertiesobjectNoProperties of this occurrence. They do not update the profile - send those in attributes
timestampstringNoISO 8601. When it actually happened. Defaults to now
timestringNoDeprecated alias for timestamp
sessionIdstringNoClient session this event belongs to
deviceIdstringNoDevice this event came from
clientEventIdstringNoYour id for this event, so a retry is deduplicated
Do not send Order Completed as an event

That 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.

FieldTypeRequiredDescription
subscriptions[].listIdstringYesThe subscription list id (Subscriptions API)
subscriptions[].channelstringNoemail (default), sms, whatsapp, push or viber
subscriptions[].statusstringNosubscribed (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 subscribed request 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, the attributes result adds subscriptions: { applied, skipped[] }, and each skip names the element index, the listId, the channel and the reason. 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": [] }
}
Unknown fields are rejected, not ignored

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​

Send phone numbers with + and the country code

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
FieldTypeRequiredDescription
anonymousIdstringYesThe session the behaviour was recorded against
externalIdstringYesYour identifier to attach it to
attributesobjectNoProfile 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>" }
]
}
FieldTypeRequiredDescription
externalIdstringYesYour identifier for the person. Send userId instead if you have Joryio's internal id
platformios | android | webYes
pushTokenstringYesThe token exactly as your previous provider recorded it
appIdstringNo*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
deviceInfoobjectNoModel, 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.

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 (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/api/users/by-user-id/user_123 \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"id": "665f1e2a9b3c4d5e6f7a8b9c",
"userId": "665f1e2a9b3c4d5e6f7a8b9c",
"externalId": "user_123",
"email": "[email protected]",
"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​

ParameterTypeDefaultDescription
limitnumber50Results per page (max 1000)
offsetnumber0Number of users to skip
emailstring-Exact match, case-insensitive
externalIdstring-Exact match on your identifier. GET /users/by-user-id/:userId returns that one record directly
userIdstring-Exact match on Joryio's internal 24-hex id
qstring-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",
"email": "[email protected]",
"attributes": {
"plan": "premium"
}
},
{
"id": "665f1e2a9b3c4d5e6f7a8b9d",
"userId": "665f1e2a9b3c4d5e6f7a8b9d",
"externalId": "user_456",
"email": "[email protected]",
"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/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​

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/api/users/deleted?limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"total": 1,
"items": [
{
"originalId": "665f1e2a9b3c4d5e6f7a8b9c",
"email": "[email protected]",
"phone": "+15551234567",
"externalId": "usr_1001",
"deletedAt": "2026-08-09T03:00:30.000Z",
"deletedBy": "[email protected]",
"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, 500 events, 500 purchases, 1000 devices
  • A request over a cap is rejected with 400 (for example "events accepts 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/track is an upsert by externalId, purchases are keyed by orderId, and events can carry a clientEventId for deduplication
List subscriptions ride on the same call

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​

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

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/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​

FieldTypeRequiredDescription
eventNamestringNoLimit 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" }
When to call this

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.