{
  "components": {
    "schemas": {
      "BatchItem": {
        "description": "One item of a batch, by its `action`: an `upsert`, which is the body of a `PUT` plus the\nprofile's `external_id`, or a `delete`, with `external_id` and `version`.",
        "oneOf": [
          {
            "allOf": [
              {
                "$ref": "#/components/schemas/ProfileUpsert",
                "description": "The upsert's `version`, `owner` and `fields`, as `PUT /v1/profiles/{external_id}`\ntakes them."
              },
              {
                "properties": {
                  "external_id": {
                    "description": "The site's own id for the profile, with the same rule as in the path of a `PUT`: 1\nto 128 ASCII letters, digits, `-`, `_` and `.`, starting with a letter or a digit,\nnever `batch`.",
                    "maxLength": 128,
                    "minLength": 1,
                    "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
                    "type": "string"
                  }
                },
                "required": [
                  "external_id"
                ],
                "type": "object"
              },
              {
                "description": "An upsert: the body of `PUT /v1/profiles/{external_id}`, plus the profile's\n`external_id`.",
                "properties": {
                  "action": {
                    "enum": [
                      "upsert"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "action"
                ],
                "type": "object"
              }
            ],
            "description": "An upsert: the body of `PUT /v1/profiles/{external_id}`, plus the profile's\n`external_id`."
          },
          {
            "description": "A delete, as `DELETE /v1/profiles/{external_id}` makes it.",
            "properties": {
              "action": {
                "enum": [
                  "delete"
                ],
                "type": "string"
              },
              "external_id": {
                "description": "The site's own id for the profile, with the same rule as in the path of a `DELETE`:\n1 to 128 ASCII letters, digits, `-`, `_` and `.`, starting with a letter or a digit,\nnever `batch`.",
                "maxLength": 128,
                "minLength": 1,
                "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
                "type": "string"
              },
              "version": {
                "description": "The delete's version, above the profile's last upsert: 1 to 9,007,199,254,740,991.",
                "format": "int64",
                "maximum": 9007199254740991,
                "minimum": 1,
                "type": "integer"
              }
            },
            "required": [
              "external_id",
              "version",
              "action"
            ],
            "type": "object"
          }
        ]
      },
      "BatchItemResult": {
        "description": "What one item of a batch did, by its `outcome`: `applied`, `unchanged` or `stale`, as `PUT`\nand `DELETE` answer them, or `rejected`, with the same reason a `PUT` or a `DELETE` would\nget.",
        "oneOf": [
          {
            "description": "Its version was above the one held, or none was: it replaced the copy.",
            "properties": {
              "external_id": {
                "description": "The profile, by the site's id for it.",
                "type": "string"
              },
              "outcome": {
                "enum": [
                  "applied"
                ],
                "type": "string"
              },
              "version": {
                "description": "The version the platform holds now: the item's.",
                "format": "int64",
                "maximum": 9007199254740991,
                "minimum": 1,
                "type": "integer"
              }
            },
            "required": [
              "external_id",
              "version",
              "outcome"
            ],
            "type": "object"
          },
          {
            "description": "The same version and content as the copy held: a retry, which changed nothing.",
            "properties": {
              "external_id": {
                "description": "The profile, by the site's id for it.",
                "type": "string"
              },
              "outcome": {
                "enum": [
                  "unchanged"
                ],
                "type": "string"
              },
              "version": {
                "description": "The version the platform holds now: the item's.",
                "format": "int64",
                "maximum": 9007199254740991,
                "minimum": 1,
                "type": "integer"
              }
            },
            "required": [
              "external_id",
              "version",
              "outcome"
            ],
            "type": "object"
          },
          {
            "description": "A lower version than the one held: a newer change arrived first, and this one changed\nnothing.",
            "properties": {
              "external_id": {
                "description": "The profile, by the site's id for it.",
                "type": "string"
              },
              "outcome": {
                "enum": [
                  "stale"
                ],
                "type": "string"
              },
              "version": {
                "description": "The version the platform holds now, higher than the item's.",
                "format": "int64",
                "maximum": 9007199254740991,
                "minimum": 1,
                "type": "integer"
              }
            },
            "required": [
              "external_id",
              "version",
              "outcome"
            ],
            "type": "object"
          },
          {
            "description": "Rejected, and listed in the partner console for 30 days. The item changed nothing; fix it\nbefore sending it again.",
            "properties": {
              "external_id": {
                "description": "The profile, by the site's id for it.",
                "type": "string"
              },
              "field": {
                "description": "The first field, in the order of the field names, that broke a rule, when the reason\nconcerns one field whose name is valid. Absent otherwise.",
                "pattern": "^[a-z][a-z0-9_]{0,63}$",
                "type": "string"
              },
              "outcome": {
                "enum": [
                  "rejected"
                ],
                "type": "string"
              },
              "reason": {
                "$ref": "#/components/schemas/RejectionReason",
                "description": "Why the item was rejected."
              }
            },
            "required": [
              "external_id",
              "reason",
              "outcome"
            ],
            "type": "object"
          }
        ]
      },
      "FieldVisibility": {
        "description": "The visibility flags a site may send with a field. `public`: kept, read by the AI assistant,\nand shown on the widget's profile card for `name`, `headline` and `assistant_name`.\n`ai_only`: kept, and read only by the AI assistant, which may repeat it to visitors.\n`private`: only the field's name is kept; the value is dropped on arrival and nothing reads\nit. Any other value, or none, is private.",
        "enum": [
          "public",
          "ai_only",
          "private"
        ],
        "type": "string"
      },
      "KeptField": {
        "description": "A field whose value the platform keeps: a public or an AI-only one.",
        "properties": {
          "value": {
            "description": "The value, as the site sent it.",
            "type": "string"
          },
          "visibility": {
            "$ref": "#/components/schemas/KeptVisibility",
            "description": "Who may see it: `public` or `ai_only`."
          }
        },
        "required": [
          "value",
          "visibility"
        ],
        "type": "object"
      },
      "KeptVisibility": {
        "description": "The visibility of a field whose value the platform keeps. `public`: the AI assistant reads\nit, and the widget's profile card shows it for `name`, `headline` and `assistant_name`.\n`ai_only`: only the AI assistant reads it. A private value is never kept.",
        "enum": [
          "public",
          "ai_only"
        ],
        "type": "string"
      },
      "Problem": {
        "description": "An error, as an RFC 9457 problem document.",
        "properties": {
          "status": {
            "description": "The HTTP status code.",
            "example": 503,
            "format": "int32",
            "minimum": 0,
            "type": "integer"
          },
          "title": {
            "description": "The standard phrase for the status code.",
            "example": "Service Unavailable",
            "type": "string"
          },
          "type": {
            "description": "Always `about:blank`: the status code says what went wrong.",
            "example": "about:blank",
            "type": "string"
          }
        },
        "required": [
          "type",
          "title",
          "status"
        ],
        "type": "object"
      },
      "ProfileBatch": {
        "description": "Up to 100 upserts and deletes of the site's profiles, each decided on its own: a rejected\nitem stops no other. The batch is saved as one: its changes take effect together, or none\ndoes.",
        "properties": {
          "items": {
            "description": "The upserts and deletes, 1 to 100, each with its `action`. Items that name the same\nprofile apply in the order sent. When one cannot be read (an unknown `action`, an\n`external_id` that breaks its rule or is `batch`, a `version` missing or out of range),\nthe whole batch gets 422 and none of it applies.",
            "items": {
              "$ref": "#/components/schemas/BatchItem"
            },
            "maxItems": 100,
            "minItems": 1,
            "type": "array"
          },
          "total": {
            "description": "Optional: how many profiles the site lists in all, from 0 to 9,007,199,254,740,991. The\npartner console shows \"imported of total\" from the last batch that sent it, with its\ntime. Send it during an initial import and whenever it changes.",
            "format": "int64",
            "maximum": 9007199254740991,
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "items"
        ],
        "type": "object"
      },
      "ProfileBatchResults": {
        "description": "What each item of a batch did.",
        "properties": {
          "results": {
            "description": "One result for each item, in the items' order.",
            "items": {
              "$ref": "#/components/schemas/BatchItemResult"
            },
            "type": "array"
          }
        },
        "required": [
          "results"
        ],
        "type": "object"
      },
      "ProfileCopy": {
        "description": "The platform's copy of one profile, for the site to check what arrived. It never holds a\nprivate value, which the platform drops on arrival, nor the owner, of whom the\nplatform keeps only a fingerprint.",
        "properties": {
          "deleted": {
            "description": "Whether that change was a delete. A deleted profile has no fields: `fields`,\n`private_fields` and `unflagged_fields` are empty.",
            "type": "boolean"
          },
          "external_id": {
            "description": "The profile, by the site's id for it.",
            "type": "string"
          },
          "fields": {
            "additionalProperties": {
              "$ref": "#/components/schemas/KeptField"
            },
            "description": "The public and AI-only fields, from their name to the field, with their values as sent:\nwhat the AI assistant reads.",
            "propertyNames": {
              "type": "string"
            },
            "type": "object"
          },
          "private_fields": {
            "description": "The names of the fields sent as `private`. Their values were dropped on arrival.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "synced_at": {
            "description": "When that upsert or delete arrived: an RFC 3339 date-time in UTC, which may carry a\nfraction of a second.",
            "format": "date-time",
            "type": "string"
          },
          "unflagged_fields": {
            "description": "The names of the fields sent with no visibility, with `null`, or with one the platform\ndoes not recognise. They are private too, and their values were dropped on arrival.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "version": {
            "description": "The version of the last upsert or delete the platform applied.",
            "format": "int64",
            "maximum": 9007199254740991,
            "minimum": 1,
            "type": "integer"
          }
        },
        "required": [
          "external_id",
          "version",
          "synced_at",
          "deleted",
          "fields",
          "private_fields",
          "unflagged_fields"
        ],
        "type": "object"
      },
      "ProfileUpsert": {
        "description": "A profile as the site sends it: its version, its owner and its fields. It replaces the\nplatform's copy when its version is above the one held, a deleted profile's included\n(contracts/profile-sync.md, \"Upsert\").",
        "properties": {
          "fields": {
            "additionalProperties": {
              "$ref": "#/components/schemas/SentField"
            },
            "description": "The profile's fields, from a field name to the field: 1 to 100 of them, `name` among\nthem. A field name matches `^[a-z][a-z0-9_]{0,63}$`. The public and AI-only values hold\nat most 16,000 characters in all; private values do not count, since the platform drops\nthem. Breaking a rule rejects the upsert as `invalid-field` or `too-large`\n(`RejectionReason`). A missing or `null` `fields` has no `name`, so it is rejected as\n`invalid-field`. `SentField` says what each visibility keeps and feeds.",
            "propertyNames": {
              "type": "string"
            },
            "type": "object"
          },
          "owner": {
            "description": "The profile owner, who answers for the profile in the inbox: the same user ID the site\nputs in `sub` when it signs that person in (contracts/user-token.md). 1 to 128\ncharacters. Never an email address, a phone number or a name. The platform keeps only a\nfingerprint of it, made with the site, and never returns it. An applied upsert with\nanother owner gives the profile and its past conversations to the new owner, and the\nprevious owner loses access to them at once. Missing, `null`, empty or too long: the\nupsert is rejected as `owner-missing`.",
            "maxLength": 128,
            "minLength": 1,
            "type": "string"
          },
          "version": {
            "description": "The profile's version, which the site chooses: a whole number from 1 to\n9,007,199,254,740,991, the largest whole number JavaScript reads exactly. It grows with\nevery change to the profile, deletes included, such as a counter or the time of the change\nin milliseconds. The platform applies a change only when its version is above the one it\nholds. The same version again with the same content is a retry (`unchanged`); with other\ncontent it is rejected as `version-reused`. A version that is missing, not a whole number\nor out of range gets 422 (`about:blank`) and is not listed in the console.",
            "format": "int64",
            "maximum": 9007199254740991,
            "minimum": 1,
            "type": "integer"
          }
        },
        "required": [
          "version",
          "owner",
          "fields"
        ],
        "type": "object"
      },
      "ProfileVersion": {
        "description": "A profile the platform holds and the version of its last change, so the site can send again\nwhat differs.",
        "properties": {
          "deleted": {
            "description": "Whether that change was a delete.",
            "type": "boolean"
          },
          "external_id": {
            "description": "The profile, by the site's id for it.",
            "type": "string"
          },
          "version": {
            "description": "The version of the last upsert or delete the platform applied.",
            "format": "int64",
            "maximum": 9007199254740991,
            "minimum": 1,
            "type": "integer"
          }
        },
        "required": [
          "external_id",
          "version",
          "deleted"
        ],
        "type": "object"
      },
      "ProfileVersionPage": {
        "description": "One page of the profiles the platform holds for the site, deleted ones included, in byte\norder of `external_id`.",
        "properties": {
          "next": {
            "description": "The `after` for the next page: the last external id on this one. Absent on the last\npage.",
            "type": "string"
          },
          "profiles": {
            "description": "The profiles on this page, at most `limit`.",
            "items": {
              "$ref": "#/components/schemas/ProfileVersion"
            },
            "type": "array"
          }
        },
        "required": [
          "profiles"
        ],
        "type": "object"
      },
      "RejectedProblem": {
        "description": "A 422 on a route that upserts or deletes a profile: a rejection, whose `type` names the\nreason and which the partner console lists for 30 days, or a request the platform could not\nread (`about:blank`, a plain problem), which the console does not list. Either way nothing\napplied, and the same request gets the same answer until it is fixed.",
        "properties": {
          "field": {
            "description": "The first field that broke a rule, when the reason concerns one field whose name is\nvalid. Absent otherwise.",
            "pattern": "^[a-z][a-z0-9_]{0,63}$",
            "type": "string"
          },
          "status": {
            "description": "Always 422.",
            "example": 422,
            "format": "int32",
            "minimum": 0,
            "type": "integer"
          },
          "title": {
            "description": "What the type means, in a few words.",
            "example": "Version reused",
            "type": "string"
          },
          "type": {
            "description": "`about:blank` when the body could not be read, such as a version out of range, and nothing is listed in the console; otherwise the reason for the rejection, which the console lists for 30 days: `/problems/owner-missing` when the upsert has no usable `owner`; `/problems/invalid-field` when 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; `/problems/version-reused` when the version is the one held, with other content; `/problems/too-large` when there are more than 100 fields, a value is longer than 4,000 characters or the public and AI-only values hold more than 16,000 characters. Compare it as written.",
            "enum": [
              "about:blank",
              "/problems/owner-missing",
              "/problems/invalid-field",
              "/problems/version-reused",
              "/problems/too-large"
            ],
            "type": "string"
          }
        },
        "required": [
          "type",
          "title",
          "status"
        ],
        "type": "object"
      },
      "RejectionReason": {
        "description": "Why the platform rejected an upsert or a delete. The partner console lists each rejection\nfor 30 days, with the field when there is one, never the content\n(contracts/profile-sync.md, \"Rejections\"). `owner-missing`: the upsert has no `owner`, or it\nis empty or longer than 128 characters. `invalid-field`: a field name breaks its pattern, a\nvalue is empty or holds U+0000, there is no `name` field, or `assistant_name` is longer than\n60 characters, has a line break or holds an invisible formatting character. `too-large`:\nmore than 100 fields, a value longer than 4,000 characters, or more than 16,000 characters of\npublic and AI-only values in all. `version-reused`: the version is the one held, with other\ncontent; give every change a new version.",
        "enum": [
          "owner-missing",
          "invalid-field",
          "version-reused",
          "too-large"
        ],
        "type": "string"
      },
      "SentField": {
        "description": "One field of a profile, as the site sends it: its value, and who may see it.",
        "properties": {
          "value": {
            "description": "The field's text: 1 to 4,000 characters, without U+0000. Lengths count characters\n(Unicode code points), not bytes. The rule holds for private values too, though the\nplatform drops them on arrival and never stores, shows or gives them to the AI assistant.\n`assistant_name` has a stricter rule, whatever its visibility: 1 to 60 characters on one\nline, without invisible formatting characters. Sent as public, it names the AI assistant\nin the label on its replies, such as \"Anna's AI assistant\". Without it, the label uses a\npublic `name` that keeps the same rule; with neither, the profile gets no AI replies\n(contracts/profile-sync.md, \"The name in the AI label\").",
            "maxLength": 4000,
            "minLength": 1,
            "type": "string"
          },
          "visibility": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/FieldVisibility",
                "description": "Who may see the value, and so what it feeds. `public`: the platform keeps it, the AI\nassistant reads it, and visitors may see it. The widget's profile card shows `name`,\n`headline` and `assistant_name` when they are public, and nothing else from the profile.\n`ai_only`: the platform keeps it, and only the AI assistant reads it, to answer visitors.\nThe AI assistant may repeat it to them, so put nothing there that a visitor must not\nread. `private`: the platform keeps the field's name only and drops the value on arrival,\nso nothing reads it. Left out, `null`, or a value the platform does not recognise, such\nas `\"Public\"` or `\"hidden\"`: private, and the partner console counts the field as sent\nwithout a flag."
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "value"
        ],
        "type": "object"
      },
      "SyncOutcome": {
        "description": "What an upsert or a delete the platform accepted did. `applied`: its version was above the\none held, or none was, and it replaced the copy. `unchanged`: the same version and the same\ncontent as the copy held, so it was a retry and changed nothing. `stale`: a lower version than\nthe one held, so a newer change arrived first and this one changed nothing. Neither\n`unchanged` nor `stale` is a failure, and neither needs anything sent again.",
        "enum": [
          "applied",
          "unchanged",
          "stale"
        ],
        "type": "string"
      },
      "SyncResult": {
        "description": "What an upsert or a delete did (contracts/profile-sync.md, \"Versions\"). A rejection is a 422\nproblem instead, whose `type` names the reason.",
        "properties": {
          "external_id": {
            "description": "The profile, by the site's id for it, as in the path.",
            "type": "string"
          },
          "outcome": {
            "$ref": "#/components/schemas/SyncOutcome",
            "description": "What happened: `applied`, `unchanged` or `stale`."
          },
          "version": {
            "description": "The version the platform holds now: the one sent when `applied` or `unchanged`, a higher\none when `stale`.",
            "format": "int64",
            "maximum": 9007199254740991,
            "minimum": 1,
            "type": "integer"
          }
        },
        "required": [
          "external_id",
          "outcome",
          "version"
        ],
        "type": "object"
      }
    },
    "securitySchemes": {
      "secret_key": {
        "description": "A site's secret key, `sk_...`, which the partner console makes and rotates under Keys and shows in full once. Send it with every request as `Authorization: Bearer sk_...`, over HTTPS, from the partner's servers only: never from a browser or an app, and never into a log. A key reaches only its own site's profiles, so a profile on another site is not found, even when the partner owns that site too. After a rotation, the replaced key keeps working for 24 hours, unless the console ends it sooner. A missing key, or one that does not work, gets 401 (contracts/profile-sync.md, \"Authentication\").",
        "scheme": "bearer",
        "type": "http"
      }
    }
  },
  "info": {
    "description": "The routes your backend calls with a site's secret key to keep the platform's copy of your profiles current: upserts, deletes, batches, and the list to reconcile. server-api.md, beside this file, is the same reference to read, and contracts/profile-sync.md gives the rules behind it.",
    "title": "Messenger Platform API",
    "version": "0.1.0"
  },
  "openapi": "3.1.0",
  "paths": {
    "/v1/profiles": {
      "get": {
        "description": "Lists every profile the platform holds for the site, deleted ones included, with the version\nof its last upsert or delete. Use it to reconcile, regularly, such as once a day, and never\nonce per page view: compare the list with the site's own, by external id, and send again what\ndiffers. Upsert each profile the platform does not list or holds at a lower version than the\nsite's. Delete, with a higher version, each profile the platform holds that the site no\nlonger lists. Send those changes in batches (contracts/profile-sync.md, \"Reconciliation\").\n\nA page holds up to 1,000 profiles, in byte order of `external_id` as ASCII sorts it: `-` and\n`.`, then digits, then `A` to `Z`, then `_`, then `a` to `z`. Pass a page's `next` as `after`\nto get the next page; the last page has no `next`. The site's database may sort the same ids\nin another order, so compare the two lists as sets rather than side by side.\n\nReconcile even when nothing changed. The AI assistant answers from the site's profiles only\nwhile a counted request arrived within the last 7 days (or another period the instance's\noperator set), and each page answered 200 counts (contracts/profile-sync.md, \"Keeping the\ncopy fresh\").\n\nSend the site's secret key as `Authorization: Bearer sk_...`.",
        "operationId": "list_synced_profiles",
        "parameters": [
          {
            "description": "The previous page's `next`: the last external id on it. Leave it out for the first page.\nText that is not an external id gets 400.",
            "in": "query",
            "name": "after",
            "required": false,
            "schema": {
              "maxLength": 128,
              "minLength": 1,
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
              "type": "string"
            }
          },
          {
            "description": "The most profiles the page may hold, from 1 to 1,000; 1,000 when left out. A limit\noutside that range gets 400.",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "format": "int32",
              "maximum": 1000,
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "example": {
                  "next": "marco",
                  "profiles": [
                    {
                      "deleted": false,
                      "external_id": "anna",
                      "version": 42
                    },
                    {
                      "deleted": true,
                      "external_id": "ines",
                      "version": 17
                    },
                    {
                      "deleted": false,
                      "external_id": "marco",
                      "version": 7
                    }
                  ]
                },
                "schema": {
                  "$ref": "#/components/schemas/ProfileVersionPage"
                }
              }
            },
            "description": "One page of profiles and their versions."
          },
          "400": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The query cannot be read: an `after` that is no external id, or a `limit` that is not a whole number from 1 to 1,000."
          },
          "401": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The secret key is missing or does not work, such as an unknown key, one ended in the partner console, or one a rotation replaced more than 24 hours ago."
          },
          "429": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "Too many requests from this network address, or for this site: a site may send about 10 a second, with all its keys together. Ask for the same page again after `Retry-After` seconds.",
            "headers": {
              "Retry-After": {
                "description": "How many seconds to wait before sending the request again: a whole number, at least 1.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "A fault on the platform's side. Ask for the same page again later."
          },
          "503": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform could not answer, for example because the request took longer than 10 seconds or the instance was switching to another server. Ask for the same page again later.",
            "headers": {
              "Retry-After": {
                "description": "Sent only while the instance is switching to another server: how many seconds to wait before sending the request again.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "secret_key": []
          }
        ],
        "summary": "Lists the profiles the platform holds.",
        "tags": [
          "profiles"
        ]
      }
    },
    "/v1/profiles/batch": {
      "post": {
        "description": "Sends up to 100 upserts and deletes in one request. Use it for bulk changes, such as an\ninitial import or what a reconciliation found, rather than one call per profile. A site may\nsend about 10 requests a second, so 10 batches of 100 a second sync 100,000 profiles in under\ntwo minutes.\n\nEach item is decided on its own, as if sent alone with `PUT` or `DELETE`, and gets one result,\nin the items' order. A rejected item does not stop the others or undo them, and items that\nname the same profile apply in the order sent. The batch is saved as one: the changes it\napplies take effect together, or none of them does.\n\nSafe to repeat: after a timeout or a 5xx, send the whole batch again. Usually none of it\napplied. If it was saved just before the failure, the items it applied come back `unchanged`,\nand the others as they did before. No idempotency key is needed.\n\nSend `total`, how many profiles the site lists in all, during an initial import and whenever\nit changes: the partner console shows \"imported of total\". A batch answered 200 also keeps the\nsite's profiles fresh for the AI assistant (contracts/profile-sync.md, \"Keeping the copy\nfresh\").\n\nSend the site's secret key as `Authorization: Bearer sk_...`.",
        "operationId": "sync_profile_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "example": {
                "items": [
                  {
                    "action": "upsert",
                    "external_id": "anna",
                    "fields": {
                      "assistant_name": {
                        "value": "Anna",
                        "visibility": "public"
                      },
                      "headline": {
                        "value": "Classic and sports massage, Baixa",
                        "visibility": "public"
                      },
                      "name": {
                        "value": "Anna, massage",
                        "visibility": "public"
                      }
                    },
                    "owner": "sg-owner-anna",
                    "version": 42
                  },
                  {
                    "action": "upsert",
                    "external_id": "marco",
                    "fields": {
                      "headline": {
                        "value": "Thai massage, Estrela",
                        "visibility": "public"
                      },
                      "name": {
                        "value": "Marco Ferreira",
                        "visibility": "public"
                      },
                      "services": {
                        "value": "Thai massage on a floor mat, 60 or 90 minutes.",
                        "visibility": "ai_only"
                      }
                    },
                    "owner": "sg-owner-marco",
                    "version": 7
                  },
                  {
                    "action": "delete",
                    "external_id": "ines",
                    "version": 17
                  }
                ],
                "total": 48
              },
              "schema": {
                "$ref": "#/components/schemas/ProfileBatch"
              }
            }
          },
          "description": "The items, and optionally the site's total.",
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "example": {
                  "results": [
                    {
                      "external_id": "anna",
                      "outcome": "applied",
                      "version": 42
                    },
                    {
                      "external_id": "marco",
                      "outcome": "unchanged",
                      "version": 7
                    },
                    {
                      "external_id": "ines",
                      "outcome": "rejected",
                      "reason": "version-reused"
                    }
                  ]
                },
                "schema": {
                  "$ref": "#/components/schemas/ProfileBatchResults"
                }
              }
            },
            "description": "What each item did, one result for each item in the items' order."
          },
          "400": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The request cannot be read, such as a body that is not valid JSON. None of the batch applies."
          },
          "401": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The secret key is missing or does not work, such as an unknown key, one ended in the partner console, or one a rotation replaced more than 24 hours ago. None of the batch applies."
          },
          "413": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The body is larger than 1 MiB (1,048,576 bytes). None of the batch applies: send fewer items in each batch."
          },
          "415": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The body was not sent as JSON. Send it with `Content-Type: application/json`. None of the batch applies."
          },
          "422": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The batch cannot be read (`about:blank`): no items or more than 100, an item with an unknown `action`, an `external_id` that breaks its rule or is `batch`, a `version` missing or out of range, a field of the wrong type, or a `total` above 9,007,199,254,740,991. None of the batch applies, and the console lists nothing: fix the batch before sending it again."
          },
          "429": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "Too many requests from this network address, or for this site: a site may send about 10 a second, with all its keys together. None of the batch applies. Send the same batch again after `Retry-After` seconds.",
            "headers": {
              "Retry-After": {
                "description": "How many seconds to wait before sending the request again: a whole number, at least 1.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "A fault on the platform's side. Send the whole batch again later: usually none of it applied, and the versions make the repeat safe."
          },
          "503": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform could not finish the request, for example because another request was writing one of the same profiles, the request took longer than 10 seconds, or the instance was switching to another server. Send the whole batch again later: usually none of it applied, and the versions make the repeat safe.",
            "headers": {
              "Retry-After": {
                "description": "Sent only while the instance is switching to another server: how many seconds to wait before sending the request again.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "secret_key": []
          }
        ],
        "summary": "Upserts and deletes profiles in a batch.",
        "tags": [
          "profiles"
        ]
      }
    },
    "/v1/profiles/{external_id}": {
      "delete": {
        "description": "Erases the platform's copy of one of the site's profiles. Send it when a profile leaves the\nsite, with a version above the profile's last upsert, since a delete is a change too. For many\ndeletes at once, send them in batches with `POST /v1/profiles/batch`.\n\nWhen the version is above the one held, the profile's fields and owner go at once. The\nplatform keeps only the external id, the version and the time of the delete, so that an older\nupsert arriving late cannot bring the profile back. A delete of a profile the platform never\nhad keeps that record too. To bring a deleted profile back, upsert it with a version above\nthe delete's.\n\nThe answer is 200 with the same outcome an upsert gets. Safe to repeat: a retry carries the\nsame version, so it cannot apply twice, and no idempotency key is needed. A delete answered\n200 or rejected also keeps the site's profiles fresh for the AI assistant\n(contracts/profile-sync.md, \"Keeping the copy fresh\").\n\nSend the site's secret key as `Authorization: Bearer sk_...`.",
        "operationId": "delete_profile",
        "parameters": [
          {
            "description": "The site's own id for the profile, such as `anna`: 1 to 128 ASCII letters, digits, `-`,\n`_` and `.`, starting with a letter or a digit. Case matters. Never `batch`, which the\nbatch route's path takes.",
            "in": "path",
            "name": "external_id",
            "required": true,
            "schema": {
              "maxLength": 128,
              "minLength": 1,
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
              "type": "string"
            }
          },
          {
            "description": "The delete's version, above the profile's last upsert, since a delete is a change too: 1\nto 9,007,199,254,740,991. It goes in the query because some HTTP clients and proxies drop\nthe body of a DELETE. A request without a version, or with text that is not a whole\nnumber, gets 400. A version outside that range gets 400 or 422, so send only versions\nwithin it.",
            "in": "query",
            "name": "version",
            "required": true,
            "schema": {
              "format": "int64",
              "maximum": 9007199254740991,
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "example": {
                  "external_id": "anna",
                  "outcome": "applied",
                  "version": 43
                },
                "schema": {
                  "$ref": "#/components/schemas/SyncResult"
                }
              }
            },
            "description": "What the delete did: `applied`, `unchanged` or `stale`, with the version the platform now holds."
          },
          "400": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The request cannot be read, such as a query with no `version` or one that is not readable as a whole number. Nothing applied."
          },
          "401": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The secret key is missing or does not work, such as an unknown key, one ended in the partner console, or one a rotation replaced more than 24 hours ago. Nothing applied."
          },
          "422": {
            "content": {
              "application/problem+json": {
                "example": {
                  "status": 422,
                  "title": "Version reused",
                  "type": "/problems/version-reused"
                },
                "schema": {
                  "$ref": "#/components/schemas/RejectedProblem"
                }
              }
            },
            "description": "Rejected as `/problems/version-reused`: the platform already holds an upsert with this version. The partner console lists it for 30 days. Or the request cannot be read (`about:blank`, not listed): a `version` out of range, such as 0, or an `external_id` in the path that breaks its rule. Nothing applied either way: fix the request before sending it again."
          },
          "429": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "Too many requests from this network address, or for this site: a site may send about 10 a second, with all its keys together. Nothing applied. Send the same request again after `Retry-After` seconds.",
            "headers": {
              "Retry-After": {
                "description": "How many seconds to wait before sending the request again: a whole number, at least 1.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "A fault on the platform's side. The delete may or may not have applied: send the same request again later, which the version makes safe."
          },
          "503": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform could not finish the request, for example because another request was writing the same profile, the request took longer than 10 seconds, or the instance was switching to another server. Usually nothing applied. Send the same request again later, which the version makes safe.",
            "headers": {
              "Retry-After": {
                "description": "Sent only while the instance is switching to another server: how many seconds to wait before sending the request again.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "secret_key": []
          }
        ],
        "summary": "Deletes a profile.",
        "tags": [
          "profiles"
        ]
      },
      "get": {
        "description": "Shows what the platform holds for one of the site's profiles, so the site can check what\narrived: the version, when it arrived, the public and AI-only fields with their values, and\nthe names of the private ones. Never a private value, and never the owner. It is for\ndebugging: a sync never needs it, and reading it does not keep the site's profiles fresh for\nthe AI assistant.\n\nA deleted profile shows `deleted: true` and no fields. A profile the platform never had for\nthe key's site gets 404.\n\nSend the site's secret key as `Authorization: Bearer sk_...`.",
        "operationId": "show_synced_profile",
        "parameters": [
          {
            "description": "The site's own id for the profile, such as `anna`: 1 to 128 ASCII letters, digits, `-`,\n`_` and `.`, starting with a letter or a digit. Case matters. Never `batch`, which the\nbatch route's path takes.",
            "in": "path",
            "name": "external_id",
            "required": true,
            "schema": {
              "maxLength": 128,
              "minLength": 1,
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "example": {
                  "deleted": false,
                  "external_id": "anna",
                  "fields": {
                    "assistant_name": {
                      "value": "Anna",
                      "visibility": "public"
                    },
                    "headline": {
                      "value": "Classic and sports massage, Baixa",
                      "visibility": "public"
                    },
                    "name": {
                      "value": "Anna, massage",
                      "visibility": "public"
                    },
                    "prices": {
                      "value": "Classic massage 60 minutes: 45 EUR. Sports massage 90 minutes: 60 EUR.",
                      "visibility": "ai_only"
                    },
                    "services": {
                      "value": "Classic massage, 60 minutes. Sports massage, 90 minutes.",
                      "visibility": "ai_only"
                    }
                  },
                  "private_fields": [
                    "phone"
                  ],
                  "synced_at": "2026-10-12T09:20:00Z",
                  "unflagged_fields": [
                    "years_active"
                  ],
                  "version": 42
                },
                "schema": {
                  "$ref": "#/components/schemas/ProfileCopy"
                }
              }
            },
            "description": "The platform's copy of the profile."
          },
          "400": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The path cannot be read, such as an `external_id` that is not valid UTF-8 once percent-decoded."
          },
          "401": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The secret key is missing or does not work, such as an unknown key, one ended in the partner console, or one a rotation replaced more than 24 hours ago."
          },
          "404": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform never had a profile with this id for the key's site. A profile on another site is not found either, even when the partner owns that site too. A deleted profile is found, with `deleted: true`."
          },
          "429": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "Too many requests from this network address, or for this site: a site may send about 10 a second, with all its keys together. Ask again after `Retry-After` seconds.",
            "headers": {
              "Retry-After": {
                "description": "How many seconds to wait before sending the request again: a whole number, at least 1.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "A fault on the platform's side. Ask again later."
          },
          "503": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform could not answer, for example because the request took longer than 10 seconds or the instance was switching to another server. Ask again later.",
            "headers": {
              "Retry-After": {
                "description": "Sent only while the instance is switching to another server: how many seconds to wait before sending the request again.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "secret_key": []
          }
        ],
        "summary": "Shows the platform's copy of a profile.",
        "tags": [
          "profiles"
        ]
      },
      "put": {
        "description": "Creates or replaces the platform's copy of one of the site's profiles. Send it each time a\nprofile is created or changes on the site, with a version above the last one sent for that\nprofile. For many profiles at once, such as an initial import, send them in batches of up to\n100 with `POST /v1/profiles/batch` rather than one call each.\n\nThe platform applies the upsert only when its version is above the one it holds, a deleted\nprofile's included. It answers 200 with the outcome: `applied`, `unchanged` (the same version\nand content again: a retry) or `stale` (a newer change arrived first). Neither `unchanged` nor\n`stale` is a failure. Private values are dropped on arrival, and only their field names are\nkept.\n\nSafe to repeat: a retry carries the same version and content, so it cannot apply twice, and\nin any order the highest version wins. No idempotency key is needed. Keep each change queued\nuntil it gets a 200, and send it again after a 429 or a 5xx; any other error comes back the\nsame until the request is fixed (contracts/profile-sync.md, \"Ordering, retries and\nidempotency\").\n\nA rejection is a 422 whose `type` names the reason, and the partner console lists it for 30\ndays. An upsert answered 200 or rejected also keeps the site's profiles fresh for the AI\nassistant (contracts/profile-sync.md, \"Keeping the copy fresh\").\n\nSend the site's secret key as `Authorization: Bearer sk_...`.",
        "operationId": "upsert_profile",
        "parameters": [
          {
            "description": "The site's own id for the profile, such as `anna`: 1 to 128 ASCII letters, digits, `-`,\n`_` and `.`, starting with a letter or a digit. Case matters. Never `batch`, which the\nbatch route's path takes.",
            "in": "path",
            "name": "external_id",
            "required": true,
            "schema": {
              "maxLength": 128,
              "minLength": 1,
              "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "example": {
                "fields": {
                  "assistant_name": {
                    "value": "Anna",
                    "visibility": "public"
                  },
                  "headline": {
                    "value": "Classic and sports massage, Baixa",
                    "visibility": "public"
                  },
                  "name": {
                    "value": "Anna, massage",
                    "visibility": "public"
                  },
                  "phone": {
                    "value": "+351 000 000 201",
                    "visibility": "private"
                  },
                  "prices": {
                    "value": "Classic massage 60 minutes: 45 EUR. Sports massage 90 minutes: 60 EUR.",
                    "visibility": "ai_only"
                  },
                  "services": {
                    "value": "Classic massage, 60 minutes. Sports massage, 90 minutes.",
                    "visibility": "ai_only"
                  },
                  "years_active": {
                    "value": "8"
                  }
                },
                "owner": "sg-owner-anna",
                "version": 42
              },
              "schema": {
                "$ref": "#/components/schemas/ProfileUpsert"
              }
            }
          },
          "description": "The whole profile: its version, its owner and every field the platform should have. Fields left out are gone from the copy once the upsert applies.",
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "example": {
                  "external_id": "anna",
                  "outcome": "applied",
                  "version": 42
                },
                "schema": {
                  "$ref": "#/components/schemas/SyncResult"
                }
              }
            },
            "description": "What the upsert did: `applied`, `unchanged` or `stale`, with the version the platform now holds."
          },
          "400": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The request cannot be read, such as a body that is not valid JSON. Nothing applied."
          },
          "401": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The secret key is missing or does not work, such as an unknown key, one ended in the partner console, or one a rotation replaced more than 24 hours ago. Nothing applied."
          },
          "413": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The body is larger than 1 MiB (1,048,576 bytes). Nothing applied."
          },
          "415": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The body was not sent as JSON. Send it with `Content-Type: application/json`. Nothing applied."
          },
          "422": {
            "content": {
              "application/problem+json": {
                "example": {
                  "field": "assistant_name",
                  "status": 422,
                  "title": "Invalid field",
                  "type": "/problems/invalid-field"
                },
                "schema": {
                  "$ref": "#/components/schemas/RejectedProblem"
                }
              }
            },
            "description": "Rejected, with the reason as the `type`: `/problems/owner-missing`, `/problems/invalid-field`, `/problems/too-large` or `/problems/version-reused`, and `field` when the reason concerns one field whose name is valid. The partner console lists it for 30 days. Or the request cannot be read (`about:blank`, not listed): a `version` that is missing, not a whole number or out of range, an `owner`, `fields` or value of the wrong type, a field without `value`, or an `external_id` in the path that breaks its rule. Nothing applied either way: fix the request before sending it again."
          },
          "429": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "Too many requests from this network address, or for this site: a site may send about 10 a second, with all its keys together. Nothing applied. Send the same request again after `Retry-After` seconds.",
            "headers": {
              "Retry-After": {
                "description": "How many seconds to wait before sending the request again: a whole number, at least 1.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "A fault on the platform's side. The upsert may or may not have applied: send the same request again later, which the version makes safe."
          },
          "503": {
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            },
            "description": "The platform could not finish the request, for example because another request was writing the same profile, the request took longer than 10 seconds, or the instance was switching to another server. Usually nothing applied. Send the same request again later, which the version makes safe.",
            "headers": {
              "Retry-After": {
                "description": "Sent only while the instance is switching to another server: how many seconds to wait before sending the request again.",
                "schema": {
                  "format": "int64",
                  "minimum": 0,
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [
          {
            "secret_key": []
          }
        ],
        "summary": "Upserts a profile.",
        "tags": [
          "profiles"
        ]
      }
    }
  },
  "tags": [
    {
      "description": "One site's profiles, which the partner's backend sends with the site's secret key: an upsert on every change, a delete when a profile goes, batches for bulk changes, and a paged list to reconcile, such as once a day. The rules are in contracts/profile-sync.md.",
      "name": "profiles"
    }
  ]
}
