Identify API
The Identify API creates a customer profile the first time it sees a userId, and updates that profile on every later call. A customer profile holds the traits you know about one person, such as their email address and plan. JustAI saves the update before it responds.
Send customer profiles walks through planning and sending profiles. This page is the field-by-field reference.
Endpoint
Section titled “Endpoint”POST https://ingest.justwords.ai/api/identify/{org_slug}X-Api-Key: YOUR_API_KEYContent-Type: application/json| Header | Required | Value |
|---|---|---|
X-Api-Key |
Yes, or send the key in Authorization |
Your JustAI API key. Authorization: Bearer and HTTP Basic also work. See Authentication. |
Content-Type |
Recommended | application/json |
X-JustAI-Environment |
Non-production environments only | The environment key, for example staging. Leave it out for Production. See Environments. |
Request body
Section titled “Request body”Send one JSON object with these fields:
| Field | Type | Required | Rules |
|---|---|---|---|
userId |
string | Yes | Your stable ID for the person. Must not be empty after trimming. JustAI removes leading and trailing spaces before it saves the ID. A number or null returns 400. |
traits |
object | Yes, unless consent has an entry |
The traits to save. Keys are strings. Values can be any JSON value: string, number, boolean, null, array, or object. Needs at least one key unless consent has at least one entry. |
messageId |
string | No | Your unique ID for this update. Must not be empty after trimming. If you leave it out, JustAI generates a UUID and returns it. |
timestamp |
string | No | When the change happened, in ISO 8601 with Z or a numeric offset. If you leave it out, JustAI uses the time it receives the call. See Timestamps. |
consent |
array | No | Channel consent decisions. See Consent decisions. |
context |
object | No | Accepted for compatibility. JustAI does not save or use it. |
integrations |
object | No | Accepted for compatibility. JustAI does not save or use it. |
JustAI ignores every other top-level field, including type, anonymousId, sentAt, originalTimestamp, receivedAt, channel, version, writeKey, and projectId.
These rules matter most when a CDP builds the payload:
- JustAI reads traits only from top-level
traitsand ignorescontext.traits. A call with traits only incontext.traitsreturns400. - JustAI ignores
anonymousIdand has no alias endpoint. An Identify call does not link earlier anonymous events to the profile. - The endpoint does not check the call type. Any body with a
userIdand traits updates the profile, including a Segment or RudderStackgroupcall. Send onlyidentifycalls to this endpoint. - An anonymous Identify call, with
userIdset tonullor left out, returns400. Filter these calls out before they reach JustAI. traits,context, andintegrationsmust be objects when present.nullreturns400.
Timestamps
Section titled “Timestamps”timestamp must be an ISO 8601 date-time string with a time zone. JustAI accepts these forms:
2026-10-01T12:00:00Z2026-10-01T12:00:00.123Z, with fractional seconds of any length2026-10-01T12:00:00+05:30or2026-10-01T12:00:00+05302026-10-01T12:00Z, with no seconds
JustAI rejects these forms with 400:
2026-10-01T12:00:00, with no time zone2026-10-01 12:00:00Z, with a space in place ofT2026-10-01T12:00:00+05, with an hour-only offset2026-10-01, a date alone2026-10-01t12:00:00z, with a lowercasetorz2026-02-30T00:00:00Z, a date that does not exist1790856000000, an epoch number
The timestamp also has two bounds:
- It cannot be earlier than
1970-01-01T00:00:00Z. - It cannot be more than 5 minutes later than JustAI’s clock. A later time returns
400with{"error":"timestamp cannot be more than 5 minutes in the future"}.
Consent decisions
Section titled “Consent decisions”consent is an optional array of decisions. Each decision records one person’s choice for one channel and purpose.
| Field | Values | Required | Notes |
|---|---|---|---|
channel |
"email", "sms" |
Yes | |
status |
"granted", "withdrawn", "unknown" |
Yes | |
purpose |
"marketing", "tracking" |
No | Defaults to "marketing". "tracking" covers email open and click tracking, and applies to email only. |
basis |
"consent", "legitimate_interest", "contract", "implied_transaction", "implied_contract", "implied_inquiry" |
No | The legal basis for the decision. |
source |
"import", "signup_form", "api", "manual" |
No | Defaults to "api". |
consentedAt |
integer | No | When the person made the decision, in epoch milliseconds. |
proofRef |
string | No | What the person agreed to, for example a form ID. Must not be empty. |
expiresAt |
integer | No | When the decision lapses, in epoch milliseconds. |
JustAI ignores unknown fields inside a decision.
Validation rules
Section titled “Validation rules”| Rule | Issue message when the rule is broken |
|---|---|
A granted implied_transaction, implied_contract, or implied_inquiry decision needs consentedAt. |
consentedAt is required for a granted implied-consent basis |
A granted implied_contract decision also needs expiresAt, set to the contract’s end date plus the period that Canada’s Anti-Spam Legislation (CASL) allows for implied consent. |
expiresAt is required for a granted implied_contract decision (termination + CASL window) |
A tracking decision applies to email only. |
tracking consent applies to the email channel only |
A granted tracking decision uses basis: "consent" or leaves basis out. |
a tracking grant needs basis 'consent' |
Which address a decision applies to
Section titled “Which address a decision applies to”A decision applies to the contact address on the profile, not to the userId:
- An
emaildecision applies totraits.email, trimmed and lowercased. - An
smsdecision applies totraits.phone, trimmed.
JustAI takes the address from the same call, or from the stored profile as of the decision time. If no address is known, JustAI skips the decision and still returns 200. Include traits.email or traits.phone in the same call unless the profile already has it.
A consent-only call is valid. Send "traits": {} with at least one decision, for example to record a withdrawal from your preference center. A consent-only call for a user ID that JustAI has not seen records no decision, because the new profile has no address.
Which decision wins
Section titled “Which decision wins”- The newest decision wins. A decision’s time is its
consentedAt. WithoutconsentedAt, it is the call’stimestamp, or the time JustAI received the call. - If
consentedAtis more than 5 minutes in the future, JustAI uses the call’s time instead. - When two decisions have the same time,
withdrawnwins overgranted. - A decision with status
unknownnever replaces an existing decision.
When implied consent expires
Section titled “When implied consent expires”| Basis | A grant expires |
|---|---|
implied_transaction |
2 years after consentedAt |
implied_inquiry |
6 months after consentedAt |
implied_contract |
At the expiresAt you send |
For implied_transaction and implied_inquiry, an earlier expiresAt that you send takes precedence.
Agree with your JustAI team how your consent records map to these fields before launch. Identity and consent covers the decisions to make.
How updates merge
Section titled “How updates merge”- Identify writes only the top-level traits that you send. Traits that you leave out stay unchanged.
- Identify never removes a trait. Sending
nullstores the valuenull, and the key stays on the profile. - A nested object or array replaces the whole stored value for that trait. There is no deep merge. For example, sending
"address": {"city": "Paris"}replaces the entire storedaddressobject. - Each trait has its own update time. JustAI writes a trait only when the call’s time is equal to or later than the time that trait was last written. The call’s time is its
timestamp, or the time JustAI received it. - An older call does not overwrite a newer value for a trait, but its other, newer traits still apply. When two calls have the same time, the call that arrives later wins.
- Every accepted call returns
200, even when JustAI skips some traits as older. Read the profile back to see what JustAI saved. - Confirm with your JustAI team whether A profile attribute changes is available for your organization. When available, a call that changes at least one trait value can start that journey. A call that changes nothing, and a duplicate retry, trigger nothing.
Ask your JustAI team whether treating each email address as one person is available and appropriate for your organization. Confirm that setup before relying on it. When it is enabled, an Identify call whose traits.email already belongs to another profile joins the two profiles, and both user IDs read back the same profile. Placeholder values that are not real addresses, such as unknown or N/A, never join profiles.
Example
Section titled “Example”curl -X POST "https://ingest.justwords.ai/api/identify/$JUSTAI_ORG_SLUG" \ -H "X-Api-Key: $JUSTAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "userId": "user-1234", "messageId": "user-1234:2026-10-01T12:00:00Z", "timestamp": "2026-10-01T12:00:00Z", "traits": { "email": "ada@example.com", "first_name": "Ada", "plan": "pro", "address": { "city": "Paris", "country": "FR" } }, "consent": [ { "channel": "email", "status": "granted", "basis": "consent", "source": "signup_form", "consentedAt": 1790855880000, "proofRef": "newsletter-signup-form" } ] }'{"success":true,"messageId":"user-1234:2026-10-01T12:00:00Z","duplicate":false}Responses
Section titled “Responses”| Status | Body | Meaning |
|---|---|---|
200 |
{"success":true,"messageId":"...","duplicate":false} |
JustAI accepted the call. It skips any trait whose stored value is newer, and a call can leave every value unchanged. Read the profile back to see what changed. |
200 |
{"success":true,"messageId":"...","duplicate":true} |
JustAI already accepted this messageId with the same userId, timestamp, and traits. Nothing is applied again. |
400 |
{"error":"Invalid Identify call","issues":[...]} |
The body failed validation. See Errors. |
400 |
{"error":"timestamp cannot be more than 5 minutes in the future"} |
timestamp is more than 5 minutes ahead of JustAI’s clock. |
409 |
{"error":"messageId <id> was already used for a different call"} |
This messageId was used before with a different userId, timestamp, or traits. |
Authentication, path, and server errors are the same for all three APIs. See Errors and retries.
Duplicates and conflicts
Section titled “Duplicates and conflicts”JustAI remembers each messageId per environment. The same ID in Production and in another environment does not collide.
- Use a new
messageIdfor every new update. Reuse amessageIdonly to retry the same call with the same body. - An identical retry returns
200with"duplicate": trueand changes nothing. - A reused
messageIdwith a differentuserId,timestamp, ortraitsreturns409. Atimestampthat is present in one call and absent in the other counts as different. - JustAI does not compare
consentwhen it checks for a duplicate. If you reuse amessageIdwith the same traits and a changed consent decision, JustAI returns"duplicate": trueand drops the new decision. Send every consent change with a newmessageId.
Send your own messageId on Identify when you can give every new update a new ID. Leave messageId out only when your sync tool can’t guarantee a new ID whenever a synced value changes. JustAI then generates a new ID for each call, so it can’t recognize a retry and treats every retry as a new update. When you leave messageId out, always send timestamp, so a delayed retry can’t overwrite a newer value.
For warehouse and reverse ETL syncs, build the messageId from values that change whenever any synced value changes, such as a hash of the user ID, the update time, and every synced trait. The Hightouch profile sync template builds it this way. An ID built only from user_id and updated_at returns 409 when a trait changes but updated_at doesn’t.
Errors
Section titled “Errors”A 400 from Identify lists every problem that JustAI found. Each issue has a code, a path to the field, and a message. Some issues carry extra fields, such as expected and received.
{"error":"Invalid Identify call","issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["userId"],"message":"Required"}]}| Cause | Issue path |
Issue message |
|---|---|---|
userId is missing |
["userId"] |
Required |
userId is null |
["userId"] |
Expected string, received null |
userId is a number |
["userId"] |
Expected string, received number |
userId is empty or only spaces |
["userId"] |
String must contain at least 1 character(s) |
No traits and no consent, or traits only in context.traits |
["traits"] |
At least one trait is required unless consent is provided |
traits is null |
["traits"] |
Expected object, received null |
timestamp has the wrong format |
["timestamp"] |
Invalid datetime. If JustAI cannot read the value as a date at all, a second issue follows with the message Timestamp must be on or after 1970-01-01T00:00:00Z. |
timestamp is a number |
["timestamp"] |
Expected string, received number |
timestamp is before 1970 |
["timestamp"] |
Timestamp must be on or after 1970-01-01T00:00:00Z |
| The body is an array | [] |
Expected object, received array |
| The body is not valid JSON | [] |
Expected object, received null |
| A consent decision breaks a rule | For example, ["consent",0,"consentedAt"] |
See Validation rules |
Verify an update
Section titled “Verify an update”Read the profile back with the Profiles API and compare fields with what you sent. You can also search for the user ID on Users in JustAI and open the profile.
A read right after an update can briefly show the previous values. If your change is not there, wait a minute and read again.
