Vendor guide
Last updated: 2026-09-21
You have built your app. This guide covers everything you need to do to publish it on this marketplace, in order: becoming a vendor, verifying your domain, writing the manifest, going through review, running the embedded session, shipping version updates after launch, and your responsibilities for user data.
The guide is long. Read it once from start to finish, then come back to the section you need. Every item here is backed either by a numbered review guideline or by a check we actually run at runtime, so a line you skip comes back to you as a rejected submission.
Three documents, three jobs
| Document | What it tells you |
|---|---|
| Vendor agreement | What you commit to. This is the binding text. |
| Review guidelines | The numbered policy. Every negative decision cites at least one number. |
| This guide | How to meet those commitments. |
If they conflict, the agreement and the guidelines are binding and this guide only explains. On the technical side, the code is the reference: every number, rule and claim name here was taken from the code the platform actually runs, not the other way around.
If you need to reach a person
Write to support@kuratowski.com. A person reads that address. For a specific submission the console thread is faster and keeps the history in one place (section 7), but anything that does not fit a submission belongs in an email: a question about these rules, a mistake you found in this guide, a decision you cannot make sense of.
0. The path at a glance
| # | Step | Where | What you have when it is done |
|---|---|---|---|
| 1 | Vendor application | /vendor/apply | Your profile is approved and you can submit |
| 2 | Domain verification | /vendor/domains | You can use that host in your manifest |
| 3 | Prepare the app | Your own infrastructure | Panel and endpoints are up, tokens are verified |
| 4 | Register the app | /vendor/apps/new | The slug is reserved for you and the app screen opens |
| 5 | Manifest | The app's Versions tab | Your declaration matches the schema |
| 6 | Review information | The app's Versions tab | The reviewer can get into your app |
| 7 | Submission | The app's Versions tab | The submission enters the queue |
| 8 | Review | On our side | Approval, a change request or a rejection |
| 9 | Store page | The app's Store page tab | The app is live in the store |
Steps 1 and 2 happen once, step 4 once per app. Steps 6 to 8 repeat for every new version; your review information stays saved, and you can update it before each version if you want.
Steps 3 and 4 have no section of their own, so the headings below jump from step 2 to step 5. Each of them is finished in one place. Step 3, prepare the app: the requirements your panel must meet and token verification are in section 9, ports and the two ends of a connection are in section 10. Step 4, register the app: you create the record on the `/vendor/apps/new` screen and choose the slug there, and that value is the string you expect as aud in section 9.
1. Four things to know before you start
1.1 Your code does not come to us
Your app runs on your infrastructure. The platform does not host, run or build your code. The only thing you send us is a declaration (manifest.json), and it carries only the addresses of your HTTPS endpoints, your ports and your store details. You cannot send us code, scripts or packages to run (guideline 1.3).
This has two consequences for you. First, your source code stays with you. Second, your app's uptime, speed, backups and security are your responsibility. Passing review is not a guarantee that your app is free of bugs.
There is a third consequence, and it is binding: without a server, you cannot be at either end of a connection. Being a source means sending signed requests to our address, and being a target means keeping your MCP endpoint open; both need a running server. A listing that offers only an embedded panel is legitimate (see 1.2.1), but it stays outside the flow.
In the other direction, we do not ask you for a secret either. The platform signs the identity token with its own private key and you verify it with the public key (section 9).
1.2 App or extension
The manifest's first decision is listingKind, and it has no default. The test is one sentence you can try out:
Is it useful when it has no connections at all?
- `app`: installs and works on its own. Inventory software keeps stock without connecting to anything.
- `extension`: has no reason to exist without an app's data. A product that pulls common questions out of chatbot conversations is an empty screen without the chatbot.
This is an experiment, not an opinion: the reviewer runs the same experiment by installing and opening your app. If your declaration does not match reality, changes are requested on the submission (guideline 2.6). We never change your declaration ourselves; we ask you to fix it.
1.2.1 You do not have to open connections
Declaring ports is not a requirement, it is your decision. Your product may have no data worth sharing; or it may, and you may not want to open it. Both are legitimate and neither affects your submission.
In return we ask for one thing: the user has to get something. Your listing needs at least one surface, either an embedded panel URL (embeddablePanelUrl) or at least one port. You do not need both, but you cannot have neither; with neither, a user who installs your app finds nothing. If you have no ports, leave the transport field out entirely.
The one exception is an extension. Choosing extension is also a choice, and making it means you accept at least one input port: something that does not consume an app's data is not an extension (guideline 2.7). If you want to stay unconnected, choose app.
If you choose extension, two extra rules apply:
- You must have at least one input port. Something that consumes nothing is attached to nothing. You do not need a panel; extensions whose effect shows up entirely inside the app they attach to are legitimate.
- An extension cannot connect to another extension (guideline 2.7). There is always an app above an extension. You can attach to more than one app, but you cannot build a chain. If you need to chain two features, either combine them into one extension or apply as an app.
If the user does not have an app the extension can attach to, the install is refused and the response says which type of data the extension needs. In other words, your port types are not just a label; they decide whether your product can be installed at all.
1.3 The user never leaves the site
This is the most visible difference from the App Store: users do not download your app to a device, they open it inside the platform. Your app is embedded in a cross-origin <iframe> and the user does not sign in a second time.
In practice this means we ask you for an embeddable panel URL (embeddablePanelUrl) and for headers on that URL that allow framing; the details are in section 9. The panel URL is not required on its own (some products show their effect only through connections), but an app without a panel has no surface the user can see; if it has no ports either, your submission is not accepted (see 1.2.1).
1.4 The free period
There is no payment flow on the platform today. That has three consequences:
- An app whose
pricingfield is notfreecannot be listed or installed (guideline 3.2). - Taking payment by sending the user off the platform is not accepted during this period either.
- You are not charged a commission or a listing fee.
When pricing arrives, the terms will be announced before they take effect.
A related rule that is often misunderstood: no purchase redirects outside the store (guideline 3.5). Your vendor website address is shown on your app page and is not removed; users should be able to see the company behind the app. But your store copy, your screenshots and your in-app screens cannot invite the user to buy the same app outside the platform, to sign up for it there or to install it from there. The address you give is for information and support, not a second sales channel.
2. Step 1: vendor application
The application form has three fields: your display name (2-120 characters), your contact email and, optionally, your website address starting with https://.
You cannot submit a manifest until your application is approved. The statuses:
| Status | Meaning |
|---|---|
pending | In the queue, waiting for a decision |
approved | You can submit |
rejected | Rejected |
suspended | Your apps stay in the catalog, but you cannot make new submissions |
Sending the form again does not reset a decision that has been made: you update your details, you do not re-enter the queue.
The name shown in the store is your approved name from here. The manifest does not ask for a vendor name: your identity comes from the account you submit with, and you do not declare it separately.
3. Step 2: domain verification
A manifest is a declaration, and it is technically possible to put someone else's address in it. This is the gate that closes that gap: you prove ownership of every host that appears in your manifest.
Which addresses count:
transport.endpointembeddablePanelUrl(if present)
Your address must answer within 5 seconds. The number is not arbitrary: when a user opens your panel inside the marketplace, the same address has to answer just as fast, so an address that cannot meet this limit would not work for users even if it passed verification. The most common cause is a free hosting tier: it sleeps when idle and takes up to 30 seconds to wake, so every check we make fails and your verification expires on its own. If your server is up and your certificate is valid but you see "unreachable", look here first.
This step needs your address to be up. We do not verify on our side, we read from your server: if the file is not being served over https at that moment, verification fails. Your whole app does not need to be ready, a single static file is enough; but the domain and the certificate must work before this step. Even if you leave step 3 of the table (prepare the app) until after this one, bring the address up first.
How to prove it:
- Add the host on the My addresses page. You get a value specific to that host.
- Publish that value, on its own, at
https://<host>/.well-known/kuratowski-vendor-challenge. - Press the verify button on the page.
Limits on the request, and why:
- `https` only. The address is called from our server.
- Redirects are not followed. If they were, an address that passed the gate could jump to a second address that never did.
- At most 512 bytes of the body are read, and it must carry a single value. A trailing newline is fine, it is trimmed; do not put any other text around the value.
- IP addresses are not accepted. Nobody can prove they own an IP: IPs are leased and reassigned. Private network ranges are blocked as well.
- Verification attempts are limited to 30 per day. Publishing the file and trying once is enough.
Claiming a host does not unlock it, verifying does. At submission time every host in the manifest must be verified; otherwise the submission comes back with unverified_host and never enters the review queue.
For the same reason, someone else claiming your domain does not lock you out: two vendors can claim the same host and each gets their own value, but only the one who can actually publish the file gets verified.
Leave the file in place: we keep reading it after verification. If you are changing addresses, verify the new host first, then submit the new version.
A verification is valid for 90 days. A proof cannot be valid forever: domains change hands, expire, pass to someone else. If the record said "verified" while the address no longer belonged to you, it would send users to a page they believe the marketplace has vouched for. The figure is not made up: in the certificate world, the period during which a past domain validation may be reused has been shrinking for years, and today's upper limit is under 200 days.
What keeps the verification alive is not something you do, it is us reading the file. We check your address regularly, and if the file is in place your timestamp moves forward to today on its own. A failed check does NOT touch your record: nothing is revoked because your server was down for ten minutes, the timestamp just keeps getting older.
On day 60 we give you a NEW value and you need to update the file once. The reason fits in one sentence: if the value in the file were permanent, someone who read it once could keep publishing it after taking over the address and keep the timestamp fresh forever. Rotating the value closes that door. The new value is shown in your vendor console and in the email we send you; you have 30 days to publish it, and we remind you 30, 7 and 1 days before the deadline. This rhythm is not made up either: it is exactly how the certificate world works, the only difference being that there a program writes the file.
An expired address blocks submissions but does not delete your record: update the file, verify, and continue where you left off.
If you want no manual work: the endpoint method
Instead of a static file you can serve an endpoint at the same path. We then add ?nonce=<value> to our request and expect the base64url form of HMAC-SHA256(secret, nonce); the secret is shown in your vendor console. Because the nonce is new on every request, there is no fixed value that could be copied: this method has no 60-day rotation and never creates manual work.
Which one you choose is up to you, and both are equally valid. The file method also works on static hosting; the endpoint method requires you to run code on that origin, which you already do for the event signing in section 10. You can switch methods in your vendor console; the moment you switch, the timestamp is cleared and you need to verify once with the new method.
If your proof goes stale, your live app does not stop, but our endorsement does. The app is no longer recommended in the store and the user's screen says the address is no longer verified; installed users keep using it, because their data is with you. There is one exception: if the address answers with content OTHER than what we expect, which suggests it may have changed hands, we do not open the panel at all.
4. Step 5: manifest
The manifest is your app's technical contract. You write it in the Versions tab of your app screen: there is a form you can fill in field by field and a second mode where you write JSON directly, and both produce the same schema. The app screen only opens after you have created your app record (step 4).
4.1 Fields and their pitfalls
| Field | Rule | Common mistake |
|---|---|---|
schemaVersion | Always the string "1", not a number | Writing 1 without quotes |
listingKind | app or extension, no default | Choosing by marketing language (see 1.2) |
slug | a-z0-9-, 3-64 characters, starts with a lowercase letter | Never changes after approval (guideline 2.3) |
name | 1-120 characters | |
version | Semver (X.Y.Z) | Not bumping the version number (section 11) |
category | The most specific leaf category | Writing a group name, or choosing a side feature instead of the main job |
description | 1-4000 characters | |
embeddablePanelUrl | Optional, https:// | Unverified host |
transport | An object with a single shape: {"kind": "mcp", "endpoint": "https://..."}. https only. If you have no ports, leave this field out entirely; if you have ports, it is required | Writing the key as type (measured: two apps were rejected for exactly this) |
inputs / outputs | At most 50 each. You can leave them empty; then embeddablePanelUrl becomes required | Missing sensitivity tag, sensitive port without a reason |
pricing | An object: {"model": "free"} | Writing just "free" |
The list is closed: 16 groups, 139 leaves. The group is derived from the leaf you choose; the category field always takes a leaf, and writing a group name makes the submission invalid. The full list is below.
| Group | Category leaves you can use |
|---|---|
sales-crm | crm, contacts-directory, sales-engagement, revenue-intelligence, proposals-quotes, partner-affiliate |
marketing | email-marketing, social-media-management, seo-content-marketing, advertising, marketing-automation, events-webinars, forms-surveys, brand-asset-management |
commerce | online-store, marketplace-selling, point-of-sale, subscription-billing, payments, shipping-fulfillment, loyalty-promotions |
finance | accounting-bookkeeping, invoicing-receivables, expense-management, payroll, tax-compliance, financial-planning, banking-treasury, personal-finance, crypto-digital-assets, lending-credit |
operations | inventory-management, supply-chain-logistics, fleet-field-service, procurement, manufacturing-production, erp, quality-compliance-ops, facilities-maintenance |
people | hr-information-system, recruiting-ats, performance-engagement, workforce-scheduling, benefits-compensation, global-employment |
support | help-desk, live-chat, knowledge-base, contact-center, customer-success, community-forums |
data | business-intelligence, product-analytics, web-marketing-analytics, data-warehouse, data-integration, data-governance, machine-learning-platform, databases |
productivity | documents-writing, spreadsheets, presentations, notes-wiki, project-management, whiteboard-diagramming, file-storage, email-client, calendar-booking, video-meetings, team-chat, workflow-automation, ai-assistants, translation-localization |
creative | image-editing, vector-illustration, ui-ux-design, graphic-design, publishing-layout, three-d-animation, video-editing, audio-music, photography-workflow, game-development, cad-technical-design, generative-ai-media |
engineering | source-control, code-editors, ci-cd, hosting-deployment, api-tools, monitoring-observability, incident-management, infrastructure-cloud, app-builders, website-builders, cms-platforms, communication-apis, ai-developer-tools, it-service-management, device-management |
security | identity-access, password-secrets, endpoint-protection, network-web-security, application-security, grc-compliance, privacy-data-protection, backup-recovery, security-training |
learning | lms-course-delivery, student-information, classroom-engagement, language-learning, course-marketplace, tutoring-assessment |
health | clinical-ehr, practice-management, telehealth, mental-health, fitness-wellness, life-sciences |
industry | legal-practice, contract-management, real-estate-property, construction-project, architecture-engineering, hospitality-hotels, restaurants-food, agriculture, energy-utilities, insurance, automotive, transportation-travel, nonprofit-fundraising, government-public, professional-services, media-entertainment, research-science |
other | other |
The category describes what job you do, not how you do it. Using AI does not decide your category. If two buckets both fit, choose your product's main job: accounting software that also issues invoices is not invoicing-receivables because of it. If there is no match for your product in the list at all, that is our gap: tell us and we will add it.
4.2 Ports
The fields of a port are below. description is required, and the reason is simple: there is no other way to tell two ports of the same type apart. When text/plain appears twice, only that sentence says which one is which.
| Field | Required | What it does |
|---|---|---|
id | Yes | The port ID; format below. It is used when a connection is made and in the MCP tool name. |
type | Yes | A type from the taxonomy. This decides matching. |
label | Yes | The name the user sees on screen. |
description | Yes, 1-1000 characters | The sentence that tells ports of the same type apart. |
required | No, defaults to true | Only meaningful on INPUT ports: whether this port must be filled for the connection to be made. Ignored on output ports; writing it there changes nothing. If you leave it out, true is assumed. |
sensitivityTags | No, defaults to empty | The risk category of the data (see 4.3). Leaving it empty and writing ["public"] are NOT THE SAME. |
sensitivityReason | YES if the tag is not public | The reason for a sensitive tag. A sensitive port without a reason is rejected (guideline 1.2). |
shape | Yes on record-carrying OUTPUT ports and on every business/* port | The port's fields, each field's class and an optional human-readable name (see 4.4, 4.6 and 4.6.1). Record-carrying types: structured/json, tabular/rows, tabular/csv, event/webhook-payload and every business/* type. |
The port ID format is enforced at schema level as well: it starts with a lowercase letter, followed by a-z, 0-9, underscore and hyphen, at most 64 characters. Uppercase letters, spaces, dots and non-ASCII letters are not allowed.
add-row ✔
write_cell ✔
AddRow ✘ uppercase letter
café-row ✘ non-ASCII letter
2-rows ✘ starts with a digitThe type list is closed (26 types) and enforced at schema level: if you write a type that is not on the list, the submission fails validation. For data with no exact match, choose the closest type and state the difference clearly in the description field. But you can only put that difference in `description`, not in your schema: the incoming value for the type you choose (the table below) must pass your schema, so you cannot add required fields because you "actually expect these fields".
| Type | What it carries | Personal data |
|---|---|---|
text/plain | Plain text | No |
text/markdown | Markdown | No |
text/html | HTML | No |
structured/json | JSON object | No |
tabular/csv | CSV table | No |
tabular/rows | Row list | No |
media/image | Image | No |
media/audio | Audio | No |
media/file | File | No |
event/webhook-payload | Event notification | No |
identifier/email | Email address | Yes |
identifier/phone | Phone number | Yes |
identifier/customer-id | Customer ID | Yes |
identifier/url | URL | No |
numeric/integer | Integer | No |
numeric/decimal | Decimal | No |
temporal/timestamp | Timestamp | No |
temporal/date | Date | No |
business/party | Party | Yes |
business/product | Product | No |
business/stock-level | Stock level | No |
business/stock-movement | Stock movement | No |
business/sale-document | Sale document | Yes |
business/payment | Payment | Yes |
business/appointment | Appointment | Yes |
business/availability | Availability slot | No |
The last column is not filler: declaring a port that carries personal data without saying so falls under review guideline 1.2 and is rejected.
There is one transport: `mcp`. At runtime the routing service speaks only MCP, so the contract has no other transport. rest used to be selectable, but nothing ever ran the layer that translated it into MCP; rather than leave a path you could choose that carried no data, we removed it.
If you already have REST endpoints you do not have to throw them away. Declaring mcp means putting a thin gateway in front of your own endpoints, and you do NOT need to install any package from us for that gateway: MCP is an open protocol with an official SDK in every major language. The contract we expect from you has two parts.
- An MCP server listens at the
transport.endpointaddress using the Streamable HTTP transport. - For each input port you expose a tool named
input_<portId>. For example, the tool for theadd-rowport isinput_add-row.
The call shape is fixed too: the routing service calls the tool with the argument { "value": <data> }. Success means the response is not flagged as an error; if you want to return something, put it in structuredContent.data. So the whole gateway is a few dozen lines that pass the incoming value to your own REST route.
Exactly what arrives in `value` is defined by the type, not left to interpretation. The table below shows the canonical value we send for each type. These rows were not written by hand: they are the very value the platform actually sends.
| Your input port's type | What arrives as value |
|---|---|
text/plain | "Kuratowski connection test (kuratowski-test)" |
text/markdown | "# Kuratowski\n\nConnection test (kuratowski-test)." |
text/html | "<p>Kuratowski connection test (kuratowski-test).</p>" |
structured/json | {"source":"kuratowski-test","id":"SYN-TEST-1","createdAt":"2026-01-05T09:00:00.000Z"} |
tabular/csv | "id,name,amount\nSYN-TEST-1,Example Row,10.00" |
tabular/rows | [{"id":"SYN-TEST-1","name":"Example Row","amount":10}] |
media/image | "https://example.com/kuratowski-test.png" |
media/audio | "https://example.com/kuratowski-test.mp3" |
media/file | "https://example.com/kuratowski-test.pdf" |
event/webhook-payload | {"event":"kuratowski-test.ping","occurredAt":"2026-01-05T09:00:00.000Z","data":{"id":"SYN-TEST-1"}} |
identifier/email | "kuratowski-test@example.com" |
identifier/phone | "+15550100" |
identifier/customer-id | "SYN-TEST-1" |
identifier/url | "https://example.com/kuratowski-test" |
numeric/integer | 1 |
numeric/decimal | 1.5 |
temporal/timestamp | "2026-01-05T09:00:00.000Z" |
temporal/date | "2026-01-05" |
business/party | [{"_private":false,"party_id":"SYN-TEST-1","display_name":"Example Customer","party_kind":"customer"}] |
business/product | [{"_private":false,"product_id":"SYN-TEST-1","name":"Example Product","unit":"piece"}] |
business/stock-level | [{"_private":false,"product_id":"SYN-TEST-1","quantity":10,"unit":"piece","as_of":"2026-01-05T09:00:00.000Z"}] |
business/stock-movement | [{"_private":false,"movement_id":"SYN-TEST-1","product_id":"SYN-TEST-1","quantity":5,"unit":"piece","occurred_at":"2026-01-05T09:00:00.000Z"}] |
business/sale-document | [{"_private":false,"document_id":"SYN-TEST-1","document_kind":"sale","issued_at":"2026-01-05T09:00:00.000Z","total":120,"currency":"TRY","lines":[{"product_id":"SYN-TEST-1","quantity":2,"unit_price":60}]}] |
business/payment | [{"_private":false,"payment_id":"SYN-TEST-1","direction":"in","amount":120,"currency":"TRY","occurred_at":"2026-01-05T09:00:00.000Z"}] |
business/appointment | [{"_private":false,"appointment_id":"SYN-TEST-1","starts_at":"2026-01-05T09:00:00.000Z","ends_at":"2026-01-05T09:30:00.000Z","status":"booked"}] |
business/availability | [{"_private":false,"starts_at":"2026-01-05T09:00:00.000Z","ends_at":"2026-01-05T09:30:00.000Z","state":"free"}] |
The most common mistake is with tabular/rows: because it is called a "row list", expecting a grid made of cell arrays ([["Name","Amount"]]). On our side a row is an object and the column names are its keys. A tool that expects a grid rejects every event that arrives after the connection is made.
What you do with the value AFTER you receive it is your product decision, and that is where the platform's contract ends. Writing a header row when you lay a record array out as a grid, the order you put the columns in, leaving a field empty when a record does not have it, how you show a nested field: we cannot give the right answer to any of these, because the answer depends on what your product is. The only thing we promise you is the shape of the value on the wire; how you show it to your user is your business. Only guideline 1.9 stays binding: incoming writes are checked as records, and a rejected record does not fail the whole batch.
This is checked, and if you fail it you do not go live. During review we read the inputSchema in your tools/list response and test the value above against it; if it does not accept the value, your submission is rejected and the reason says which port expects what. To try it yourself: find your port's type in this table, put that value inside { "value": … } and send it to your own tool.
This schema check does NOT call your tool. The gate runs initialize and tools/list, reads the inputSchema you declare and tests the value above against it; it does not send tools/call. We say this explicitly because assuming otherwise leads to two mistakes at once: preparing for an unauthenticated check call (there is no such call) and thinking that the 10.6 rule "do not write on an unauthenticated call, return a tool error" will fail you here (it will not). The one real call made during review is authenticated and is described in 10.6 (guideline 2.10).
If you declare an OUTPUT port, you need one more tool: `kuratowski_subscription`. When a user makes or removes a connection we tell you through that tool; without it you never learn who to send events for. The tool lives on the same MCP endpoint, its argument is empty ({}) and its contents are described in section 10.
This is also checked at submission, looked up in the same tools/list response: a submission with an output port and no such tool is not approved. It is a separate gate from the input port check; even if your input ports are perfect, you stop here if this is missing.
Your schema MUST ACCEPT this value, which means you cannot make a field required if the incoming value does not have it. You feel this most with structured/json: the sender sends its own object (section 10), and there is no reason for that object to carry your field names. That is why an input port cannot expect a command: if you make target and address fields required for a job like "update this cell in that table", the port cannot pass the check. You cannot expect a connection to carry that kind of job; a connection brings you a VALUE, not an order. If you want to offer your own users a command interface, the place for it is your own panel, not a port you declare.
Two traps come out of this, and both are silent. Do not close your input schema with `additionalProperties: false`, and do not make a field `required` if it does not arrive. First: the canonical value we send during the check always carries the _private field, yet you cannot declare that field in shape (names starting with an underscore are reserved for the platform), and on types other than business/party the field never reaches you on delivery. So a field you cannot declare shows up in the check and disappears in production. Second: on the business/party type, display_name is REQUIRED in the dictionary, but a flagged party record reaches you with only party_id and _private; it has no name, because the whole point of the flag is that the name does not travel. If you make display_name required in your own schema, every flagged delivery to you breaks.
There is a limit at the other end too: a schema that restricts nothing counts as "could not verify" and the decision goes to the reviewer. The test is narrow and fits in one sentence: if the schema body is completely empty ({}, or anything that means "accept everything"), nothing has been proven. Declaring a type ({"type": "object"}) is enough to clear this bar; you do not need to add required fields out of caution, and if you do you will run into the rule above.
Port IDs (id) are not just a technical detail: the connections users make are written against those IDs. Changing a port's ID after launch is the same as deleting that port. The consequences are in section 11.
4.3 Sensitivity tags
The type tells you the format of the data, the sensitivity tag tells you its risk. Two ports can have the same type (both media/image) while one is a marketing image and the other is a photo of a face.
There are six tags: biometric, health, financial-account, customer-pii, location, public.
- If in doubt, tag it. A missing tag means that data can flow to any port, skipping the platform's isolation protection entirely.
- `biometric` and `health` are strict: a port with one of these tags only matches a port with the same tag. There is no exception and it cannot be changed.
- We state a known tension openly: fewer tags means compatibility with more apps. That creates a real incentive to under-tag. Review questions exactly this, and the vendor is responsible for a false declaration (guidelines 1.1 and 1.2).
Carrying a field you did not declare is also forbidden: your app may only send and accept the fields declared in its manifest. A connection that carries an undeclared field goes beyond what the user agreed to.
Leaving the tags empty and writing `["public"]` are not the same thing, and the difference shows up in matching. An empty list means "no declaration" and every match is allowed. ["public"], on the other hand, is a declaration: when your port is the TARGET of a connection and the other side carries customer-pii, financial-account or location, that connection goes to review, because personal data would be flowing into a port in the public category. So writing public is the more cautious choice, not decoration. Write it if you can say your data is truly public; if you are not sure, leave it empty.
If you add a tag, you also write its reason. A port's sensitivityReason field is required on every port that carries a tag other than public, and if it is empty the submission comes back from the schema without entering the review queue (guideline 1.2). The reverse is an error too, and here `public` counts as no tag: if you write a reason on a port that has no tags or only ["public"], the submission comes back. The reason is that an orphaned reason is the trace of either a forgotten tag or a sentence put in the wrong box.
"sensitivityTags": ["customer-pii"],
"sensitivityReason": "A movement can carry a party ID in the optional party_id field;
even though the ID is opaque, it points to a person. Name, email, phone and address are not carried."Do not put the reason in the description field, it has its own box. The two answer different questions: description answers which port is this (required to tell two ports of the same type apart), the reason answers why is this tag here. If they share one box, the reviewer cannot see whether the reason was written, and the guideline cannot be checked.
4.4 Shared business objects: we own the dictionary, you own the detail
Most of the types above describe the format of the data: a row list, plain text, a number. Format is not enough for two apps to understand each other. An inventory app's product list and a customer app's contact list are both row lists; the engine considers them compatible and offers the user to pour products into customer records.
That is why we own the name and meaning of shared business objects: party, product, stock level, stock movement, sale document, payment, appointment, availability slot. If you carry one of these, you choose the business/… type and the field names come from the dictionary below.
- You can split more finely. If you expose your appointments on two ports, say times and people, instead of one, the side receiving your data asks only for what it needs. That is good, and it shows on your store page.
- You cannot give less. Required fields must be present with the same name and the same type; schema validation enforces this at submission time.
- Extra fields are allowed. You add fields that are not in the dictionary and write their classes yourself. If your product records have a
shelf_code, put it in the schema. - You cannot rename what is shared.
party_id, notclient_id. If everyone uses their own name, an extension that receives your data has to support three different spellings; nobody writes that.
The value is always an array of records, even when you send a single record: [{…}]. Sending an object for one record and an array for several means branching and silent failures at both ends.
The gate is honesty, granularity is a matter of degree: what your port returns must be what you declared. An app that declares a single broad port is accepted; an app that leaks a field it did not declare is not.
Party (business/party). A customer or supplier record (a person or a company). Customer and supplier are not separate TYPES; they are a field on the same record.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
party_id | string | Yes | operational | The ID that uniquely identifies the party in your system. |
display_name | string | Yes | personal | The name shown on screen: a person's name or a company's legal name. |
party_kind | string | Yes | operational | The party's direction relative to the business. Values: customer, supplier, both |
is_company | boolean | no | operational | Whether this is a company or a natural person. |
email | string | no | personal | Email address. |
phone | string | no | personal | Phone number. |
tax_id | string | no | personal | Tax number or national ID number. For a natural person this is a direct identifier, so its class is personal. |
address | string | no | personal | Address. |
merged_from | json | no | operational | Array of OLD party IDs merged into this record. When two records for the same person are merged, the ID that disappears is announced here; the receiver uses it to fix the old party_id it holds. |
note | string | no | free-text | Free-text note about the party. Does not travel by default. |
Product (business/product). The definition of a product or service you sell or stock: code, name, unit, sale price.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
product_id | string | Yes | operational | The ID that uniquely identifies the product in your system. |
name | string | Yes | public | Product name. |
unit | string | Yes | public | Unit of measure: piece, kg, hour, pack… |
sku | string | no | operational | The business's own product code. |
barcode | string | no | operational | Barcode. |
sale_price | number | no | public | Sale price (whether it includes tax is stated in price_includes_tax). |
purchase_price | number | no | internal | Purchase price or cost. INTERNAL: does not travel over connections unless the user explicitly allows it. |
currency | string | no | public | Currency of the prices, ISO 4217 (TRY, USD, EUR). |
price_includes_tax | boolean | no | operational | Whether the price includes tax. |
is_active | boolean | no | operational | Whether the product is still on sale. |
description | string | no | free-text | Free-text product description. Does not travel by default. |
Stock level (business/stock-level). The quantity of a product on hand at a given moment.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
product_id | string | Yes | operational | Which product (the same ID as in business/product). |
quantity | number | Yes | operational | Quantity on hand at that moment. |
unit | string | Yes | operational | Unit of the quantity. |
as_of | timestamp | Yes | operational | The moment this level applies to. A level without a timestamp is meaningless: the receiver cannot tell which one is newer. |
location | string | no | operational | Warehouse or branch name. |
Stock movement (business/stock-movement). A single event that increases or decreases stock: receipt, issue, count adjustment.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
movement_id | string | Yes | operational | Unique ID of the movement. The receiver uses it to drop duplicates. |
product_id | string | Yes | operational | Which product. |
quantity | number | Yes | operational | SIGNED quantity: positive for receipts, negative for issues. There is NO separate direction field, because two fields can contradict each other. |
unit | string | Yes | operational | Unit of the quantity. |
occurred_at | timestamp | Yes | operational | The moment the movement happened. |
reason | string | no | operational | Reason for the movement. Values: sale, return, purchase, count, waste, transfer, other |
reference | string | no | operational | ID of the related document (for example a sale document). |
note | string | no | free-text | Free-text note. Does not travel by default. |
Sale document (business/sale-document). A quote, sale or return document, with its lines and total. It is NOT an e-invoice filed with a tax authority.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
document_id | string | Yes | operational | Unique ID of the document. |
document_kind | string | Yes | operational | Type of document. Values: quote, sale, return |
issued_at | timestamp | Yes | operational | The moment the document was issued. |
total | number | Yes | operational | Document total. |
currency | string | Yes | operational | Currency, ISO 4217. |
lines | json | Yes | operational | Lines: an array where each item carries at least product_id, quantity and unit_price. |
party_id | string | no | personal | The party the document belongs to. Even as an opaque ID it points to a person, so its class is personal. |
party_name | string | no | personal | Display name of the party. |
status | string | no | operational | Status of the document. Values: draft, open, paid, partial, cancelled |
note | string | no | free-text | Free-text note. Does not travel by default. |
Payment (business/payment). A single amount entering or leaving a till or bank account: a collection or a payment.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
payment_id | string | Yes | operational | Unique ID of the payment. |
direction | string | Yes | operational | Direction of the money: in or out. Values: in, out |
amount | number | Yes | operational | Amount; always POSITIVE, direction gives the direction. |
currency | string | Yes | operational | Currency, ISO 4217. |
occurred_at | timestamp | Yes | operational | The moment the money moved. |
account | string | no | operational | Which till or bank account. |
category | string | no | operational | Income or expense category. |
party_id | string | no | personal | The counterparty (party). |
document_id | string | no | operational | ID of the sale document it settles. Many-to-many matching lives on this field's side. |
note | string | no | free-text | Free-text note. Does not travel by default. |
Appointment (business/appointment). Work to be done with a person within a given time range.
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
appointment_id | string | Yes | operational | Unique ID of the appointment. |
starts_at | timestamp | Yes | operational | Start time. |
ends_at | timestamp | Yes | operational | End time. |
status | string | Yes | operational | Status of the appointment. Values: booked, done, cancelled, no_show |
party_id | string | no | personal | The person the appointment is with (party ID). |
party_name | string | no | personal | Display name of the person. |
phone | string | no | personal | The person's phone number. |
service | string | no | public | Name of the work or service to be done. |
note | string | no | free-text | Free-text note. Does not travel by default. |
Availability slot (business/availability). A time range that carries ONLY occupancy: who it belongs to and what it is are NOT included. This is the second tier for public surfaces (booking page, chatbot).
| Field | Type | Required | Class | What it is |
|---|---|---|---|---|
starts_at | timestamp | Yes | public | Start of the range. |
ends_at | timestamp | Yes | public | End of the range. |
state | string | Yes | public | State of the range; occupancy ONLY. Values: free, busy |
4.5 The record's private flag
Every record that leaves a business object port carries the _private field, and its value is a boolean. If it is true, that record does not pass through the connection.
[
{ "document_id": "INV-1001", "total": 240, "currency": "USD", "_private": false },
{ "document_id": "INV-1002", "total": 980, "currency": "USD", "_private": true }
]
// the second record never reaches the target; `_private` is also removed from the first
//
// The example is a sale document because the PARTY PORT is the exception to this
// rule: there a flagged record is not dropped, it is reduced to its ID and
// delivered, and `_private: false` is kept too. See 4.5.1.- The flag is set on a PERSON, not on a record. In your interface the user does not flag rows one by one: they say "this person is private" on the customer record, and that person's appointments, sales and payments are all closed at once. The field on the wire is the computed result of that flag.
- Carry it from day one. If you add it later, your older records stay unflagged and that gap never closes.
- Some business objects are not linked to any person, so there is no flag to inherit:
business/product,business/stock-level,business/stock-movement,business/availability. The field is still REQUIRED (every record carries it), but its value isfalseunless you flagged the record for some other reason. This list is computed from the dictionary: an object without aparty_idfield is on it. - You cannot choose not to send a flagged record. The platform does the stripping; you compute and carry the flag. On the party port this has a separate consequence, see 4.5.1.
- If the flag is missing, the record is not delivered and the whole delivery is rejected. We do not filter silently: a missing flag is a bug on your side and it should show up on the first try. Records the user flagged themselves are filtered silently, and the rest are delivered normally.
- If every record is flagged, no delivery is made at all, and that is not an error. If all the records in the batch you send are flagged, not even an empty call goes to the target; your event is accepted with
202and you do not need to retry. An empty call would be an indirect way of telling the target "there is a record here you are not being shown", which is exactly what the flag exists to prevent. If you are on the target side: in this case we never call you, so for you "no call arrived" and "there was no record" are the same thing. - Field names starting with an underscore belong to the platform. Do not use them in your own data.
- The flag does not recall history. Records that already went to the other app yesterday stay there; the flag stops what comes after. Say this in your own interface too, otherwise the user assumes "nobody can see it anymore".
- The same filtering applies on every surface where data leaves the user (guideline 1.8): endpoints you open for extensions, exports, anything reachable with a key given to a third party. The user's OWN screen is excluded, and that exclusion is required: people see their own purchase price. The details and the trap are in section 10.
4.5.1 The flag travels between apps
The rule above says one more thing, and it is easy to miss: since the flag is set on a person, when that person moves to another app the flag has to move too. If the user flags a person as private in their contacts ledger, the document the sales app sends for that person must be closed as well. Otherwise the promise the user sees on screen quietly becomes a lie.
The only path that carries this is the `business/party` port (guideline 1.10). It is the object that describes the person; the other objects refer to the person by party_id and inherit the flag from it. Both directions bind you.
| Role | What you do |
|---|---|
| Sender | When you flag a person you do not pull them from the port: you send the record with _private: true. The platform reduces it to the ID, and the name and phone are not delivered. When the flag is removed, the record goes out in its normal form with _private: false. |
| Receiver | When you receive a party record carrying _private: true, you flag your own records linked to that ID. When false arrives, you remove the flag. What to do for a person you have never seen is written right below the table, and it binds you. |
What to do when a flag arrives for a person you do not know: the ONLY exception to guideline 1.9. You store the flag against the party_id, and you do NOT CREATE a nameless record in the user's list. This does not contradict the requirement in guideline 1.9 that "data that would create a new record must carry the field that identifies it"; this is exactly that guideline's one exception, and it is written in the review guidelines as well. The incoming record has no name because it must not have one: the whole point of the flag is that the name does not travel. If a party record with a name arrives later, the record is created then and the flag you stored is applied to it.
// what the sender sends, a flagged party:
{ "party_id": "c-9", "display_name": "Jane Doe", "phone": "+1 555 0100", "_private": true }
// what REACHES the receiver: the ID and the flag, nothing else
{ "party_id": "c-9", "_private": true }So this is not the stripping getting looser, it is the flag SPREADING. A sale document or appointment carrying _private: true does not reach the target; only the party record reaches it, and only reduced to its ID. Ignoring a flag you receive violates guideline 1.10: you would be silently overriding a decision the user made in another app.
Know the limit too: this covers the case where the person came to you through a connection. If the user typed the same person into two apps separately by hand, the IDs do not match and the flag cannot find them. That is why you keep your own flagging surface as well; the carrying described here does not replace it, it adds to it.
4.6 Field classes: how far each field goes
In a product record, the product name and the purchase price travel on the same port with the same type; yet one can be shown to customers and the other is internal to the business. Because the problem is in the column and not in the record, the user cannot solve it by flagging records one by one. That is why the user classifies records, and the app classifies fields.
You write the class in the port's shape field. Both notations are valid; a field without a class counts as operational. The type name comes from a closed list (6 names) and a name that is not on the list sends the submission back from the schema: string, json, binary, number, timestamp, boolean. These are not port TYPES, they are the primitive type of a single FIELD; names like date, int or text are not accepted.
"shape": {
"product_id": "string",
"name": { "type": "string", "class": "public" },
"purchase_price": { "type": "number", "class": "internal" },
"description": { "type": "string", "class": "free-text" }
}| Class | Name | Over a connection | On a public surface | What it means |
|---|---|---|---|---|
public | Public | Travels | Allowed | Information that is already shown to the outside world: product name, service duration, opening hours. Can be a candidate for a chatbot's published set. |
operational | Operational | Travels | Not allowed | The data of day-to-day work: quantity, date, status, document number. Travels over connections, never reaches a public surface. |
personal | Personal | Travels | Not allowed | A field that identifies a person or can be linked to one: name, phone, email, address. Travels over connections because that is often the point of the connection, but is listed separately on the consent screen. |
internal | Internal | Does not travel | Not allowed | Business information nobody but the owner should see: purchase price, cost, profit margin, discount ceiling. Does NOT travel by default; travels if the user explicitly allows it. |
free-text | Free text | Does not travel | Not allowed | A field a person writes in their own words: note, description, call log. Its content cannot be known in advance, so it does NOT travel. Travels if the user explicitly allows it and reads the warning. |
This table applies to EVERY port where you declare a `shape`, even if the port's type is not a shared business object. If you declare a field as free-text on a tabular/rows or structured/json port, that field is not delivered; a field without a class counts as operational and travels. A field you did not declare does not travel either. Stripping does not look at the format of your value: an array of records, a SINGLE record object and CSV rows are all stripped by the same rule.
RULE CHANGED (2026-09-15). This paragraph used to say *"if you do not declare a shape on a port that is not a shared business object, your value goes through as is"*, and that sentence described a limit: the platform did not strip there. It was measured, and the user's flag and internal fields were passing through those ports. The guarantee has to stay in the stripping, so the limit was removed: a record-carrying output port MUST now declare a schema, and delivery from a record port without a schema is stopped. On ports that carry a single value (text/plain, identifier/email, numbers, time, media) there is no named field to strip, so no schema is required and the platform does no stripping there; the filtering obligation stays with you under guideline 1.8. RULE ADDED (2026-09-17): such a port must actually send a single value. If the value is an object or an array, the delivery is stopped and you get a 400: a structure has named fields, none of them were declared, and the platform will not deliver fields it cannot read. If you need to send a structure, declare a record-carrying port type and give it a shape.
The INSIDE of `json` and `binary` fields is opaque to the platform, and that has a cost. A binary field carries a SINGLE STRING (a URL or base64), so stripping cannot reach inside it at all; an object or an array sent in a binary field stops the delivery (see the list below). A json field is reached into only partly (only the names the dictionary treats as closed and sub-records flagged _private are stripped; a name it does not recognize passes through). Which fields are reached into is decided by your DECLARATION, not by the value that arrives: only a field you declared as json is reached into, because the user approves a card written from your declaration before any delivery happens, and that card names what is stripped inside. So putting a whole record inside a single json field looks like meeting the schema requirement while switching the stripping off. Guideline 1.12: declare every field you can declare. Opaque fields are not forbidden and will not be, because they have legitimate uses: in a relay the body passes through exactly as it came from the source, in a polymorphic stream every event has a different body, and you cannot know the names of fields the user defines themselves. But during review these ports are flagged and you are asked why you could not declare the fields; you write your answer in the submission thread. A submission that leaves declarable fields opaque gets a change request.
Structured data travels, prose does not. What is inside a free-text field cannot be known in advance: the sentence "this guy always pays late" gets written into a notes field, the record is not private, and no classification can catch it. That is why the rule relies on format, not on cleverness.
- `shape` is required on every record-carrying OUTPUT port (
structured/json,tabular/rows,tabular/csv,event/webhook-payloadand everybusiness/*type); onbusiness/*it is required in both directions. No schema means no field classification, which means the platform has no declaration to strip by. On input ports with general types it is optional: stripping is done according to the SOURCE's schema. On ports that carry a single value (text/*,identifier/*, numbers, time, media) no schema is required, because there is no named field to strip; in return the value you send there must itself be a single value, never an object or an array (2026-09-17). - The classes in the dictionary are a FLOOR, not a suggestion. You can move a field to a more closed class; you cannot move it to a more open one. A manifest that writes
operationalforpurchase_price, which the dictionary callsinternal, comes back from the schema, and even if it did not, that field would still be stripped on delivery using the dictionary's class. Not writing a class is also a declaration (it counts asoperational), so you cannot use the short notation for a field that isinternalorfree-textin the dictionary. If there is a reason the field should still travel, the path is not your declaration but the user's explicit permission. - Do not send fields you did not declare. The rule works both ways: you do not send them, and an undeclared field is stripped before delivery. This also lightens the honesty burden that stays on your side.
- `shape` is a DECLARATION, and the platform now enforces one half of it (2026-09-16). Declaring a field as
stringstill does not guarantee that the value arriving in it is sensible text: we do not check text against number, and you validate incoming data on your side (section 10). What we DO enforce is the STRUCTURAL half, because the user's consent card is written from it and the card says which names are stripped inside ajsonfield: a field you declared as `json` must carry an object or an array, and a field declared with any other type, `binary` included, must not carry one. A value that contradicts its declaration is no longer stripped silently; the delivery is stopped, the reason names the fields, and the user is told the data could not be read. A field that is absent ornullcontradicts nothing. If a field carries a structure, declare it asjson. - The private flag works on every record-carrying port and does not look at the format of the value. If you send a record carrying
_private: truefrom a port liketabular/rows, that record is NOT delivered; the same holds for a SINGLE record object (structured/json,event/webhook-payload) and for CSV rows. There are two differences: the obligation to put the flag on EVERY record (guideline 1.6) exists only onbusiness/*ports, while on general ports the flag is applied if present and otherwise the record passes normally; and reducing a record to its ID and delivering it is specific to thebusiness/partyport, while on a general port nothing is left of a flagged record. - If you send the flag, send a BOOLEAN. If you put text like
"true"or a number in the_privatefield, delivery stops and the reason is written in the response to you. We do not interpret it: guessing a malformed flag would be another name for silently ignoring the user's decision. Not sending a flag AT ALL is fine on general ports. - Send a strippable value from a record-carrying port. A record object or an array of records; for
tabular/csv, CSV text with a header row. If you send a string, a number or an array with primitive items, delivery stops, because we cannot strip a value whose fields we cannot read. Wrapping the record array in an object ({ "rows": [...] }) does not work either: the wrapping field is not declared, so it is stripped. - Do NOT write `_private` into `shape`, and you cannot. Names starting with an underscore belong to the platform and the manifest schema rejects them. This is not a contradiction: your
shapedescribes only your own fields, while the flag is a separate mechanism that asks no declaration from you. The flag you send is NOT IGNORED for being undeclared: record-level stripping reads it BEFORE field stripping.
If you are on the receiving side, the question turns around: does the record you receive carry the `_private` field? The answer is one sentence: NOT, except on the party port. Right before delivery, fields that belong to the platform are removed from the record, so an appointment or a sale document reaches you without the flag. No information is lost: a flagged record never reaches you anyway, it is dropped. So if you do not see the field, the record is not private.
The one exception is the party port (4.5.1): there _private is deliberately KEPT, because it is the very thing being carried. You must read the field on an incoming party record and apply it to your own records (guideline 1.10); on other ports a branch like that would never run.
4.6.1 Give field names a human name: `label`
Your field names are your column names: party_id, till_code, amt_net. They are the right names for you, but a user reading them cannot answer "what is this of mine". That is why you can write an optional label on each field declaration: the field's name in plain human language.
"shape": {
"party_id": { "type": "string", "class": "personal", "label": "Customer ID" },
"purchase_price": { "type": "number", "class": "internal", "label": "Purchase price" },
"amt_net": { "type": "number", "label": "Amount before tax" }
}The platform cannot write the label for you. Only the person who wrote till_code knows what it is; if we guessed, we would sometimes get it wrong, and a wrong label is worse than the raw name. If you leave it out nothing breaks: the field shows with its raw name and behavior stays exactly as it is today.
Where it appears: on the user's connection screen (which fields travel, which ones wait for approval, which ones were stripped) and when the same user asks in chat *"which of my data goes through this connection"*. So the person reading the label is not your own user in your app, it is the person deciding to move their data from you to another app. Write the sentence for them: expand abbreviations, do not repeat your own table name.
- The short notation has no label. You cannot add a label to a line that says
"qty": "number"; you have to switch to the full notation. Fields that need a label are usually the ones you write a class for anyway. - An empty label is rejected. At most 200 characters, and it cannot be only whitespace. Saying "there is a label" and leaving it blank is worse than not writing one: the screen reading it draws a nameless row. If you are not going to write a label, leave the key out.
- A label does NOT CHANGE what travels. Stripping follows the class; the label is only for display. Writing "Public price" on an
internalfield does not open it. The label must describe the field itself: it is the sentence the user reads on the consent screen, and a wrong label leaves the user approving something they cannot see (the honesty gate in 4.4). - It is a single string, not one per language. It is not one of your store page's translation fields (section 8); whatever language you wrote in the manifest is what shows.
- You can also write it on dictionary fields. The field NAMES of shared business objects are ours (4.4), but the label is yours: you can call
party_id"Customer ID" or "Patient number". The dictionary floor touches the class, not the label.
4.7 One complete example
The sections above describe the fields one at a time. This is the only place where they all appear together; you can copy it and replace the values with your own. This box was not written by hand: it is a printout of a real value that passes the schema.
{
"schemaVersion": "1",
"slug": "appointment-book",
"listingKind": "app",
"name": "Appointment Book",
"version": "1.0.0",
"category": "calendar-booking",
"description": "An appointment book for small businesses: it books appointments, sends reminders and keeps their history.",
"embeddablePanelUrl": "https://appointment-book.example.com/panel",
"transport": {
"kind": "mcp",
"endpoint": "https://appointment-book.example.com/mcp"
},
"inputs": [
{
"id": "book-appointment",
"type": "business/appointment",
"label": "Book an appointment",
"description": "Writes appointments coming from another app into the book.",
"required": true,
"sensitivityTags": [
"customer-pii"
],
"sensitivityReason": "An appointment can carry a party ID in the party_id field; even though the ID is opaque, it points to a person. Name, phone and address are not carried.",
"shape": {
"appointment_id": "string",
"starts_at": {
"type": "timestamp",
"class": "operational",
"label": "Start time"
},
"ends_at": {
"type": "timestamp",
"class": "operational",
"label": "End time"
},
"status": {
"type": "string",
"class": "operational",
"enum": [
"booked",
"done",
"cancelled",
"no_show"
],
"label": "Appointment status"
},
"party_id": {
"type": "string",
"class": "personal",
"label": "Customer ID"
},
"service": {
"type": "string",
"class": "public",
"label": "Service"
},
"note": {
"type": "string",
"class": "free-text",
"label": "Appointment note"
}
}
}
],
"outputs": [
{
"id": "appointment-feed",
"type": "business/appointment",
"label": "Appointment feed",
"description": "Publishes appointments as they are booked and changed in the book.",
"required": true,
"sensitivityTags": [
"customer-pii"
],
"sensitivityReason": "The same record leaves the app; party_id points to a person. Name, phone and address are not carried.",
"shape": {
"appointment_id": "string",
"starts_at": {
"type": "timestamp",
"class": "operational",
"label": "Start time"
},
"ends_at": {
"type": "timestamp",
"class": "operational",
"label": "End time"
},
"status": {
"type": "string",
"class": "operational",
"enum": [
"booked",
"done",
"cancelled",
"no_show"
],
"label": "Appointment status"
},
"party_id": {
"type": "string",
"class": "personal",
"label": "Customer ID"
},
"service": {
"type": "string",
"class": "public",
"label": "Service"
},
"note": {
"type": "string",
"class": "free-text",
"label": "Appointment note"
}
}
}
],
"pricing": {
"model": "free"
}
}Every choice in the example follows a rule: both ports are shared business objects, so shape is required and the dictionary's required fields are all there (4.4, 4.6); they carry party_id, so they have the customer-pii tag with a reason next to it (4.3); the note field is free-text, so it does not travel over a connection by default, while party_id is personal: it travels but is listed separately on the user's consent screen (4.6); status carries a closed list, so enum is written out and the other side does not have to guess which values will arrive; every field has a label except appointment_id, and that row shows that the short notation is still valid (4.6.1); slug is the value you expect as aud (section 9) and does not change after approval.
5. Step 6: review information and demo account
The form in the Versions tab of your app screen is shown above the raw manifest on the reviewer's decision screen. It asks for three things:
- Contact: name, email and an optional phone number. If something needs to be asked during review, it is asked here.
- Notes: at most 4000 bytes. Write how your app is tried out, what appears on which screen, and the reason for anything unusual.
- Demo account: required if your app needs signing in.
Guideline 2.5 is explicit: an app that requires signing in provides the credentials of a demo account that does not expire. Without access, review can only be done on the manifest and the app cannot be approved. If your demo account expires or its password changes, you will be held up at the next review; keeping it current is your job.
The "My app requires signing in" checkbox is a DECLARATION, and ticking it wrongly has consequences. If you leave it unticked and the reviewer hits a sign-in wall, the app cannot be opened, review cannot be completed and the submission is rejected under guideline 2.5. Nothing technically forces the box; what forces it is the outcome. If you are unsure, tick it and provide a demo account: an extra demo account costs you nothing.
This information is stored per vendor and slug, not per submission. Fill it in once and use it for every version.
6. Step 7: submission
When you send the manifest form, your submission passes a series of gates on the platform before it enters the review queue. All of them answer immediately:
| Response | Reason | What to do |
|---|---|---|
invalid_json | The JSON could not be parsed | Fix the text |
invalid_manifest | Schema error, returned with the field path and a message | Fix the field shown |
slug_taken | This slug belongs to another vendor | Choose another slug |
invalid_host | The address does not resolve to a host | Fix the address |
unverified_host | The host is not verified | Complete section 3 |
rate_limited | The limit of 20 submissions a day | Leave it to the next day |
review_service_unavailable | The review service cannot be reached | Try again |
About the daily limit of 20 submissions: even a busy day of fixes does not go past 20. A number that does is not fixing, it is a loop.
7. Step 8: review
How long it takes
Review usually finishes within 3-5 business days, and at most 7 business days.
The shape of this number is deliberate. Apple publishes an aggregate rate (more than 90 percent of submissions in under 24 hours) and holds it with a team of thousands. Steam gives a conservative range plus a buffer. This platform has one reviewer today, so the second shape was chosen: a promise you cannot keep costs more trust than giving no promise at all.
What the reviewer does
- Reads your manifest: category, kind, ports, sensitivity tags.
- Runs a health probe. It checks whether the endpoints you declared can be called. What it measures is whether the origin answers, not whether it works correctly: your panel URL may return 401 to a call without a token, and that is a healthy answer. Only two states count as dead: no answer at all (connection refused, timeout, TLS error) and 5xx.
- Installs and opens your app. Whether the kind you declared is correct is tested here.
Decisions
| Decision | Result |
|---|---|
| Approve | Your catalog row is written and you can fill in your store page |
| Request changes | You fix the manifest and submit again, and review starts over (guideline 5.2) |
| Reject | Comes back with a reason and a guideline number |
Every negative decision rests on at least one guideline number (guideline 5.1), and the number is verified on the server: a decision citing a number that is not on the published list cannot be made at all. A free-text reason is required as well. So you cannot get a rejection that just says we did not like it.
The thread
The reason for the decision lands in that submission's thread in your app's Versions tab, and you can reply. You do not need to make a new submission to ask about a reason you did not understand. There is a limit of 20 messages a day.
We email you when a decision is made (approved, rejected or changes requested), and the email carries the reason and the guideline lines it rests on. It is sent from a no-reply address, but replying works: replies go to support@kuratowski.com. The console is still the full record: your product list marks what is waiting on you, and an app's Versions tab is that app's complete history with the thread.
8. Step 9: approval, store page, publishing
After approval you fill in your store content in the Store page tab of your app screen:
| Field | Limit |
|---|---|
| Tagline | 160 characters |
| Full description | 8000 characters |
| Icon URL | https, 2048 characters; PNG, JPEG, WebP, GIF, AVIF or TIFF (not SVG) |
| Screenshots | At most 8, all https; PNG, JPEG, WebP, GIF, AVIF or TIFF (not SVG) |
| Video | https; YouTube, Vimeo or a direct .mp4 |
| Support email | 320 characters, required (guideline 3.7) |
| Privacy policy URL | https, required; the CONTENT of the policy is also required (guideline 3.6, section 12) |
We keep a copy of your images. The moment you save, we download the image from the address you gave, produce our own copy and show that in the store. This has three consequences for you: your store page does not break if your address goes down later, the browsers of visitors opening your store page never reach your server, and changing the file on your server is not enough to change the image (our copy does not change). To show a new image, save your store page again.
Your address has to work at the moment you save. If an image cannot be downloaded the save does not complete and you see which address failed under the field; your text stays in the form and is not lost. Images whose address you did not change are not downloaded again, so a save where you only fixed your description never goes out to the network.
Accepted formats: PNG, JPEG, WebP, GIF, AVIF, TIFF. SVG is not accepted. Every image is re-encoded as WebP, so the metadata inside your file (EXIF, copyright field, producer information) does not make it into the copy; for an animated file only the first frame is taken. Icons are scaled down to at most 512 pixels and screenshots to at most 2048; a smaller image is not scaled up. The file limit is 5 MB for an icon and 10 MB for a screenshot.
Video is not copied, it stays as an address: YouTube and Vimeo embed their own player anyway. If an address is rejected (not https, or not an allowed host for video) that item is not drawn at all: no image is better than a broken one.
A store page change does not go back through review. Changing a screenshot is not a technical change, so it does not need a submission: it goes live the moment you save.
But going live is not being exempt from checks (guideline 5.4). Every change you make lands in the reviewer's queue and each language is read separately: changing only the Japanese text puts that language in the queue too. Users can also report an app from its product page. The guidelines apply as they are and breaking them is grounds for suspension (guideline 5.3):
- The name, description and images describe what the app actually does. Content describing a feature that does not exist is rejected (guideline 3.1).
- You cannot use a brand, logo, image or text you do not own. You cannot choose a name that evokes another product when you have no connection to it (guideline 3.3).
- Coming soon screens, empty screenshots and placeholder text cannot go live (guideline 3.4).
- No purchase redirects outside the store (guideline 3.5).
- A screenshot shows the product in use: it cannot be only a logo, a sign-in screen or a marketing poster. If you provide a video, it too is a real recording of the product (guideline 3.8).
- The privacy policy URL stays working and the content of the policy is required (guideline 3.6): giving an address is not enough, and what the text has to say is written in section 12.
- Screenshots cannot contain real customer data (guideline 1.5). The names, emails, invoices or health records in a screenshot must be fictional: the store page is public and anyone can open it without signing in.
- The content must be suitable for visitors of any age; the store is browsed without signing in (guideline 3.10).
- Copies of the same product with small differences are not listed (guideline 3.11).
- The main text of your listing is written in English (guideline 3.13). Translations are optional; the language section below says what happens when one is missing.
Languages: publish your product in all 12 languages of the site
You can publish your store page in every language the platform supports. The model is the App Store's: you choose a primary language and write the other languages on top of it.
Today's languages (12 of them), with en as the default: en, tr, de, fr, es, it, pt, ru, ar, zh, ja, ko.
- The primary language has to be English (guideline 3.13). The reason is what visitors see: anyone whose language has no translation reads your main text, so the main text must be in the language the whole site shares. A listing with no main text, or with a main text that is not in English, is taken into review; filling a field only in a translation while leaving it empty in the main text has the same result.
- The primary language is the language you wrote your texts in. It is the first tab in the editor and it is removed from the translation list.
- Translations are not required. In a language you did not translate, visitors see your primary language text. A single-language vendor keeps working exactly as today.
- Fallback works field by field. You can translate only the tagline in one language and leave the full description and the images empty; whatever you leave empty comes from the primary language.
- You can remove a language entirely. Instead of clearing its fields one by one, remove the language: a half-finished translation is worse than no translation.
| Field | Varies by language |
|---|---|
| Tagline | Yes |
| Full description | Yes |
| Screenshots | Yes (if your interface is translated, the screens should look translated too) |
| Video | Yes |
| Icon | No, one brand |
| Support email | No, a single box |
| Privacy policy URL | No, a single address (you can translate it on your own site) |
| Name and category | No, they come from the manifest |
Translations have to describe the same product (guideline 3.12): a feature promised in one language and not in another is a false declaration for the user who speaks that language. A missing translation is not a violation, a wrong translation is.
A known limit: translations do not reach search. Store search indexes the product name, the manifest description and your category's labels in all 12 languages; store page texts (including the primary language ones) do not take part in ranking. So translation changes how your product looks, not how it is found.
The distribution switch
In the Overview tab of the app screen you can remove your app from the store and put it back. Removing is not deleting: it stays in the libraries of the users who installed it and keeps working. The details are in section 13.
9. Embedded session: the technical contract
Your app opens inside the marketplace, without the user leaving the site and without signing in a second time. This section is the contract of that session: what you receive, how you verify it, and the three things we require from you.
The three values you pin when verifying, and where they come from
In the code examples below you will see a placeholder called <platform>. The placeholder is deliberate: that value is not a sentence in this document but a setting of the platform's deployment, and it changes when we move to our own domain. If we had written a copy of it here, your verification would break the day the copy went stale. Instead the value itself is read from a single address:
GET https://<marketplace address>/.well-known/kuratowski-platform.json{
"issuer": "https://…",
"jwks_uri": "https://…/.well-known/kuratowski-jwks.json",
"signing_alg_values_supported": ["RS256"],
"token_purposes_supported": ["panel", "dispatch", "subscription"],
"audience_source": "manifest.slug"
}`<marketplace address>` is not something you do not know: it is the address of the site you are reading this on. The document describes the rules, not the setting; the setting is there. The endpoint needs no authentication, so you can read it before you submit.
But you cannot test your verification END TO END before you submit, and you should go in knowing that: the discovery endpoint gives you the public key, not a token. There is no signed sample token, test account or sandbox today. You will be testing your verification setup (iss, aud, purpose, alg) for the first time on a real user's connection. The known way to narrow that down is to run your verification in your own tests against a token you generate yourself according to the contract in this document; it shows that you match the document, not that you match us.
| Value | Where from | Note |
|---|---|---|
iss | The issuer field | The token's iss claim is exactly this value; there is no trailing slash |
| JWKS address | The jwks_uri field | The path is fixed, only the origin changes |
aud | Your manifest slug | Not a separate identity: whatever manifest.slug says, that is your aud |
You choose the slug, we do not assign it. The value you type when you create the app record (step 4) is reserved for you, goes into the manifest and never changes after approval (guideline 2.3). So the string you will expect as aud is the slug you typed into the form yourself.
All three have a clear source and all three come from different places. The only thing that lives in your configuration is the marketplace's base URL; issuer and jwks_uri are READ from the discovery endpoint at that address, not written into your configuration a second time. aud is your own slug anyway. When the domain moves, the only value that changes is the base URL; if you hardcode the other two into your code or configuration, that is the day you have to ship a new version.
Until 2026-09-09 this paragraph said "read all three from environment variables", and the discovery endpoint was opened precisely to make that unnecessary; the old sentence had survived the fix. We do not name your environment variables, they are a detail on your side.
Do not read the discovery document at startup and tie your app to it. If our endpoint is down briefly, your restart fails too, and that is an outage you cannot fix yourself. The right way: use the value when you can read it, and carry on with the last known value when you cannot. jose's createRemoteJWKSet object already behaves this way, so there is nothing for you to do on the JWKS side; what to watch out for is your own code reading issuer once at startup and making it mandatory.
Key rotation is not your problem, but know how it works: the JWKS is a LIST, and a new key is added without removing the old one from the list. The token's kid field says which key signed it; a verifier that looks at kid (every common library does) never breaks during the transition. We do not publish a rotation schedule, because there is nothing for you to do about it on your side.
9.1 How it works
Your app does not run on the platform's server. It runs on your own origin and the platform embeds it in a cross-origin <iframe>. The iframe's address is the embeddablePanelUrl you declared in your manifest, and the platform adds a short-lived, signed token to it:
https://panel.your-domain.com/?__mp_token=<JWT>Why not a cookie: browsers block third-party cookies inside iframes (Safari and Firefox completely, Chrome by partitioning). Every cookie-based solution silently fails for some users. Shopify's embedded app architecture uses a session token for the same reason, not a cookie.
Why an asymmetric signature: the platform signs the token with RS256 and you verify it with the public key. You are not given a private key and we take no secret from you; no code change or key exchange on the platform is needed for a new vendor to connect.
The public keys are published here:
https://<platform-domain>/.well-known/kuratowski-jwks.json9.2 What is inside the token
| Claim | Value | Note |
|---|---|---|
iss | The platform's origin | Pass the value you expect when verifying |
aud | Your app slug | Another app's token is not valid with you |
sub | A two-way pseudonym | Not the real user ID, see below |
exp | iat + 120 seconds | Short, because the token can end up in the address bar |
jti | UUID | Refusing replays is your job (rule 1) |
purpose | "panel" | Check it. The other two tokens are told apart by this |
kid in the header | The key ID | Lets you pick the right key when keys rotate |
Why `purpose` exists: the platform sends you THREE tokens, all signed with the same key, all carrying the same iss, the same aud and the same sub. The only difference between them is this claim:
purpose | Where you meet it |
|---|---|
"panel" | In the address bar when a user opens your embedded panel (this section) |
"dispatch" | At your MCP endpoint when a connection writes data to you (section 10.6) |
"subscription" | In the kuratowski_subscription call when a user subscribes or leaves (section 10) |
If you do not check it, they stand in for each other: the panel token ends up in the address bar and can be captured, and whoever presents it at your endpoint can write to the user's data. Pass the purpose you expect explicitly on every verification; our discovery endpoint also publishes which purposes we produce (token_purposes_supported).
`sub` is not a real identity: sub = HMAC(platform_secret, userId + appSlug).
- The same user and the same app always produce the same value. You can recognise your own user and store data for them.
- The same user and a different app produce a different value. Two vendors cannot combine their data and say these two are the same person. This is deliberate; the user's identity on the platform, their email and their name are not in the token.
Store this value on your side as your primary user key.
The format is fixed: 43 characters, base64url. The alphabet is A-Z a-z 0-9 - _, there is no padding (=), and the length is always 43 (the 32 bytes of HMAC-SHA256). Size your column accordingly: a column that truncates merges two users into one row and no error message will tell you. varchar(64) is comfortable.
9.3 Verification: the minimum you have to do
This code runs ON THE SERVER. Verifying the token in the browser has no security value: to get past that check an attacker only has to get past an if in their own browser, and they are the one supplying the token anyway. Until your server verifies the token itself, you do not know who the user is. The only browser code in this section is rule 2 of 9.4 (dropping the token from the address bar), and it says so there.
import { createRemoteJWKSet, jwtVerify } from "jose";
// This object carries ITS OWN CACHE, create it ONCE at module scope.
// Creating a new one per request means a JWKS round trip on every verification.
const jwks = createRemoteJWKSet(new URL("https://<platform>/.well-known/kuratowski-jwks.json"));
// `expectedPurpose` is what the CALLER says: "panel" for the panel token,
// "dispatch" for the connection token (section 10.6). That is why it is a
// parameter: the same core works at both ends. Hardcode it and the other end breaks.
export async function verifyMarketplaceToken(token, expectedPurpose) {
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["RS256"], // REQUIRED: pin the alg
issuer: "https://<platform>", // the platform origin you expect
audience: "your-app-slug", // YOUR OWN slug
clockTolerance: 5,
});
if (payload.purpose !== expectedPurpose) throw new Error("token is for another purpose");
return payload; // payload.sub is your user key
}
// In the panel:
const session = await verifyMarketplaceToken(token, "panel");
// Do NOT put jti replay checks or an exp requirement INSIDE THIS FUNCTION: both
// are rules of the panel flow only (9.4 rule 1), and both are wrong for the
// connection token. Doing this without reading 10.6 breaks all of your connections.Four details are not negotiable:
- `algorithms: ["RS256"]`: if you do not specify it, the library reads the token's own
alg. This single line closesalg: "none"and every downgrade-to-HMAC attack. - `audience`: if you do not pass your own slug, a token minted for another app is valid with you as well. A malicious vendor could take the token from their own iframe and get into your app.
- `issuer`: not just anyone publishing their own JWKS, only the platform.
- `purpose`:
jwtVerifydoes not check this for you, which is why it is a separate line in the example above. In the panel the expected value is"panel"; when a connection writes data to you, the token will say"dispatch"(section 10.6). Do not hardcode the value inside the function, or one of the two ends will always be refused.
If there is no token, or it is refused, do not show an empty panel: tell the user they are unauthenticated.
9.4 The four rules we expect from you
The platform cannot enforce these on its own; that is why the first three are written both here and in clause 4 of the vendor agreement. The fourth is not about the session itself but about a setting that bypasses it entirely.
Rule 1: remember the `jti` for the lifetime of the token.
Keep the panel token's jti in memory (or in Redis) and refuse the same `jti` if it arrives a second time. The token is in the address bar: a copied link can open a second session until it expires. A 120-second set is small and needs no durable store.
Keep the set not until `exp` but until `exp` plus your clock tolerance. The example in 9.3 passes clockTolerance: 5, meaning you still accept a token that expired 5 seconds ago; if the set empties exactly at exp, those 5 seconds are a replay window. With today's values: 120 + 5 = 125 seconds. If you change the tolerance, change the lifetime of the set with it.
This rule is for the panel token only, and its exception sits right next to it. The connection token (section 10.6) arrives with the same jti on EVERY call; if you treat that as a replay and refuse it, all of your working connections break on the first delivery. Tie the check to the panel-opening flow, not to the token itself.
Rule 2: drop the token from the address. As soon as the panel opens:
const url = new URL(location.href);
if (url.searchParams.has("__mp_token")) {
url.searchParams.delete("__mp_token");
history.replaceState(null, "", url);
}Otherwise the token ends up in the browser history, in links the user copies and in screenshots they share.
Rule 3: send `Referrer-Policy: no-referrer`. In the response headers of your panel page. Otherwise every outbound request from your page can carry the address with the token in it in the Referer header.
Rule 4: let CODE turn off your development identity in production (review guideline 1.11). While developing, faking the identity with an environment variable is expected: you cannot open your panel without the platform. The danger is not that switch itself but it leaking into production: if the variable stays defined in the production environment, the panel session opens with no identity and the user's entire ledger (internal fields, notes, key generation, data deletion) is open to anyone who finds the address.
That is why the ban is written in code, not in a document: if your app sees that variable while running in production, it must not start. If you already behave that way for a missing setting (a database address, say), do the same for an EXTRA setting; this is always the side that gets forgotten. The same measure applies to every setting that bypasses authentication: a fixed token, a flag that turns verification off, a public test switch. The point people miss: while that switch is on, none of the three rules above is in effect, because there is no token to verify.
We measured this once and it turned out to be real: an app that failed closed on missing environment variables (database address, public URL) started happily with an EXTRA variable. What gets tested is always the thing that gets forgotten; an extra one of the things nobody forgets occurs to no one.
Developing locally: how to meet the HTTPS requirement
Guideline 2.2 requires every endpoint to be served over HTTPS with a valid certificate, and there is no exception for local development. You cannot declare http://localhost: the manifest schema rejects a non-https address at submission time, and even if it did not, our outbound gate blocks loopback and private network addresses, so no call could ever reach your machine.
What people do in practice: run plain HTTP locally, expose the outward-facing address through a tunnel (Cloudflare Tunnel, ngrok and the like provide the certificate themselves) and put that https address in your manifest. A self-signed certificate does not work; the certificate is verified by the client on our side and it does not accept a root it does not know.
If your tunnel address changes on every start, that is fine for experiments before submission, but the address in the manifest has to be permanent: if a live app's address disappears, your endpoints look dead (guideline 2.1) and the connections users made break.
9.5 What your panel URL has to satisfy
The first two the platform checks itself at runtime: if either fails, your app is not embedded at all and the user is told it cannot be opened inside the site. The third one the platform cannot check for you, and that difference matters, so it is spelled out below.
- `https` is required.
http,data:andjavascript:are refused. Checked by the platform. - It cannot be the platform's origin. The iframe is opened with
allow-same-origin, and the only thing making that safe is you being on a separate origin. Checked by the platform. - You have to allow framing. This one is enforced by the browser, not by us: if your panel URL sends
X-Frame-Options: DENY, the frame opens and stays empty, and neither you nor the user is told why (guideline 2.4). Test it in a real browser before you submit.
The right setting is a CSP that names the platform:
Content-Security-Policy: frame-ancestors https://<platform-domain>;The `<platform-domain>` here is the base URL that already lives in your configuration (see the table of three values in 9.1). An HTTP response header is a string at runtime, so you cannot read this value from the discovery endpoint on every request; and you do not need to. The "do not hardcode the origin" rule is about issuer and jwks_uri, which come from the discovery endpoint. The base URL is the one value that lives in your configuration, and frame-ancestors wants exactly that. Reading the issuer field from the discovery endpoint at startup and building the header from it is also valid, and more robust.
You can keep sending X-Frame-Options for the rest of your own site; the rule is only about the panel URL.
The permissions given to the iframe: allow-scripts allow-forms allow-popups allow-same-origin allow-downloads. `allow-top-navigation` is not given, so the embedded page cannot navigate the user's tab somewhere else. The permissions policy is narrow as well: no camera, microphone or location by default.
`allow-modals` is not given, and this is a silent failure. alert(), confirm() and prompt() do not work in your panel: the browser never shows them, raises no error, and confirm() returns false immediately. So if you built a flow that asks for confirmation, it counts as "cancelled" without the user ever being asked. Draw everything that asks for confirmation, warns or takes input in your own interface; and try your panel inside the frame once, because this defect only appears there.
Because `allow-downloads` is on the list, you can hand the user a file: a download triggered by a user action (<a download>, Blob plus URL.createObjectURL) works from the panel. Without that flag the user would press the button and the browser would block the download silently, with no error anywhere. Downloads that start on their own, without a user action, are still blocked.
9.6 After the first 120 seconds: how to keep the session going
The token is for the moment of opening, not for the session. It lives 120 seconds, while a user may work in your app for hours. How you keep recognising the user after that is your design, and there are two wrong reflexes here, both of which fail silently.
- Do not set a cookie. The iframe the panel lives in is a third-party context; for the reason described in 9.1, some browsers never write the cookie, and only those users experience it.
- Do not rely on `localStorage` or `sessionStorage` either. In a cross-origin iframe Safari can refuse this access entirely and throw a
SecurityError, and partitioned storage can be cleared without notice. If you do not wrap it intry/catch, your panel dies silently at startup.
The right way: keep the session in memory. When the panel loads, verify the token once, mint your own session ticket and keep it in a variable that lives in the panel's memory. Sign every request to your own endpoints with that ticket in the Authorization header. You do not need to write anything to disk or to browser storage.
Why this is usually enough: every time the platform page loads, a fresh token is put in the address (the token's jti is new each time). Your panel is not reloaded while the user moves around inside the platform, so the in-memory session stays alive for the whole working session.
But that does not mean "every load", and if you do not know the difference the user gets locked out. What puts a fresh token there is the PLATFORM PAGE being rendered again. If the iframe reloads on its own (refreshing the frame, a page transition inside your panel, the back button), the address either has no token at all or carries the SAME jti a second time; in the second case your own rule requires you to refuse it (9.4 rule 1). The right behaviour is the same on both branches: tell the user to open the app from the marketplace again.
If your panel is multi-page, "keep it in memory" is not enough on its own. In a server-rendered panel that does a full page load on every click, JavaScript memory is cleared at every transition, and cookies and localStorage are unreliable for the reasons above. In that case keep the session ticket on your server and carry it by embedding it in every page (a hidden field, a data- attribute or a variable printed into the page). The ticket is verified on the server and only carried in the browser.
// ONCE, when the panel opens
const claims = await verifyMarketplaceToken(tokenFromUrl, "panel"); // see 9.3
const session = await createSession(claims.sub); // your own session
// session.id lives in memory only; NO cookie, NO localStorage.
// On every request to your own endpoints
fetch("/api/documents", { headers: { authorization: `Bearer ${session.id}` } });Give your server-side session record a lifetime comfortably longer than a working day and refresh it while the panel is open. If the session still drops, do not show a blank screen: tell the user they need to open the app from the marketplace again. And whatever happens, content the user has typed but not yet saved must not disappear with the session.
9.7 Running inside the frame: ready signal, deep links, response time
Your panel is always loaded at the exact URL you registered as embeddablePanelUrl. We add reserved query parameters, all starting with __mp_: __mp_token (session token), __mp_origin (the marketplace origin to post messages to), and __mp_route (see below). The whole __mp_ prefix belongs to the marketplace: we strip any you put in your panel URL, and we never carry one back into the user's address bar.
Serve the panel from that exact origin. If it redirects to another host (for example example.com to www.example.com), the document is no longer on your registered origin and every message it sends is ignored.
Tell us when you are ready. Until then the user sees the marketplace's waiting screen, not your page.
const origin = new URLSearchParams(location.search).get("__mp_origin")!;
window.parent.postMessage({ type: "mp:loading", v: 1 }, origin); // early, from your first script
// ...once your first screen is usable:
window.parent.postMessage({ type: "mp:ready", v: 1 }, origin);Send mp:loading before your document finishes loading; it tells us to wait for mp:ready instead of revealing your page when the document loads. If you send neither, your panel still works exactly as before. Always post to the exact __mp_origin, never "*".
Deep links: read `__mp_route` at startup. When the user reloads or shares a link to a page inside your app, we pass that page to you as a value: new URLSearchParams(location.search).get("__mp_route"). It is a path relative to your origin, such as /invoices/12. Validate it before you use it: it comes from a URL a user can edit or share. Accept only routes your app actually has, never pass it to location.href unchecked, and never forward any __mp_* parameter into your own URLs. If you ignore it, your panel opens at its root as before.
Report the page the user is on, on every route change including browser back and forward:
window.parent.postMessage(
{ type: "mp:route", v: 1, path: location.pathname + location.search + location.hash },
origin,
);- The path must start with a single
/; absolute URLs,//, backslashes,.or..segments, spaces, control characters and non-ASCII are rejected (percent-encode them instead). - Anything containing
__mp_, anywhere in the path, the query or the fragment, is rejected. - The maximum length is 512 characters.
- Reporting a route does not add a browser history entry: your own navigations already do.
- Messages from any other window or origin are ignored.
Respond quickly, and keeping your app awake is your job. Before showing your panel we send one request to your origin's root. If it answers, users get a normal wait. If it stays silent we tell them the app is starting and wait longer; if we see your hosting provider's own placeholder page we keep our waiting screen and reload your panel once your origin answers. Apps on plans that sleep when idle make every first opening slow. Keeping your service warm is your responsibility, not the marketplace's.
10. Connections: when your app is at one end of a flow
A connection has two ends and your app can be at either. When you are the source, you send events to us: those rules run from the start of this section up to the 10.6 heading. When you are the target, we call your MCP endpoint: those rules are 10.6 and after. The two directions have different rules; if your product will sit at both ends, read both.
Both ends need a server. Being a source means calling an address with a signature, being a target means keeping an MCP endpoint open. A product without a server (a listing that only offers an embedded panel) cannot be at either end of a flow; that is not a shortcoming but a choice, and it does not affect your submission (see 1.2.1).
The address and the secret are in YOUR console
Two values sit in the Connections tab of your app screen, and both are per app, not per connection:
- Event address:
https://<routing-service>/events/apps/<slug>. Every connection your users make flows through this single address. - Signing secret: starts with
whsec_and is the base64 of 32 random bytes. It is shown, copied, and rotated whenever you want.
The user does not carry these values to you. They used to: the platform generated a separate address and a separate secret for each connection, and the user typed both into your panel by hand. Until they did, the connection looked green and carried nothing. That step is gone; today the user does two things: installs an app, and says what to connect to what.
Keep the secret in an environment variable, do not commit it to your repository. If you lose it you read it again from the console; there is no "shown only once" rule, because hiding a secret from its owner would be punishment, not security.
Signature: Standard Webhooks v1
Our signature scheme is not specific to us, it is an open specification: Standard Webhooks v1 (standardwebhooks.com). The header names, the signed string and the format of the secret come from there. The gain is on your side: you produce the signature with an off-the-shelf library in most languages, and the code you write is not specific to this platform.
signed string = "<webhook-id>.<webhook-timestamp>.<raw body>"
signature = "v1," + base64(HMAC-SHA256(decoded secret, signed string))| Header | Content |
|---|---|
webhook-id | The ID of the event. It stays the same on a resend and is never reused for a new event. At most 255 printable ASCII characters |
webhook-timestamp | The moment of sending, in Unix seconds. Renewed on every attempt |
webhook-signature | v1,<base64>. You can send more than one signature separated by spaces |
The HMAC key is not the secret itself: the whsec_ prefix is dropped and the rest is base64-decoded; the key is those bytes. Off-the-shelf libraries do this for you. If you compute it yourself, do it like this:
import { createHmac, randomUUID } from "node:crypto";
const rawBody = JSON.stringify(envelope); // THIS is the signed string
const webhookId = randomUUID(); // stays the SAME on a resend
const timestamp = String(Math.floor(Date.now() / 1000));
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signature = createHmac("sha256", key)
.update(`${webhookId}.${timestamp}.${rawBody}`)
.digest("base64");
await fetch(eventsUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"webhook-id": webhookId,
"webhook-timestamp": timestamp,
"webhook-signature": `v1,${signature}`,
},
body: rawBody, // do not re-serialize
});Do not re-serialize the body: the signature is built over the raw string. Calling JSON.stringify twice (once for the signature, once for the body) silently breaks the signature the moment key order changes.
The timestamp window is 300 seconds. A server with a skewed clock gets a 401, and the reason is its clock, not its signature; run NTP on your servers.
The envelope: who, from which port, what
{
"sub": "the user's pseudonym in your app",
"port": "sale-documents",
"data": { }
}- `sub`: the user's pseudonym in your app, 43 characters. You do not generate it: it is whatever the
subclaim of the token we gave you when announcing the subscription says (the heading below). It is the same value as thesubin the embedded panel token, so you already recognise the user in your own records. - `port`: the ID of your output port the event comes from, exactly as you wrote it in your manifest.
- `data`: the value of the type your port declares. Its shape is in the table in 4.2, and that table looks both ways: the shape shown there as "the value arriving at your input port" is exactly the shape you send from an output port of the same type.
The envelope is STRICT: a body carrying anything besides these three fields gets a 400 and the reason is in the response. data cannot be missing or null; an event with no content carries nothing in this system.
This rule was reversed once and the record is kept here. On the previous path the whole body was the port's value and an envelope was FORBIDDEN; because the address was per connection, the address told us the user and the port. The address is now per app, so the only place carrying that information is the body. A sender written against the old version of this document gets a 400: add the envelope and put your data in the data field.
The structured/json and event/webhook-payload types already mean "a free-form object"; there, your object goes inside the data field, it does not replace the envelope.
Subscription: WE tell you who to send for
Every app that declares an output port must have a tool called `kuratowski_subscription` at its MCP endpoint. This is a gate checked at submission: without the tool there is no approval, because a vendor without it can never learn who to send events for.
When a user makes a connection we call that tool with an empty argument ({}). All of the information is in the signed token in the Authorization: Bearer header, so that you never have to reconcile two separate sources:
| Claim | Value |
|---|---|
purpose | "subscription". This is the claim that tells them apart, check it |
aud | Your slug |
sub | The user's pseudonym in your app. The value you will put in the envelope when sending events |
port | Which output port |
action | "subscribe" or "unsubscribe" |
iat / exp | The moment it was minted and 120 seconds later. It is a single-shot token |
Verification is the same verifyMarketplaceToken function from section 9, with the expected purpose being `"subscription"`. The panel token's rules apply here too: exp is required and a repeated jti is refused.
Store the subscription durably. If you keep it in memory, your list is wiped on your first redeploy and the flow stops silently; we cannot see it, because no events arriving from you does not produce an error on our side.
Ordering rule: store the last iat you applied per (sub, port) pair and apply an unsubscribe only if its own iat is greater than the stored one. On a tie, the subscription wins. The reason is that the two mistakes do not weigh the same: staying subscribed by mistake corrects itself with a 403 on the first event, while dropping off by mistake is silent.
Send only for the (`sub`, `port`) pairs you have a subscription for. A pair with no subscription gets a 403, the event is not stored and reaches nowhere. The user may have connected the same port to two different targets; that does not concern you, because a subscription is per user and port, not per target. You send the event once and distributing it is our job.
You never see the targets. Which apps the user connected your data to is not told to you, and our responses never carry the target's name, its reason or its data.
An event has to stand on its own
The event you send must carry everything the receiver needs to do its job. The receiver cannot come back to you and read the data, and that is not a shortcoming but the design itself: the engine gives the target only the value and a token identifying the user (section 10.6). The target holds no credentials for your product, and it would not be right for it to: opening the user's account with you to a third app would go beyond the scope of the connection the user made.
So an event that says "this field of this record changed, fetch the details from our API" does not work here. The receiver cannot make that call; it gets the news of the change, not the change itself. The user has made the connection, it looks green, and useless notifications pile up at the target.
Practical rule: put the changed record in the event, not the record's ID. Including the ID is useful too (the receiver can drop duplicates), but on its own it is not enough.
Do not send an empty array. [] is valid JSON, so it passes the gate and produces a meaningless call at the target. If you have no records left after filtering, do not send at all; we treat this as a sender defect.
Responses
| Code | What it means | What to do |
|---|---|---|
| 202 | The event was accepted and dispatched | Nothing. If removed is not empty, those fields did not reach the target (below) |
| 400 | The body or the envelope does not match the contract; or your payload breaks a rule | Do not send the same body again. message says what happened, and for a guideline violation it says which guideline |
| 401 | The signature, the secret or the timestamp did not match | Retrying gives the same result. Check your secret and your clock |
| 403 | No subscriber for this (sub, port) | Stop sending for this user and port. We will tell you when they subscribe again |
| 404 | No app registered under this slug, or your secret has not reached production yet | Take the address from your console exactly; if it is right, try again shortly |
| 409 | This webhook-id was already used with a different body | Generate a new ID for a new event |
| 413 | The body exceeded the 1 MiB limit | Split the event. The limit is applied before signature verification |
| 429 | One of the user's connections is rate limited | Wait briefly and resend with the same webhook-id |
| 502 | At least one delivery failed | Resend with the same webhook-id. It does not go twice to the ones already delivered |
Every response is a JSON object. error is for machines and is where your code should branch; message is for humans, write it to your log, because the same code can have different causes. When an envelope is rejected, the errors field lists line by line which field failed and why.
A successful delivery can tell you something too. If the removed field in a 202 response is not empty, those fields did NOT reach the target: either they were not declared in your port's shape (guideline 1.1) or they are in the internal or free-text class and do not travel unless the user asks for it explicitly (guideline 1.7). Stripping is not an error, but it should not be silent either: if a field you sent is not visible at the target, look at your response first.
Resending: the same `webhook-id`
An event's ID is fixed for its lifetime. When you send it a second time with the same ID it does not go again to the connections that were delivered, only the failed ones are retried. This is safe as long as the ID and the body match; a request with the same ID and a different body gets a 409, because that is not a retry but a bug in how you generate IDs.
IDs are kept for 24 hours. After that, a body arriving with the same ID counts as a new event, so do your retries inside that window.
The only things worth retrying are 429, 502 and network errors. 400, 401, 403, 404, 409 and 413 give the same answer to the same request; do not spin your queue on them.
When you resend, the webhook-timestamp is renewed and the signature is recomputed with it; the webhook-id and the body stay the same.
Rotating the secret
You rotate your secret from your console whenever you want, and you are offered two options: keep the old one working for one more day (time to move the secret onto your servers) or drop the old one immediately (if the secret leaked). In both cases the new secret takes effect at once.
During the overlap window both secrets are accepted, so no event is lost. If you want a seamless switch you can use the path the specification offers: put two signatures in the webhook-signature header separated by a space (one with the old secret, one with the new); if either matches, the event goes through.
The platform can rotate it too. If we suspect your secret has leaked we rotate it; in that case there is no overlap and you get an email with the reason. The same information is in your console.
If the user stops the flow
The only way for the user to stop a connection is to delete it; there is no on/off switch. When the user deletes the connection, uninstalls the app or deletes their account, you receive action: "unsubscribe" through the kuratowski_subscription tool.
THE PLATFORM can stop it too, and in that case no "unsubscribe" ARRIVES. If your new version drops a user's extension approval (section 16), we stop that connection. Because the connection is not deleted, no unsubscribe notice goes to you; the only thing you see is a 403 on the events you send for that (sub, port). The rule does not change: when you see a 403, stop sending. When the user approves the new state and resumes the connection, you get a new action: "subscribe" and you start sending again from that moment.
The notice may not have reached you (if your server is down at that moment we do not retry). The second gate is always open: an event you send for a (sub, port) with no subscribers gets a 403. When you see that response, drop the subscription on your side too.
If the user has another connection from the same port, no "unsubscribe" arrives, and it should not: a subscription is per user and port, not per connection.
What you see in the Connections tab
In the Connections tab of the app screen you see your event address, your signing secret, your ports, how many connections have been made and the apps whose port types match yours. There are numbers, no identities: you should know whether your product is being used, and you should not learn from us who your customers are.
10.6 When your app is the TARGET of a flow: the identity of the incoming call
Everything up to here was the source side: you were sending events. In the other direction, when a connection writes data to you, we call you: we connect to the MCP address you declared in your manifest and run your input_<portId> tool with the argument {"value": <data>}. That is the shape of the argument and it does not change.
This call arrives authenticated. Every request carries this header:
Authorization: Bearer <connection token>The token is signed with the same key as the panel token in section 9: the same JWKS address, the same RS256, the same iss, and aud is again your slug. What they share is signature verification, not the rules of the panel flow. Call the verifyMarketplaceToken from 9.3 with "dispatch", but do NOT carry over the three checks you wrote for the panel: a hardcoded purpose, requiring exp, and refusing a repeated jti. All three break every delivery with this token. Four differences:
| Topic | Panel token | Connection token |
|---|---|---|
purpose | "panel" | "dispatch". This is the only claim that tells them apart, check it |
exp | iat + 120 s | NONE. The identity lives as long as the connection |
jti repeats | Refuse them (9.4 rule 1) | Do not refuse them. The same token arrives on every call |
cid | Not present | The connection's ID; use it if it is useful to you |
Do not expect an `exp`. If your verification library requires an expiry, turn that off for this token; otherwise every working connection breaks on the first call. Leaving the expiry out is deliberate: there is already a revocation path, and when the user deletes the connection the token is never sent again. An expiry would add nothing to security while adding the risk that a flow nobody touches quietly stops one day. The signing secret on the source side has no expiry for the same reason.
The most important part is `sub`: this value is exactly the same as the sub you see for that user in your panel. You can attach the incoming write directly to that user's rows; you do not need a mapping table in between.
The example below is written against the official MCP SDK. The tool callback's second argument is `extra` and the HTTP headers are there; there is no request object. If you use another framework, what you are looking for does not change: these are all ordinary HTTP headers of the POST request carrying the call: Authorization for identity, X-Kuratowski-Event-Id for deduplication, X-Kuratowski-Test to tell a test delivery apart. If you wrote the transport yourself, you have to pass these three into your tool yourself: if you built a tool interface that only sees the arguments, the deduplication path cannot be built at all and a retried event opens a second record.
// Let inputSchema impose a REAL constraint: the intake gate tests our canonical
// value against it, and passing a schema that constrains nothing proves nothing,
// so it counts as "could not verify".
server.registerTool("input_<portId>", { inputSchema: { value: z.array(z.object({}).passthrough()) } },
async ({ value }, extra) => {
const raw = extra.requestInfo?.headers["authorization"];
// The header type is string | string[] | undefined; if an array arrives, take the first.
const bearer = Array.isArray(raw) ? raw[0] : raw;
const token = bearer?.replace(/^Bearer /i, "");
if (!token) throw new Error("unauthenticated call");
// The SAME function as in 9.3, with the expected purpose "dispatch".
const payload = await verifyMarketplaceToken(token, "dispatch");
// payload.sub -> THE SAME USER KEY you know from the panel
// payload.cid -> the connection's ID (optional)
// ... write value into that user's rows
// A tool MUST return a result; returning nothing does not compile.
return { content: [{ type: "text" as const, text: "written" }] };
});How we connect to your endpoint: transport and session mode
We connect to your endpoint over Streamable HTTP, with the official MCP client. The points below were read out of the dispatcher's code; you should not have to guess, because an app that guesses the opposite simply does not work and the error message does not lead to the cause.
- Every delivery is a new connection. The client opens, runs
initialize, makes theinput_<portId>call and closes. No state is carried between two deliveries. - The identity is on the first request too. The
Authorizationheader is on every request includinginitialize, so there is no unauthenticated handshake round IN A DELIVERY. But do not close your endpoint to unauthenticated requests from the first call: the call that reads yourtools/listresponse during review (4.2) arrives WITHOUT identity, because at that moment there is no connection and therefore no connection token.initializeandtools/listmust work unauthenticated; the place that should require identity is theinput_<portId>calls. If you close the endpoint from the start, your port check fails as "could not verify" and the test in 4.2 never runs. - During review, `input_<portId>` is also called once FOR REAL (guideline 2.10). That call is authenticated: it arrives with the
Authorizationheader a normal delivery carries, so you do not need to write a separate verification path. You see the difference in two headers: it carriesx-kuratowski-test: trueand the token'scidfield iskuratowski-review. Its body is the canonical example of that port type and the values inside it are fictional (example.com,+1 555 01xx). You do not have to process the record: you can see that header and drop the record, write it somewhere separate, or put it through your normal flow. The only requirement is that the tool DOES NOT RETURN AN ERROR; if it does, the submission is not approved, because every connection the user makes would die with the same error. - Stateless mode is enough and it is what we recommend. If you do not return an
Mcp-Session-Id, nothing breaks. - Stateful mode works too. If you give an
Mcp-Session-Idin theinitializeresponse, the client stores it and sends it back on the remaining requests of the same delivery. - But we do not close the session. We do not send
DELETEat the end and we do not reuse the session on the next delivery. If you keep sessions, they must expire on their own; otherwise every delivery leaves a session behind. - We try to open an SSE stream with `GET`, and we close it when the delivery ends. Our client closes on every delivery (
close()), so we do not leave a stream open. If you do not support this, returning405is fine too; the delivery is not affected. - If you use the official server SDK it does NOT return `405`, it opens the stream. Measured (
@modelcontextprotocol/sdk1.30.0): no405is produced forGET;405exists only for methods other thanGET,POSTandDELETE(Allow: GET, POST, DELETE). OnGETthe SDK returns either406(if Accept is wrong) or409(if a stream already exists), and otherwise opens the stream. This is not a problem because we close the stream ourselves; just do not assume you need to do something about it.
What to do on an unauthenticated or invalid call
Do not write. Return a tool error and say what happened. Silently returning success is the worst case, where data is lost and nobody notices; writing it to a random user is worse still.
Returning HTTP 401 works too, but it does not start an authorization flow: the engine has no OAuth client configured and a 401 turns straight into an error. So there is no practical difference between a 401 and a tool error; prefer a readable tool error.
A failed call is retried three times with increasing backoff, then it goes to the dead-letter queue. So a temporary fault recovers on its own and a permanent one does not disappear silently. The waits are 200 ms and 400 ms, so all three attempts fit into a window of roughly 600 ms.
The measures of a delivery: batch, repeats, partial success
These four numbers and rules directly shape the code you write on the target side, and they are not things to guess:
- The upper limit of a batch is 1 MiB and it has already been enforced. The source passes the 1 MiB body limit when sending the event to us (section 10), so the batch reaching you is always under that limit. We do not set a separate ceiling on the NUMBER of records.
- Do not narrow your `inputSchema` by batch length. If you declare an upper limit in the schema, the MCP library rejects a delivery over that limit at the call level and the whole batch fails; the per-record reporting guideline 1.9 asks for never runs at that threshold. Let the schema say the value is an array of records; validate the contents of the records inside the tool.
- WE give you the deduplication key: `X-Kuratowski-Event-Id`. This header is the same across ALL THREE ATTEMPTS of the same event, and it does not change across the resends the source vendor makes with the same `webhook-id` either, and it is never the same for two different events. If an ID you have seen arrives a second time, ignore that call and return success. You can also deduplicate with the record's own ID; together they are better. But you no longer need to fingerprint payloads and invent a time window: an invented window silently swallows work the user really did twice. (The connection token's
jtiCANNOT be used for this, it is the same on every call.) - Partial success is NOT an error. If you accepted some of the records in the batch and rejected others, do not flag the response as an error: if you do, three more attempts arrive and the records you accepted are written again. But do show the ones you rejected to the user on your side: the report you write into
structuredContent.datadoes NOT reach the user's screen on a normal delivery, it only appears in the "Test connection" output.
A test delivery carries `X-Kuratowski-Test: true` and that header is NEVER present on a normal delivery. But there are TWO different calls carrying this header, and you should not mix them up: the call that arrives when the user presses "Test connection" on their connection row, and the one-off port check we make against your input port during review (guideline 2.10). What tells them apart is the token's `cid` field: in the user's test, cid is that connection's ID; in the review check it is the fixed value kuratowski-review and there is NO user involved. If you write the record into a user's ledger, make this distinction; do not write the review check into anyone's ledger.
Still process an incoming test, do not drop it silently. The whole point of the test is to show that the write path really works; if you drop it, the user sees "passed" while nothing has been proven. The right way: write it as you normally would, but make it obvious in your own interface that it is a test record (a tag, a separate section or a short-lived record). That is how the user knows what they are deleting.
Do not return 429 to us. A rate limit on a surface you opened yourself is the right call (below), but if you throttle the deliveries arriving at your MCP endpoint, events are lost: we do not read the Retry-After header, a 429 is a failed call for us and after three attempts it goes to the dead letter queue. The measure against flooding is already on our side: 1 delivery per second per connection, with a burst allowance of 60.
Incoming data is untrusted
A record arriving at you over a connection is text typed on a keyboard by the user of another app. Having passed through the platform does not make it validated: we strip undeclared fields, drop privately flagged records and verify the identity; we do not look at the content and cannot. Whether a product name is really a product name is for you to decide.
- Limit the length. Your schema saying
stringdoes not mean the incoming value will not be two megabytes. - Validate the type. Text can arrive in a field you declared as
number, and an invalid date in a field you declared astimestamp. Do not assume a field is "one we produced". - Escape it when you print it. The incoming text will show up in your interface; an unescaped value is code written by another vendor, running in that user's browser.
- Do not adopt identifiers blindly. If an incoming
party_idorproduct_idwill be written into your own ID space, check for collisions and for the format first. - Look at the sign and magnitude of numbers. A negative quantity, a ratio that divides by zero or an overflowing amount will quietly break your books.
- Store active content as data. A cell starting with
=in a spreadsheet, a macro embedded in a document, a link starting withjavascript:in a text: all of them are text typed on a keyboard by the other app's user, and all of them can turn into something that RUNS in your product. The result of a formula changes the user's books; the same cell goes on to a third program's formula engine through an export. Treat the incoming value as plain text, not as an expression to evaluate. If the user wants to turn it into a formula themselves, that is a separate decision and it is theirs.
This is the complement of guideline 1.9 (checking incoming writes as records): 1.9 asks is the incoming thing a record, the rules here ask is the value inside the record sensible. They are separate questions and neither replaces the other.
When two sources write to the same record
A user can make more than one connection to the same input port of yours, and at the same time they are editing the same record by hand on your own screen. So a business object being written from two places is an expected situation, not an edge case.
The platform does not resolve conflicts. The guarantee we carry is identity: the same event is never written to you twice (the X-Kuratowski-Event-Id above) and every incoming write resolves to a single user (the token's sub field). Beyond that we do not arbitrate. You define what happens when a write arrives from outside for a record that already exists: which write wins, whether the two are merged and what the user is shown are decided by you.
The reason is the platform's own principle: we do not hold data, we carry it. Resolving a conflict means deciding what the truth of that record is, which means holding it; the side that knows the truth is the side that holds the data, and that side is you.
If you do not write a rule you still have one, it just was not chosen: the last write silently overwrites the previous one and the user never sees what they lost.
If you also want to connect your own MCP client
Only the platform can mint a connection token. If you want your users to connect their own tools to the same endpoint, your endpoint has to accept a key you issue yourself as well; that is your design and the platform does not interfere. The one requirement is the same: whatever identity arrives, it must resolve to a single user, and when there is no identity the answer must be an empty set or an error.
Two rules and one recommendation for the surface you open yourself
Opening your own read and write endpoint for extensions is expected and we encourage it: the only place an extension can show what it does is your data. But that endpoint does not go through the platform, so none of the protections the platform applies work there on their own. Two of the following are rules, one is a recommendation.
Rule 1: the same filtering applies there too (guideline 1.8). Privately flagged records and fields in the internal or free-text classes do not leave through your own endpoint either. A record stripped on a connection coming out of the door next to it would destroy the promise we show on the user's screen; for the user there is no such thing as two doors, there is one app.
But the user's OWN screen is outside this, and it has to be. People see their own purchase price, their own cost, their own note; putting a filter on their own panel would mean hiding the user's data from the user. The rule stops data going out, not the user reading their own ledger.
The distinction disappears silently in exactly one place, and that is the name of the trap: if the same identity resolver accepts both your panel session and the API key you hand out, every endpoint of your panel becomes an externally reachable surface without anyone deciding it. Remembering the filter at that point does not help, because the hole is not in the filter but in the identity: a client carrying the key reads purchase prices, free-text notes and the records of flagged people through the panel endpoint.
The right way is to resolve the two identities separately: a key handed out must never get into panel endpoints. Build this check in one place and leave it there; a rule written endpoint by endpoint breaks at the first endpoint someone forgets.
Rule 2: check incoming writes as records (guideline 1.9). At least three checks look at whether the incoming thing is a record: data that would create a new record must carry the field that identifies it (a name, for example); data that arrives without an identifier and repeats the same record within a short window does not open a second record, it is answered with the ID of the existing one; and there is a body limit that is refused before being read. A rejected record does not fail the whole batch, it is reported on its own.
The second of these applies to authenticated calls too, and the reason is measured: the damage happens in the user's ledger. A broken integration sending {} opens nameless records and pollutes the ledger faster than an unauthenticated bot. A rate limit does not close this: a limit counts requests, not records; one request can carry a thousand records.
Recommendation: let your rate limit answer in this shape. It does not bind you, but the extension author on the other side being able to write a single backoff path depends on it; if everyone invents their own body, that person writes separate code for every app, and usually writes none.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 12
{ "error": "rate_limited", "retry_after": 12 }Retry-Afteris in seconds and is the standard header (RFC 9110); it is the first place a client looks. TheRateLimit-*trio is optional, but if you write one, write all three.- Count per key, not per user: a runaway integration should not lock the user's other tools, and the user should be able to revoke that key and carry on.
- Use a token bucket rather than a fixed window: with a fixed window, twice the limit gets through at the boundary of two windows.
- Apply the limit before authentication, otherwise an invalid key gets unlimited attempts.
- If one request can carry more than one call (an MCP
tools/callbatch), count per call; counting per HTTP request makes batching a ready-made way around the limit.
11. After publishing: versions and updates
Every manifest change is a new submission
Changing any field of the manifest means a new submission and a new review: adding a port, changing an address, correcting the category, updating the name. An approved new version overwrites your catalog row.
While a new version is in review, your product stays in the store with its previously approved version. The product appears under My apps and the submission under Pending; that is not a contradiction.
The only thing that does not go through review is the store page (section 8).
Never changes: the slug
Once approved, the slug does not change (guideline 2.3). Users' installs, their connections and the addresses they share depend on it. If you want a different slug, you are submitting a new product; the users of the old one do not move to it.
Changes that break your users
These are not forbidden, but they do damage silently and it is on you to foresee it:
| Change | What happens |
|---|---|
Removing a port or changing its id | Not a silent failure, an immediate stop. The moment your new version is approved, connections that depend on that port (source or target side, with or without the user's extra field approval) are STOPPED: no data flows and the user sees the reason on their own connection screen. If you bring the port back the user can resume the connection; until you do, the connection stays stopped. |
| Changing a port's type | A pair that used to be compatible may no longer be. |
| Adding a sensitivity tag | Adding biometric or health makes existing matches impossible by rule. |
Changing the transport address | Existing connections carry the target address as it was at install time; if the old address closes, the flow stops. |
Changing listingKind | A product that moves from app to extension stays installed for users who are left without a host, but the install rule is now different. |
The right method is the same as in any API: add instead of removing. Define a new port ID, leave the old one working for a while, and explain the move to the user from your panel. Never reuse a port ID with a different meaning: sending different data under the same name goes beyond what the user approved (guideline 1.1).
If you are changing addresses, the order is: verify the new host, submit the new version, keep the old address up until the approval comes through.
If the schema itself changes
The above was about your version. This heading is about OUR side: the manifest schema itself changes over time, and you should know in advance what changes how.
The founding mechanic should be reassuring: the manifest is validated only at submission time. After approval, your catalog row carries its own copy and the runtime never re-reads your manifest. So tightening the schema does not take your live app down; at worst you meet it on your next submission.
| Kind of change | What happens to you | schemaVersion |
|---|---|---|
| Widening: a new optional field, a relaxed rule | Nothing. A manifest that is valid today is valid tomorrow | Unchanged |
| Narrowing: a new required field, a tightened rule | Your live app keeps working; on your next submission you are asked to fill in the missing field and the error message names it | Unchanged |
| Meaning-changing: the same JSON meaning something different | Both versions are accepted for a while and the transition date is written here | Increases |
Before making a narrowing change we scan the catalog and count how many live apps would fail on their next submission; if that number is not zero, what we will tell those vendors becomes part of the change, not an afterthought.
`schemaVersion` has never increased so far and the only valid value is "1". When it needs to increase, the transition date will be written in this section; we are not giving a date today, because it has never happened and promising a process nobody has been through would be wrong.
The version number
version has to be semver, and a new submission has to be higher than the version that is live. Submitting a lower or equal number is refused before it reaches review, because approving an older version would roll your listing, your ports and your users' connections back to it. Until a version is approved you can resubmit the same number during rounds of fixes. Bump the major number on a breaking change and the patch number during fixes.
An app has one submission waiting for a decision at a time. If you send a new one while another is still waiting, it is refused and the answer tells you which submission is waiting. Withdraw that one first, or wait for its decision.
You can withdraw a submission while it is waiting, and also while it is being reviewed. The Withdraw button sits on the submission in your app's Versions tab and only appears while the submission can still be withdrawn; an approved version is live, so it is not withdrawn but taken off the store from the app's Distribution card. Withdrawing does not delete the record: it stays in your history, and a resubmission starts the review from the beginning. There is a limit of 6 withdrawals a day.
The app record comes first, the version second. A manifest whose slug has not been created as an app is refused: create the app at /vendor/apps/new (step 4), then send a version for it. That order is what makes the name yours before anything else is written against it.
Your service has to stay up
Guideline 2.1 is not only about the moment of review: every endpoint you declare has to be callable while you are live as well. But there is no probe reminding you of it: your endpoints are called only during review, not polled periodically after launch. A dead endpoint is not noticed on its own; it is noticed when a user's connection breaks. So staying up is your responsibility, and an app that is permanently unreachable can be removed from the store when we receive a complaint about it.
If you use a service that sleeps on a free plan, know this: most free hosts put an idle service to sleep and the first request can take more than fifty seconds to wake it. Our probe takes that into account and, if no answer comes, tries once more with a long budget. Even so, if your wake-up time goes beyond that, your app looks dead in review. Calling your app once before submitting to wake it, or moving to a paid plan, is the safest path.
If you want to stop your app yourself for a while, the right tool is the distribution switch (section 13), not shutting the service down.
12. User data and your responsibility
You are the data controller
For the data users give your app, you are the data controller (agreement clause 5). Publishing your own privacy policy, answering users' data requests and complying with the law are your obligations. The platform makes no commitment on your behalf here.
What your privacy policy has to say
Giving the https address of your privacy policy on your store page is required (section 8), but giving an address is not enough: guideline 3.6 also sets requirements for the content. It has to answer three questions clearly.
| Question | What to write |
|---|---|
| What data do you collect | The fields your app stores. Records the user enters, what comes in over the connections they made, and logs if you keep any. |
| How do you collect it | What the user types on screen, files they import, records arriving over connections. Write what you do NOT collect as well: if you do not track behaviour, say so. |
| What do you use it for | The app's own job. And explicitly what you do NOT do: selling, advertising, combining data across users, training models. |
You also write your retention period and how a user can have their data deleted; we do not impose a fixed period (agreement clause 5), it only has to be reasonable and written down.
These three questions are skipped often, and the reason is on us: even a vendor who writes a complete policy tends to cover "what I collect" and "how it is deleted" and skip "how I collect it" and "what I use it for". The user reads this text before installing: installing means starting to pour their data into your system.
Do not re-identify the user
sub is a pseudonym derived specifically for you. These are forbidden:
- Trying to match it with another vendor's.
- Attempting to reverse it.
- Asking the user to reveal their real identity outside the platform.
You cannot use a token issued for one user for another user, for another app or for your own internal purposes (guideline 1.4).
You do not get an uninstall notice
When a user removes your app from their library, no notification goes to you. That is the situation today and it has two consequences:
- You have to set your own rule for how long you keep the user's data, and write it in your privacy policy.
- Leave the user a way to send you a deletion request. Do not expect a trigger from the platform at the moment of uninstall.
Leaving the vendor programme does not end the obligations you have taken on towards your users either; you are expected to keep their data accessible for a reasonable period (agreement clause 9).
13. User reviews, withdrawal, suspension
User reviews
You cannot hide or delete user reviews. You can report a review you believe is abusive, exactly as a user can; the platform decides. You have the right to reply once to each review, and your reply appears under it as a developer response.
Two hard rules:
- Rating manipulation is forbidden (guideline 4.1): you cannot offer incentives in exchange for a review or a rating, write ratings from fake accounts, or pressure a user into changing a negative review.
- No personal data in replies (guideline 4.2): you cannot share anything about the user's account, their email or their usage records. Not even in an argument you are right about.
Removal from the store and suspension
Two different situations with different consequences:
| Situation | Who does it | Result |
|---|---|---|
| Removal from the store | The vendor or the platform | Not visible in the store, no new installs. Existing users keep using the app and tokens keep being issued. |
| Suspension | The platform only | New installs are refused, no token is issued at all, the frame does not open. The user sees the reason on screen. |
This split is the App Store and Steam model: stop selling and cut off existing users are not the same decision.
A vendor cannot put a suspended product back. If they could, a suspension would turn into a suggestion you could undo with one click. Suspension is applied when a live app turns out to break one of the guidelines, and it is shown in your vendor console with its reason (guideline 5.3).
14. The most common reasons for rejection
Read this list before you submit. All of them are real and all of them are avoidable.
- Unverified host. The address in the manifest is not verified. The submission never enters review.
- No demo account, or an expired one. An app that requires signing in cannot be opened and review cannot be completed (guideline 2.5).
- The panel does not open in the frame.
X-Frame-Options: DENYor a missingframe-ancestors(guideline 2.4). - An endpoint is down. The health probe gets no answer or gets a 5xx (guideline 2.1).
- The kind is declared wrongly. A product that shows an empty screen with no connections declared itself an app (guideline 2.6).
- A sensitivity tag is missing. The payload carries a real name or email but there is no
customer-pii(guideline 1.2). - A sensitive port has no reason. The tag is there but
sensitivityReasonis empty; the submission comes back from the schema (guideline 1.2). - The category was chosen by marketing. A group was written instead of a leaf, or a side feature was presented as the main job.
- An input port is wired to a `GET` route. The data is silently never delivered.
- The store content is unfinished. Coming soon screenshots, placeholder text (guideline 3.4).
- Paid pricing. An app that is not
freecannot be listed during this period (guideline 3.2). - No privacy policy URL. A required field on the store page (guideline 3.6).
- Real customer data in a screenshot. A real name, email or invoice line is visible (guideline 1.5).
- The screenshot does not show the product. Only a logo or a sign-in screen (guideline 3.8).
15. Pre-submission checklist
- My vendor application is approved.
- Every host in the manifest shows as verified.
- The verification file is still published.
- I decided
listingKindwith the experiment in 1.2: is it useful when it has no connections at all? - If I am an extension, I have at least one input port.
- The category is the most specific leaf.
- Every port's
descriptionis clear enough to tell it apart from the other port of the same type. - Every port carrying personal, health, financial or biometric data is tagged.
- Every port I tagged has its
sensitivityReasonfilled in. - Every record-carrying output port of mine has a
shape, and I did not hide declarable fields insidejsonorbinary; I can explain why the remaining opaque field could not be opened up. - The private flag and the field classes are filtered on every surface where data leaves the user.
- My party port carries the flag: I do not pull a flagged person, I send them with
_private: true. - When a party record arrives with
_private: true, I flag my own records linked to that ID. - I resolve my panel session and the key I hand out SEPARATELY; the key cannot get into panel endpoints.
- I check writes coming from outside as records: nameless records, repeated records, oversized bodies.
- I validate the length and the type of incoming values, and escape them when printing.
pricingis an object in the form{"model": "free"}.- My port IDs start with a lowercase letter and contain only
a-z0-9_-. - My panel URL is
httpsandframe-ancestorsauthorises the platform. algorithms,issuerandaudienceare all pinned in my token verification.- I refuse a repeated
jti. - I drop the token from the address bar.
- My development identity is OFF in production and what turns it off is code: if that variable is defined, my app does not start.
- I send
Referrer-Policy: no-referrer. - If signing in is required, the demo account works and does not expire.
- My store images are
https, reachable and in an accepted format (not SVG). - There is no real customer data in my screenshots, all of it is fictional.
- My privacy policy is published and I put its address on the store page.
- My privacy policy says what I collect, HOW I collect it and WHAT I use it for.
- My support address is read.
- I chose my primary language and filled in the translations for the languages I want to publish in.
- All of my translations describe the same product, the same features and the same limits.
16. What does not exist today
An honesty section. These are known limits, so that they do not come as a surprise:
- A reply in the thread does not email you. A decision does (section 7) and so does a suspension, but when a reviewer writes a later message in that submission's thread, nothing leaves the console. If you asked a question there, come back and look.
- Field class permission now exists, but the default is still closed. Section 4.6 says
internalandfree-textfields travel "if the user explicitly asks"; the surface where the user says that opened on 2026-09-12. Permission is given per field and through a separate action, so making a connection on its own opens no closed class. If you are on the target side, do not assume these fields will arrive: on a connection without permission they do not, and theremovedlist in the202response says which ones were stripped (section 10). - Store page checks are silent. Your change lands in the queue but you are told nothing: whether it was read or not, your page stays live. The only thing you will hear about is a suspension decision (guideline 5.3).
- No uninstall notice. See section 12.
- Connections of a removed port are NOT migrated automatically (but they are no longer silent either). When your new version deletes a port or changes a field's name or class, the platform does not move those connections to a new port for you: the responsibility for a breaking change is yours. It does not continue silently either: the moment your version is approved, the extension approvals of live connections are re-evaluated against your new
shape, a connection that no longer matches is stopped and the user is told why (section 10). A stopped connection carries no data until the user approves the new state. - Translations do not reach search (section 8): language changes how your product looks, not how it is found.