The profile lifecycle
Your site is the source of truth for its profiles. The platform keeps a copy, and your backend keeps it current. The messenger shows a profile's card from this copy, and the AI assistant answers visitors only from it. The platform never calls your site to fetch a profile: what you send is all it has.
This guide follows one profile through its whole life, with the real requests and answers. The example program profile-lifecycle.ts runs the same story against your sandbox site, so you can watch each answer come back. The rules come from contracts/profile-sync.md, linked at each step, and every route and field is in the server API reference.
You need the site's secret key from the partner console (guide 1). It goes only on your server.
What each field feeds
A profile is a set of fields, each with a value and a visibility flag. The flag decides who sees the value (Fields and visibility):
| Field and flag | What the messenger shows | The AI assistant | What the platform keeps |
|---|---|---|---|
name, public |
The chat's heading, with its first letter on the profile's card. Sentences about the owner name the profile by it too, such as "Lena, yoga will reply here" and "Lena, yoga replied" | Reads it. Uses it in its label ("Lena, yoga's AI assistant") when there is no public assistant_name and the name fits the label's rule |
The value |
headline, public |
Under the heading, after your site's name | Reads it | The value |
assistant_name, public |
The AI assistant's label and its notice: "Lena's AI assistant" | Reads it | The value |
Any other field, public |
Nothing today: the messenger shows only the three fields above | Reads it | The value |
Any field, ai_only |
Nothing. With an AI-only name, the heading reads "Chat" and sentences say "the profile owner" |
Reads it, and may repeat it to a visitor word for word | The value |
Any field, private |
Nothing | Nothing | Only the field's name |
Any field with no flag, null or a flag it does not know, such as "Public" |
Nothing | Nothing | Only the field's name, counted in the console as sent without a flag |
A sentence about the owner always starts with the name, so a name with a comma, such as "Lena, yoga", still reads, though a plain name reads best (Fields and visibility). Guide 3 shows where each of these appears.
public and ai_only both reach the AI assistant, which reads them the same way and may repeat either to a visitor. The flag decides only whether the messenger may show the value itself:
publicis for what visitors may see in the messenger. Today it shows onlyname,headlineandassistant_name, so any other public field reaches the AI assistant and nothing else.ai_onlyis for what the AI assistant needs to answer questions, such as services, prices, opening hours and how to book. The messenger never shows it. Your page may show the same details, and that is fine: the example below sends its classes and prices this way.privateis for everything else. The platform drops a private value as it arrives and never stores it, shows it or gives it to the AI assistant. You may leave private fields out altogether. Sending one only tells the platform that the field exists, which the console shows.
So for the services and prices your page shows anyway, either flag gives the AI assistant the same facts. Send them ai_only, and keep public for what the messenger shows. Neither flag keeps a secret: put nothing in a public or AI-only field that a visitor must not read.
The AI assistant labels each reply with a name. Send that name as assistant_name, public, 1 to 60 characters on one line (The name in the AI label). Without it the label uses a public name that fits the same rule. When neither fits, the profile gets no AI replies: its owner answers every message.
Limits on fields: a name matches ^[a-z][a-z0-9_]{0,63}$, a value holds 1 to 4,000 characters, a profile has 1 to 100 fields with name among them, and its public and AI-only values hold at most 16,000 characters in all (Limits).
1. A new profile
When a profile appears on your site, send the whole profile with PUT. example-lena is your own id for it, the same one your pages give the messenger (guide 3).
PUT /v1/profiles/example-lena HTTP/1.1
Host: api.messenger.example
Authorization: Bearer sk_test_...
Content-Type: application/json
{
"version": 101,
"owner": "owner-lena",
"fields": {
"name": { "value": "Lena, yoga", "visibility": "public" },
"assistant_name": { "value": "Lena", "visibility": "public" },
"headline": { "value": "Hatha and yin yoga, Alfama", "visibility": "public" },
"classes": { "value": "Hatha yoga 60 minutes, yin yoga 75 minutes, beginners on Mondays", "visibility": "ai_only" },
"prices": { "value": "One class 14 EUR, ten classes 120 EUR", "visibility": "ai_only" },
"editor_note": { "value": "Featured in the spring newsletter", "visibility": "ai_only" }
}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "external_id": "example-lena", "outcome": "applied", "version": 101 }
versionis a number you choose, and it grows with every change to the profile (Versions). A counter your database increments works, and so does the time of the change in milliseconds.owneris the profile owner's user ID on your site: the samesubyour backend puts in their user token (The owner). Never an email address, a phone number or a name. The platform keeps only a fingerprint of it, so the console counts owners but cannot list them, and no answer shows the owner.- An upsert replaces the whole profile. Send every field the platform should have. A field you leave out is gone from the copy once the upsert applies.
Your editors' note, editor_note, went out AI-only by mistake. It is harmless, and step 4 shows why that matters.
2. Sending it again
A request can time out after the platform applied it, and then you cannot tell whether it arrived. Send exactly the same request again:
HTTP/1.1 200 OK
Content-Type: application/json
{ "external_id": "example-lena", "outcome": "unchanged", "version": 101 }
The same version with the same content is unchanged: nothing happens. That is what makes every sync request safe to repeat. There is no idempotency key to manage: the version and the content are the key (Ordering, retries and idempotency).
3. A field changes, and an older change arrives late
Lena raises her prices. Send the whole profile again with a higher version:
PUT /v1/profiles/example-lena HTTP/1.1
{ "version": 102, "owner": "owner-lena", "fields": { ..., "prices": { "value": "One class 15 EUR, ten classes 130 EUR", "visibility": "ai_only" } } }
{ "external_id": "example-lena", "outcome": "applied", "version": 102 }
The AI assistant quotes the new prices from its next reply. Now suppose version 101 was stuck in a retry queue and arrives after 102:
{ "external_id": "example-lena", "outcome": "stale", "version": 102 }
stale means a newer change arrived first, so nothing happened, and version is the one the platform holds. Neither unchanged nor stale is a failure: you can send requests in any order, and the highest version wins. Versions has the whole table, with the one case that is rejected: the same version with other content (step 5).
4. A field turns AI-only or private
The editors' note is yours alone, and the AI assistant has no use for it. Change its flag to private and send the profile with a higher version:
{ "version": 103, "owner": "owner-lena", "fields": { ..., "editor_note": { "value": "Featured in the spring newsletter", "visibility": "private" } } }
The platform drops the stored value at once, and the AI assistant no longer has it from its next reply. Moving a field from public to ai_only works the same way: it leaves the card and stays with the AI assistant.
A new flag does not reach back. What the AI assistant already read stays in the record of each reply it made, kept for the platform's retention period (90 days by default), and in any reply it already sent to a visitor. A public value may also have been on the card. So get each flag right on the first sync: send nothing as public or ai_only that must never reach a visitor or stay on record. That is why this step changes a harmless field.
Check what the platform kept with GET /v1/profiles/{external_id}. It is for checking, not for every page view, and it does not keep the copy fresh (step 9).
GET /v1/profiles/example-lena HTTP/1.1
Authorization: Bearer sk_test_...
{
"external_id": "example-lena",
"version": 103,
"synced_at": "2026-10-20T09:20:00Z",
"deleted": false,
"fields": {
"assistant_name": { "value": "Lena", "visibility": "public" },
"classes": { "value": "Hatha yoga 60 minutes, yin yoga 75 minutes, beginners on Mondays", "visibility": "ai_only" },
"headline": { "value": "Hatha and yin yoga, Alfama", "visibility": "public" },
"name": { "value": "Lena, yoga", "visibility": "public" },
"prices": { "value": "One class 15 EUR, ten classes 130 EUR", "visibility": "ai_only" }
},
"private_fields": ["editor_note"],
"unflagged_fields": []
}
private_fields and unflagged_fields hold names only. A private value never comes back. The platform keeps no private value, so a change to a private value alone is not seen: it would only come back unchanged.
5. When the platform rejects a change
Some changes break a rule. The platform then changes nothing and answers 422 with a problem document whose type names the reason (Rejections). Sending version 103 again with another headline reuses a version:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{ "type": "/problems/version-reused", "title": "Version reused", "status": 422 }
An assistant_name on two lines, at version 104, breaks the label's rule. When the reason concerns one field whose name is valid, the answer names it in field:
{ "type": "/problems/invalid-field", "title": "Invalid field", "status": 422, "field": "assistant_name" }
Rejections lists the four reasons, owner-missing, invalid-field, too-large and version-reused, and exactly when each applies.
A rejection is not worth retrying: the same request gets the same answer. Fix the profile on your side and send it with a new version.
You see rejections in the partner console. It lists every rejection for 30 days on its Profiles page, under Rejected changes: the time, the profile ID, the change, the version, the field when the reason concerns one, and the problem. It never shows the content. The same change rejected again for the same reason is listed once, with the time it last came. Requests the platform cannot read at all (a body that is not JSON, a version out of range, an external_id that breaks its rule) get 400, 415 or 422 with the type about:blank, and the console does not list them. Your logs are the place to catch those.
6. A batch
For many changes at once, such as your first import or a nightly job, send up to 100 upserts and deletes in one request (Batch). Never send one request per profile when you have many, and never a sync request per page view: sync when a profile changes on your side.
POST /v1/profiles/batch HTTP/1.1
Authorization: Bearer sk_test_...
Content-Type: application/json
{
"total": 3,
"items": [
{
"action": "upsert",
"external_id": "example-hugo",
"version": 105,
"owner": "owner-hugo",
"fields": {
"name": { "value": "Hugo, physiotherapy", "visibility": "public" },
"assistant_name": { "value": "Hugo", "visibility": "public" },
"headline": { "value": "Sports injuries and back pain, Campo de Ourique", "visibility": "public" },
"prices": { "value": "First visit 50 EUR, follow-up 40 EUR", "visibility": "ai_only" }
}
},
{
"action": "upsert",
"external_id": "example-sofia",
"version": 105,
"owner": "owner-sofia",
"fields": {
"name": { "value": "Sofia, hair studio", "visibility": "public" },
"assistant_name": { "value": "Sofia", "visibility": "public" },
"headline": { "value": "Cuts and colour, Graça", "visibility": "public" },
"opening_hours": { "value": "Tuesday to Saturday, 10:00 to 19:00", "visibility": "ai_only" }
}
}
]
}
{
"results": [
{ "outcome": "applied", "external_id": "example-hugo", "version": 105 },
{ "outcome": "applied", "external_id": "example-sofia", "version": 105 }
]
}
- Each item is decided on its own, as if you had sent it alone, and gets one result, in the same order. A rejected item comes back as
{ "outcome": "rejected", "external_id": ..., "reason": ..., "field": ... }and stops no other item. - The batch is saved as one: the changes it applies take effect together, or none does. After 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. - If the platform cannot read an item (an unknown
action, anexternal_idthat breaks its rule, a missingversion), the whole batch gets 422 and none of it applies. totalis optional: how many profiles your site lists in all. The console shows "imported of total" from the last batch that sent it. Send it during an import and when it changes.- A request body holds at most 1 MiB, and a site may send about 10 requests a second (Limits). At that pace, batches of 100 import 100,000 profiles in under two minutes.
7. A change of owner
Hugo's practice now answers through Joana. Send Hugo's profile with a higher version and the new owner:
{ "version": 106, "owner": "owner-joana", "fields": { ... } }
{ "external_id": "example-hugo", "outcome": "applied", "version": 106 }
That upsert is the whole change. A profile's conversations belong to the profile, not to its owner: Joana sees its past conversations in her inbox, and the previous owner loses them at once, even with the inbox open (The owner). Joana starts without the previous owner's instructions for the AI assistant.
8. The daily reconciliation
Requests can get lost, even with retries. Once a day, compare what the platform holds with your own list, and send again what differs (Reconciliation).
GET /v1/profiles lists every profile the platform holds for the site, deleted ones included, up to 1,000 a page. Pass a page's next as after to get the next page. The last page has no next.
GET /v1/profiles?limit=1000 HTTP/1.1
Authorization: Bearer sk_test_...
{
"profiles": [
{ "external_id": "example-hugo", "version": 106, "deleted": false },
{ "external_id": "example-lena", "version": 103, "deleted": false },
{ "external_id": "example-sofia", "version": 105, "deleted": false }
]
}
Your database may sort ids in another order than the platform does, so compare the two lists as sets, by external id:
- the platform holds a lower version than yours, deleted or not, or does not list the profile: send it again;
- the platform holds a profile, not deleted, that your site no longer lists: send a delete with a higher version.
Here Sofia's opening hours changed on your side at version 107, but that request was lost. The reconciliation finds example-sofia at 105 and sends version 107 in a batch. profile-sync.ts reads every page for you (listProfiles) and compares the lists (reconcile).
9. Keeping the AI assistant answering
A price or an opening time can change on your site without the platform hearing of it. So the AI assistant answers for a site's profiles only while your backend has been in touch: a request with the site's secret key must have arrived within the last 7 days (Keeping the copy fresh). The platform's operator may set another period for its instance.
- These count once the platform has read them, whatever it decides (applied, unchanged, stale or rejected): an upsert, a delete, a batch, and a reconciliation read (a page of
GET /v1/profilesanswered 200). - These do not: reading one profile's copy, and any request the platform could not read or did not take (one answered 400, 401, 413, 415, 429 or 5xx, or 422 with the
typeabout:blank).
When the period passes with no request that counts, the AI assistant stops answering for all of the site's profiles. New messages wait for the owners, and visitors are told 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.
So run the daily reconciliation even when nothing changed: the read alone keeps the copy fresh. The partner console's Sync status card shows until when the AI assistant answers, and warns a day before (guide 6).
10. A delete
When a profile leaves your site, delete it with a version higher than its last upsert. The version goes in the query, because some HTTP clients and proxies drop the body of a DELETE (Deletes).
DELETE /v1/profiles/example-sofia?version=108 HTTP/1.1
Authorization: Bearer sk_test_...
{ "external_id": "example-sofia", "outcome": "applied", "version": 108 }
The platform drops the fields and the owner at once, and keeps only the external id, the version and the time of the delete, so an older upsert arriving late cannot bring the profile back. The messenger then says the profile is not available. Its copy now reads "deleted": true with no fields. To bring a deleted profile back, send an upsert with a version higher than the delete's.
Retries, in short
Keep each change in a queue on your side until the platform answers 200, and retry only a 429, a 5xx or no answer (Ordering, retries and idempotency). After a 429, wait the seconds Retry-After gives. After a 5xx or no answer at all, wait longer each time: 1, 2, 4 seconds and so on, with some randomness, up to about 30 seconds. Either way, send the same version and content again. Any other answer comes back the same until you fix the request: a 401 means the secret key does not work, and a 422 with a /problems/... type is a rejection (step 5). Errors lists every status.
profile-sync.ts does exactly this in send(), with its own 15-second timeout on each request.
What comes later
Webhooks, such as needs_owner when a conversation needs the profile owner, come in a later release. So do paid messages. Neither has a shape you can build against yet.
Next: the messenger on your profile pages, which shows the profiles you just sent.