All docs pages

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 }
  ]
}
  • items holds 1 to 100 items. An upsert item has action, external_id and the upsert's version, owner and fields. A delete item has action, external_id and version.
  • 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.
  • total is 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.