Contract: profile sync
Version 2.
Purpose
Your site is the source of truth for its profiles. Your backend sends the messenger a copy of each profile when it changes, and a delete when the profile goes. The widget shows a profile's name and headline from this copy, and the AI assistant answers only from it (A-4). The messenger never calls your site to fetch a profile: what you send is all it has.
The API schema gives the exact request and response types: for partners, sdk/reference/openapi.json in the developer kit, with its readable form sdk/reference/server-api.md, both made from the platform's own api/openapi.json. This file says what they mean: versions, visibility, the owner, deletes, how long the AI assistant trusts the copy, and what the messenger does when something is wrong.
Parties
| Party | Does |
|---|---|
| Your backend | Sends upserts and deletes for one site with that site's secret key, and reconciles regularly |
| The messenger | Keeps one copy of each profile, applies a change only when its version is newer, and drops private values on arrival |
| The partner console | Shows how many profiles arrived, which were rejected and why, which fields came without a visibility flag (PC-4), and until when the AI assistant answers from the copy |
Authentication
Every request sends the site's secret key: Authorization: Bearer sk_.... A key reaches only its own site's profiles. A profile on another site is not found, even when that site is yours too.
- Make and rotate the key in the partner console, under Keys (PC-1). The console shows the whole key once.
- After a rotation, the replaced key keeps working for 24 hours, so you can switch your servers over. The console can end it sooner.
- Keep the key on your servers. It never goes to a browser, an app or a log.
A request with no key, an unknown key, or a replaced key past its 24 hours gets 401.
Routes
All routes are under /v1/ on the messenger's API address. Request bodies are JSON, sent with Content-Type: application/json.
| Route | Does |
|---|---|
PUT /v1/profiles/{external_id} |
Creates or replaces one profile |
DELETE /v1/profiles/{external_id}?version=N |
Deletes one profile |
POST /v1/profiles/batch |
Up to 100 upserts and deletes in one request |
GET /v1/profiles |
Lists the ids and versions the messenger holds, for reconciliation |
GET /v1/profiles/{external_id} |
Shows the messenger's copy of one profile, for debugging |
external_id is your own id for the profile, such as anna, and the same one your pages give the widget. It has 1 to 128 characters: ASCII letters, digits, -, _ and ., starting with a letter or a digit. Case matters. batch is not allowed, because /v1/profiles/batch is the batch route.
Lengths in this file count characters (Unicode code points), not bytes.
Upsert
PUT /v1/profiles/anna
{
"version": 42,
"owner": "sg-owner-anna",
"fields": {
"name": { "value": "Anna, massage", "visibility": "public" },
"assistant_name": { "value": "Anna", "visibility": "public" },
"headline": { "value": "Classic and sports massage, Baixa", "visibility": "public" },
"services": { "value": "Classic massage 60 min, sports massage 90 min", "visibility": "ai_only" },
"prices": { "value": "60 min 55 EUR, 90 min 75 EUR", "visibility": "ai_only" },
"commission": { "value": "15%", "visibility": "private" },
"years_active": { "value": "8" }
}
}
| Field | Type | Rules |
|---|---|---|
version |
integer | Required. From 1 to 9,007,199,254,740,991, the largest whole number JavaScript reads exactly. See "Versions" |
owner |
string | Required. 1 to 128 characters. See "The owner" |
fields |
object | Required. 1 to 100 entries, each from a field name to a field. Must include name, so an upsert without fields, or with null, is rejected as invalid-field |
| a field name | object key | Matches ^[a-z][a-z0-9_]{0,63}$: a lowercase letter, then up to 63 lowercase letters, digits or underscores |
a field's value |
string | Required. 1 to 4,000 characters, without U+0000 (the NUL character) |
assistant_name's value |
string | Optional field. 1 to 60 characters on one line, without invisible formatting characters. See "The name in the AI label" |
a field's visibility |
string | public, ai_only or private. Anything else, or nothing, means private. See "Fields and visibility" |
The public and AI-only values of one profile hold at most 16,000 characters in all. Private values do not count, because the messenger does not keep them.
The answer is 200 with the outcome:
{ "external_id": "anna", "outcome": "applied", "version": 42 }
Its version is the one the messenger now holds: yours when the outcome is applied or unchanged, a higher one when it is stale.
A rejected upsert gets 422. See "Rejections".
Delete
DELETE /v1/profiles/anna?version=43
The version goes in the query, with no body, because some HTTP clients and proxies drop the body of a DELETE. The answer is 200 with the same outcome document as an upsert.
Versions
You choose each version: a positive whole number that grows with every change to the profile, such as a counter your database increments or the time of the change in milliseconds. A delete is a change too, so it takes a higher version than the last upsert.
The messenger compares the version you send with the one it holds, a deleted profile's included:
| You send | The messenger holds | Outcome | What happens |
|---|---|---|---|
| A higher version | A lower one, or nothing | applied |
Your change replaces the copy |
| The same version, the same content | That version | unchanged |
Nothing: this is a retry |
| A lower version | A higher one | stale |
Nothing: a newer change arrived first |
| The same version, other content | That version | rejected, version-reused |
Nothing: give every change a new version |
unchanged and stale are not failures. You can repeat any request, in any order, and the highest version wins.
Content here is the owner, the field names, their flags, and the public and AI-only values. For a delete, the content is the delete itself. The messenger keeps no private value, so a change to a private value alone is not seen. To change one anyway, send it with a new version.
Fields and visibility
Every field carries a visibility flag:
| Flag | Who sees the value |
|---|---|
public |
Visitors and the AI assistant. Today the widget shows only name, headline and assistant_name |
ai_only |
The AI assistant, to answer visitors. It may repeat what the field says to a visitor, so put nothing there that a visitor must not read |
private |
Nobody. The messenger keeps the field's name and drops its value on arrival (D-73) |
A field sent with no visibility, with null, or with a value the messenger does not recognise, such as "Public" or "hidden", is private (D-52). The console counts these fields as sent without a flag, so you can spot the mistake.
The messenger never stores a private value, never shows it and never gives it to the AI assistant (INV-3). It does not come back from GET /v1/profiles/{external_id} either: you see the field's name only. You may send private fields or leave them out. Sending one tells the messenger that the field exists, which the console shows.
name is required in every upsert. The widget shows a profile's name, headline and assistant_name when they are public, and nothing else from the profile. If name is not public, the widget shows the profile without a name.
The widget also names the profile by its public name in the chat's heading and in sentences about the owner, such as "Anna, massage will reply here" and "Anna, massage replied". The name always comes first in such a sentence, so a name with a comma still reads, though a plain name reads best.
The name in the AI label
Every reply from the AI assistant carries a label that nobody can remove (W-4): "<assistant name>'s AI assistant", such as "Anna's AI assistant". The notice that the AI assistant may reply uses the same name. A profile's name often says more than who the person is, such as "Anna, massage", and would read badly there. So send the name for the label in a field of its own, assistant_name, and make it public:
"name": { "value": "Anna, massage", "visibility": "public" },
"assistant_name": { "value": "Anna", "visibility": "public" }
assistant_name is optional. It holds 1 to 60 characters on one line, without invisible formatting characters. A longer value, or one with a line break or such a character, is rejected as invalid-field, with field assistant_name, whatever its visibility. Sent as AI-only or private, it does not reach the label.
Without a public assistant_name, the label uses name when that is public and fits the same rule. When neither fits, the profile gets no AI replies: its owner answers every message, and visitors are told that the owner will reply.
The owner
owner names the profile owner, the person who answers for the profile in the inbox. A profile has one owner (D-55). Send the same user ID your backend puts in sub when it signs that person in on your site (contracts/user-token.md). Never send an email address, a phone number or a name.
- The messenger keeps only a fingerprint of the owner's ID combined with your site, so the same ID on another site names a different person (INV-5). The console therefore counts owners but cannot list their IDs, and
GET /v1/profiles/{external_id}does not show the owner. - When the owner signs in on your site and opens the inbox, the messenger finds the profiles they own by this ID.
- To give a profile a new owner, send an upsert with a higher version and the new
owner. That upsert is the change (ID-9, D-46). - A profile's conversations belong to the profile, not to its owner (D-74). After a change of owner, the new owner sees the profile's past conversations, and the previous owner loses access to them at once.
Deletes
A delete erases the profile's copy. The messenger drops its fields and its owner at once, and keeps only the external id, the version and the time of the delete, so that an older upsert arriving late cannot bring the profile back. The widget then says the profile is not available.
- To bring a deleted profile back, send an upsert with a version higher than the delete's.
- A delete of a profile the messenger never had applies too. The messenger keeps the record of the delete, so a late upsert with a lower version stays out.
Batch
POST /v1/profiles/batch
{
"total": 3,
"items": [
{
"action": "upsert",
"external_id": "anna",
"version": 42,
"owner": "sg-owner-anna",
"fields": {
"name": { "value": "Anna, massage", "visibility": "public" },
"assistant_name": { "value": "Anna", "visibility": "public" },
"headline": { "value": "Classic and sports massage, Baixa", "visibility": "public" }
}
},
{ "action": "delete", "external_id": "ines", "version": 17 }
]
}
itemsholds 1 to 100 items. An upsert item hasaction,external_idand the upsert'sversion,ownerandfields. A delete item hasaction,external_idandversion.- Each item is decided on its own, as if you had sent it alone. A rejected item does not stop the others or undo them. Items that name the same profile apply in the order you sent them.
- The batch is saved as one: the changes it applies take effect together, or none of them does.
totalis optional: how many profiles your site lists in all. The console shows "imported of total" from the last batch that sent it, with its time. Send it during an initial import and when it changes.
The answer is 200 with one result for each item, in the same order:
{
"results": [
{ "external_id": "anna", "outcome": "applied", "version": 42 },
{ "external_id": "ines", "outcome": "rejected", "reason": "version-reused" }
]
}
If the messenger cannot read an item (an unknown action, an external_id that breaks its rule, a missing version), or there are no items or more than 100, the whole batch gets 422 and none of it applies.
If a batch fails with a timeout or a 5xx, send the whole batch again. Usually none of it applied. If it was saved just before the failure, the items it applied come back unchanged, the others as they did before.
Reconciliation
Requests can get lost. Regularly, for example once a day, compare what the messenger holds with your own list, and send again what differs. The messenger never calls your site to ask. The read also keeps the copy fresh for the AI assistant (see "Keeping the copy fresh").
GET /v1/profiles lists every profile the messenger holds for the site, deleted ones included, in the byte order of external_id, as ASCII sorts it: - and ., then digits, then A to Z, then _, then a to z. limit takes 1 to 1,000 profiles a page, 1,000 when left out. Pass a page's next, the last external id on it, as after to get the next page; the last page has no next.
Your database may sort the same ids in another order, so compare the two lists as sets, by external id, rather than walking them side by side.
{
"profiles": [
{ "external_id": "anna", "version": 42, "deleted": false },
{ "external_id": "ines", "version": 17, "deleted": true }
],
"next": "ines"
}
Send a profile again when the messenger has a lower version than yours, or does not list it. Send a delete, with a higher version, for a profile the messenger holds but your site no longer lists.
GET /v1/profiles/anna shows the messenger's copy, so you can check what arrived:
{
"external_id": "anna",
"version": 42,
"synced_at": "2026-10-12T09:20:00Z",
"deleted": false,
"fields": {
"name": { "value": "Anna, massage", "visibility": "public" },
"assistant_name": { "value": "Anna", "visibility": "public" },
"headline": { "value": "Classic and sports massage, Baixa", "visibility": "public" },
"services": { "value": "Classic massage 60 min, sports massage 90 min", "visibility": "ai_only" },
"prices": { "value": "60 min 55 EUR, 90 min 75 EUR", "visibility": "ai_only" }
},
"private_fields": ["commission"],
"unflagged_fields": ["years_active"]
}
private_fields are the fields sent as private, and unflagged_fields the ones sent without a flag the messenger recognises; both are private. A deleted profile has no fields. A profile the messenger never had gets 404.
Keeping the copy fresh
A price or an opening time can change on your site without the messenger hearing of it. So the AI assistant answers from a site's profiles only while your backend has been in touch: a request for that site, with its secret key, must have arrived within the last 7 days (D-82). Each of these counts once the messenger has read it, whatever it decides (applied, unchanged, stale or rejected):
- an upsert or a delete;
- a batch;
- a reconciliation read,
GET /v1/profiles: any page of it answered 200.
Reading one profile's copy (GET /v1/profiles/{external_id}) does not count, and neither does a request the messenger could not read or did not take: one answered 400, 401, 413, 415, 429 or 5xx, or 422 with a problem document whose type is about:blank.
When 7 days pass without one, the AI assistant stops answering for all of the site's profiles. Each new visitor message waits for the profile's owner, and the visitor is told that the owner will reply. The next request that counts lets the AI assistant answer again, from the next visitor message on. You do not need to send any profile again. A conversation that went to its owner meanwhile stays with the owner until they hand it back.
So reconcile even when nothing has changed: the read alone keeps the copy fresh. The partner console's sync card shows until when the AI assistant answers, and warns a day before. The messenger's operator may set a period other than 7 days for its instance.
Rejections
The messenger rejects an upsert or a delete for one of four reasons. PUT and DELETE answer 422 with a problem document whose type is /problems/ and the reason, such as /problems/owner-missing. A batch item gets "outcome": "rejected" and the reason.
| Reason | When |
|---|---|
owner-missing |
The upsert has no owner, or it is empty or longer than 128 characters |
invalid-field |
A field name breaks its pattern, a value is empty or holds U+0000, there is no name field, or assistant_name is longer than 60 characters, has a line break or holds an invisible formatting character |
too-large |
More than 100 fields, a value longer than 4,000 characters, or more than 16,000 characters of public and AI-only values in all |
version-reused |
The version is the one the messenger holds, with other content |
When the reason concerns one field whose name is valid, the problem document and the batch result also carry field, the name of the first such field in the order of the field names.
The partner console lists each rejection for 30 days: the external id, the version, the reason, the field when the reason concerns one, and the time, never the content. The same change rejected again for the same reason is listed once, with the time it last came. Stale and unchanged requests are not rejections, and the console does not list them.
Some requests the messenger cannot read at all: the body is not JSON, version is missing or out of range, a value is not a string, or external_id breaks its rule. These get 400, 415 or 422 at once, with a problem document whose type is about:blank, and the console does not list them.
Limits
| Limit | Value |
|---|---|
| Request body | 1 MiB. A larger one gets 413: send fewer items in each batch |
| Items in a batch | 1 to 100 |
| Fields in a profile | 1 to 100 |
| Field name | ^[a-z][a-z0-9_]{0,63}$ |
| Field value | 1 to 4,000 characters, without U+0000 |
| Public and AI-only values of one profile | 16,000 characters in all |
external_id |
1 to 128 characters: ASCII letters, digits, -, _ and ., starting with a letter or a digit, and not batch |
owner |
1 to 128 characters |
version |
1 to 9,007,199,254,740,991 |
A page of GET /v1/profiles |
1 to 1,000 profiles |
| Requests | About 10 a second for each site. Each network address has a limit of its own |
Past a rate limit the answer is 429 with Retry-After: wait that many seconds, then send the request again. At 10 batches of 100 a second, 100,000 profiles take under two minutes.
Errors
Errors are problem documents (RFC 9457, Content-Type: application/problem+json), as everywhere in the API.
| Status | When |
|---|---|
| 400 | The body is not JSON, or the query is not valid, such as a limit above 1,000 or an after that is no external id |
| 401 | No secret key, an unknown one, or a replaced one past its 24 hours |
| 404 | GET /v1/profiles/{external_id} for a profile the messenger never had |
| 413 | The body is larger than 1 MiB |
| 415 | The request has a body but no Content-Type: application/json |
| 422 | A rejection, whose type names the reason, or a body the messenger cannot read (about:blank) |
| 429 | Too many requests: try again after Retry-After seconds |
| 5xx | A fault on the messenger's side: send the same request again later |
Ordering, retries and idempotency
- Every request is safe to repeat. Versions make retries and late arrivals harmless.
- Keep each change in a queue on your side until the messenger answers 200, so that none is lost.
- Retry 429 and 5xx answers. Any other error comes back the same until you fix the request.
Security
- The secret key is the only credential. Send it only over HTTPS, and never log it.
- Send only what the messenger needs. A private value is dropped anyway, so you can leave it out.
- The messenger writes no field name, field value, owner ID or external id to its logs.
- A leaked secret key lets someone change your site's profiles, and no other site's. Rotate it in the console at once, then end the replaced key.
Versioning
The path carries the version: /v1/. New optional fields may appear in requests and answers without a new version, so ignore any field you do not know. Any other change raises the version, and the messenger keeps version 1 working while a live site uses it.
This file has a version of its own. Version 2 adds the optional field assistant_name and its rule, and says how long the AI assistant answers from a site's copy. No request that version 1 accepted is refused, unless it sends a field named assistant_name that breaks the rule.