Privacy executor contract (v2)
A rights orchestrator — Cookie Munch, OneTrust, Transcend, DataGrail, Ketch, Osano, or one you wrote — fulfils a data-subject request against your organization's Forumulate data through this contract. It is plain HTTPS and JSON with a bearer key, and nothing in it is specific to one vendor. Version 1 still works unchanged; this page describes version 2.
Integrating a product with Forumulate? Already built for you lists every contract that already exists, so you do not rebuild one.
Connect
Both Cookie Munch calls below (PUT …/consent with connect, and POST …/privacy/cookiemunch/connect) accept any Cookie Munch organisation key, cookiemunch_live_org_… or a legacy fck_…. A key is checked by asking Cookie Munch (GET /v1/me, which also reads its scopes), never by its prefix: a key Cookie Munch refuses is a 422 naming orgProvisioningKey, and nothing is changed. In a manifest the same link is privacy: {executor: cookiemunch} (see forumulate.yaml apply).
Mint the key:
POST /api/orgs/{org}/privacy/executor-key(needsorg.manage_settings). The key (forumulate_live_exec_…; older keys startfxk_) is shown once. Only its hash is stored, so a lost key is rotated, never recovered. A key lives one year: the mint answer carriesexpiresAt(365 days from the mint), the organization's owners are e-mailed 30, 7 and 1 days before, and anexecutor_key.expiringwebhook goes out at the same three moments so the orchestrator's owner can rotate in time. If you rotate it, the orchestrator stops working until it has the new key; send{"graceMinutes": 60}to keep the old key working while you update it. With Cookie Munch,PUT /api/orgs/{org}/consentwith{"provider":"cookiemunch","connect":{"orgProvisioningKey":"cookiemunch_live_org_…"}}mints the key and registers it with Cookie Munch server to server. The key is never printed. That one call also creates (or rolls) the Cookie Munch consent webhook and stores itswhsec_…here, so neither secret passes through a person; afterwardsGET …/privacy/executor-keyand theexecutorblock ofGET …/consentreadenabled: true. A partner platform (Cookie Munch, or any CMP registered the same way) can also mint it under its link, with no Forumulate token: a signed, single-usePOST /api/orgs/{org}/privacy/executor-keyanswered with{ secret, executorUrl }. See Becoming a partner platform.Point the orchestrator at the base URL
https://forumulate.com/api/privacy/executor/{org}and choose version 2 with one of these:send
X-Version: 2on every request;use the base
…/privacy/executor/{org}/v2, for an orchestrator that can set a URL but not a header. The first 16 characters of every key are the same (forumulate_live_), so a console that shows only the first 16 cannot tell two keys apart. The API'skeyPrefixis the hintforumulate_live_exec_AbCd…WxYz(the first characters after the constant part, and the last four), which does tell them apart; compare that, not a 16-character cut.
Authenticate every call with
Authorization: Bearer forumulate_live_exec_…. A wrong key, a missing key and an org with no key all get the same401.Optional: tell us where to report finished requests:
PUT /api/orgs/{org}/privacy/executorwith{"completionWebhook":{"url":"https://…","secret":"whsec_…"}}.
Check the connection with GET /api/orgs/{org}/privacy/executor (state: key metadata, deletion mode, retention, completion webhook, last request) and POST /api/orgs/{org}/privacy/executor-key/test (does this key match, does the webhook deliver).
Connect Cookie Munch in one call
When only the executor needs connecting (your Cookie Munch consent is already set up), one call does it, and the console has it as Connect Cookie Munch under Settings → Privacy:
POST /api/orgs/{org}/privacy/cookiemunch/connect
{ "orgProvisioningKey": "cookiemunch_live_org_…", "cbid": "<site id>" }orgProvisioningKeyis a Cookie Munch key withdsar:write. Cookie Munch's connection API takes an organisation key and nothing else, so this call needs one. It is used for this request only and never stored.cbiddefaults to the site in your Cookie Munch consent config.An existing connection is adopted or replaced, whoever registered it. Cookie Munch keeps one connection per system and site. Forumulate lists Cookie Munch's connected systems and clears every one that points at this organization's executor, and every
forumulateconnection on this site (including one you registered by hand, and one that names no site), then registers its own (contract v2, base…/privacy/executor/{org}/v2). If Cookie Munch still answers409because a connection appeared in the meantime, Forumulate lists again, clears it and registers once more. A409is never passed on to you for a connection Forumulate can clear.It first asks Cookie Munch to mint the key itself under its partner link (
mintFrom: "forumulate"): Cookie Munch sends a signed request to…/privacy/executor-key, so the key never crosses our call. If Cookie Munch cannot (no live link to Forumulate on that site, or this organization has not chosen Cookie Munch), Forumulate rotates the key (the old one keeps working for an hour) and sends it server to server.No key is left to die. If anything fails after Forumulate rotated the key (Cookie Munch refuses the registration, or cannot be reached), the rotation is undone: the new key stops working at once, and the key you had is current again with its own expiry, as if the connect had not run. The connection Forumulate cleared is not restored, so run the connect again once the cause is fixed.
The answer is state only. No secret is ever in it:
{
"linked": true, "connectionId": "cx_…", "cbid": "…", "mode": "pull",
"linkedAt": "2026-10-30T12:00:00.000Z", "executorEnabled": true,
"keyPrefix": "forumulate_live_exec_AbCd…WxYz", "keyExpiresAt": "…",
"consentWebhookSecretSet": true, "executorUrl": "https://forumulate.com/api/privacy/executor/{org}"
}GET /api/orgs/{org}/privacy/cookiemunch answers the same status. Both need org.manage_settings. mode is pull when Cookie Munch minted the key and push when we sent it. A refused key is a 422 naming what is wrong, and nothing is changed.
Dry run. POST …/privacy/cookiemunch/connect?dryRun=true checks the key and the site the same way, then answers the plan and changes nothing. Only read calls reach Cookie Munch (the connection list, and its partner-link lookup):
{
"dryRun": true, "cbid": "…",
"wouldMint": "rotate-and-send",
"existingConnection": { "id": "dv-mvujd86o-8uznzhfo", "system": "forumulate" },
"action": "replace"
}wouldMintiscookiemunchwhen Cookie Munch can mint the key under its partner link: this organization lets Cookie Munch mint for this site, and Cookie Munch reports a live link for it. Otherwise it isrotate-and-send.actioniscreatewhen Cookie Munch holds no connection of ours,adoptwhen one already points at this organization's v2 executor (it is re-keyed), andreplacewhen aforumulateconnection on this site points elsewhere, such as one registered by hand.existingConnectionis that connection, ornull.
Headers
Header | Direction | Meaning |
|---|---|---|
| in | The org's executor key. |
| in and out | The contract version. Every v2 answer carries it. |
| in | Required on |
| in and out | Your trace id (or a W3C |
Find the subject: POST /lookup
{ "subject": { "iss": "https://auth.example.com", "sub": "user_123", "ext": "merchant:9f1c", "principal": "merchant" },
"emails": ["ana@example.com"] }The answer is {"subjectId": "…" | null, "status": "found" | "no_match", "matchedOn": "subject" | "ext" | "email" | "status_subscriber" | null}, plus "canary": true on an answer about a conformance canary.
Lookup runs in this order, and stops at the first match:
subject.iss+subject.sub. These are matched against the reader identities of every sign-in method in this org whose configured issuer isiss, so they work with any OIDC or OAuth2 identity provider: Auth0, Clerk, Okta, Cognito, Keycloak, Firebase, Supabase, WorkOS, Entra, or your own Atlas instance. Trailing slashes and case in the issuer are ignored. Readers who sign in withatlas_byoare matched here, by the Atlas identity's ownsub(thesubclaim of the Atlas token, withissthe instance's issuer), never by their address and never by your product's user id: send what Atlas callsidentity.subassubject.sub. A reader who first signed in underexternal_jwtand later under Atlas has two bindings (one periss), and a request that names either finds the same member. Closure and deletion remove every binding of that member (each(iss, sub)row ofreader_identities, the legacy per-method link and the recorded sign-ins), so erasing through the Atlas subject leaves no reader record behind, whichever method created it.subject.ext. This is your product's own account id: thesubof anexternal_jwtsign-in.The address. Used only when no subject key was sent at all. If you send a subject key and it matches nobody, the answer is
no_match. We never fall back to the email: an address can change hands, and the person you mean is the one your identity provider knows.An email subscriber with that address who has no account here: a status page's subscribers, and every other list that takes an address (a blog's newsletter, changelog, FAQ or careers followers).
matchedOnis stillstatus_subscriber, and the erased count is stillstatusSubscriptions, so existing integrations read it unchanged. ThesubjectIdis thenemail:<sha256>.
atlas is accepted as another name for subject. A person who belongs to another organization is no_match.
Conformance canaries
An orchestrator that checks an executor against this contract (Cookie Munch's executor Test and its conformance run) needs a person to run its checks on, and that person must never be real. We follow the data-rights executor protocol's canary subjects: an address at a domain the orchestrator owns, <prefix><token>@<domain>, and identities under an issuer it owns. The token is 8 to 64 lower-case letters or digits. Cookie Munch's canary:
Key | Pattern |
|---|---|
| |
|
|
|
|
The same list is in GET /api/orgs/{org}/privacy/executor as canaries (id, email, iss, sub, ext, token, ttl_seconds: 86400).
A canary is a synthetic person:
Lookup. Its email, its
sub(with the canaryiss) and itsextall answerfoundwith the samesubjectId:canary_and 24 hex characters, fixed per canary token and per organization. Any other address at the canary domain isno_match. Asubunder the canary issuer that is not a canary isno_matchtoo, and an email never rescues it. Every canary answer carries"canary": true. Mixing a canary address with a real one is a real lookup.Requests. They go through the same validation and the same
Idempotency-Keyreplay (and409for a key reused with another body) as real ones, and follow the same states:A deletion or closure is
scheduleduntilexecuteAfter, withetaequal to it. WithoutexecuteAfterit is due one minute later, never after the organization's own grace period.Any other type is
pendingfor a second.When due, it is
processingfor about a second. A deletion or closure then becomescompleted_with_retention(retentionaudit_log,legal_obligation); any other type becomescompleted. An access request'sresult.downloadUrl(andzipUrl) serves a small synthetic package:{"canary": true, "subject": {"id": "canary_…"}, "note": …, "markdown": []}.A cancel before the request runs answers
cancelled, and the canary is still found afterwards. A cancel after it runs is a409.
Never real. A canary request never reads or writes a member, never erases anything and never sends a completion webhook. It is not in
GET /requestsand does not count as the organization's last request. Every answer about it carries"canary": true. Canary requests are kept for 24 hours, then removed; after that,GETanswers404.
There is no canary flag in the lookup body: a canary is recognised by its subject alone. The canary is a v2 answer; a v1 lookup (no X-Version: 2, on …/privacy/executor/{org}/lookup) is unchanged.
Open a request: POST /requests
{ "subjectId": "…", "type": "erasure", "caseId": "DSR-1042",
"executeAfter": "2026-11-20T00:00:00Z", "reason": "Art. 17 request" }Request types
| Also accepted | What happens in this organization |
|---|---|---|
|
| Exports the person's data from this organization only: profile, memberships, topics and posts, reactions, standing (internal and external), subscriptions (including status pages), bookmarks, profile fields, course and event records, the identities and IdP sign-ins this organization knows them by ( |
|
| Everything closure does, plus everything about the person, and their content according to the org's |
|
| Removes the person from every space. Also removes their standing (ours and external), status subscriptions, notification subscriptions and every identity this org knows them by: each |
|
| Keeps everything. Suspends the person here (they cannot post and are hidden) until a moderator lifts it. |
|
| Applies |
The answer echoes requestedType (what you sent) and canonicalType (what we did).
Deletion mode, per organization
Set deletionMode with PUT /api/orgs/{org}/privacy/executor:
anonymize(the default). The person's topics and posts stay in their threads, re-attributed to a shared "Former member" with the profile stripped. The result counts them underanonymized.erase. Each post's text is removed and the post is marked deleted. Its number and the replies to it stay, so the thread still reads in order. A topic nobody else posted in is removed entirely.
Retention
Some records must be kept even after a deletion. Each one is listed in retention:
[{ "category": "legal_acceptance", "basis": "legal_obligation", "until": "2032-10-24T09:12:00.000Z" }]Records of accepting a legal document are kept for retention.legal_acceptance years after the latest acceptance. The default is 6. Set it to 0 to erase them with everything else. A deletion that keeps anything ends as completed_with_retention.
Scheduled requests
With executeAfter in the future, the request is scheduled and its eta is that instant. Nothing runs before it, whatever any clock says. The sweep compares with the database's own clock. POST /requests/{id}/cancel withdraws a pending or scheduled request, and its data is untouched. A request in any other status answers 409 naming that status.
Status: GET /requests/{id}
{ "id": "…", "type": "deletion", "canonicalType": "deletion", "requestedType": "erasure",
"status": "completed_with_retention", "subjectId": "…", "caseId": "DSR-1042",
"executeAfter": null, "eta": null, "detail": null,
"retention": [ … ], "result": { "deletionMode": "anonymize", "erased": { … }, "anonymized": { "posts": 14, "topics": 2 }, "accountDeleted": true },
"createdAt": "…", "updatedAt": "…", "finishedAt": "…", "trace": "…" }
| Terminal | Meaning |
|---|---|---|
| no | Accepted, not started. |
| no | Waiting for |
| no | Running. |
| yes | Done. |
| yes | Done. Some data is kept on a legal basis, listed in |
| yes | Will not be done. The reason is in |
| yes | Nobody by that subject in this organization. |
| yes | Withdrawn before it ran. |
An access request's result carries downloadUrl (the JSON) and zipUrl (the JSON plus posts/*.md). Both are under your base URL and need the same key. They work for 7 days; after that they answer 410.
Anonymised posts are completed, not completed_with_retention. Once a post is re-attributed to "Former member" and the profile is stripped, it is no longer about an identifiable person (GDPR Recital 26). Nothing about the subject is being kept. Retention is only for personal data we hold on a legal basis.
List: GET /requests
Every request opened in this organization, for reconciling your case log with ours after an outage on either side. Same key, same organization scope.
GET /requests?status=pending,scheduled&type=deletion&since=2026-10-24T00:00:00Z&limit=50{ "data": [ { "id": "…", "status": "pending", "caseId": "DSR-1042", … } ],
"page": { "limit": 50, "hasMore": true, "nextCursor": "eyJ2Ijox…" } }Each row is the same object
GET /requests/{id}answers.Order: oldest change first (
updatedAt, thenid). A request that changes while you walk moves to the end, so walking untilhasMoreisfalsegives you the latest state of every request.sincekeeps requests updated at or after that instant (ISO 8601 with an offset). A request opened last week that finished an hour ago is included. To sweep: note the time you start a walk, and send it assincenext time.statusandtypetake one value or a comma list, in any case.typetakes the five canonical names (not the aliases).limit: 50 by default, 200 at most (a larger value is capped).cursor:page.nextCursorfrom the previous page. There is nototal. Walk onhasMore.An unknown status or type, a malformed
sinceorlimit, or a cursor from any other list is a422naming the parameter. It is never an empty page.
The list is v2 only. It is also served at the v1 base path (/privacy/executor/{org}/requests), always in the v2 shape: v1 keeps no request rows to list.
Completion webhook
Every request that reaches a terminal status is reported to your completion webhook. Failed deliveries are retried with backoff for about a day.
POST <your url>
webhook-id: evt_<request id>_<status>
webhook-timestamp: 1761300000
webhook-signature: v1,<base64 HMAC-SHA256 of "<webhook-id>.<webhook-timestamp>.<body>">
x-forumulate-trace: <the request's trace>
x-forgedeck-trace: <the same, under the header's first name>
user-agent: Forumulate-Privacy-Executor/2.0
{"id":"evt_…","type":"privacy.request.completed","organizationId":"…","timestamp":"…","data":{ …the request… }}This is Standard Webhooks, so any of its libraries verifies it. If the secret has the form whsec_<base64>, the HMAC key is the base64-decoded bytes; any other secret is used as its raw UTF-8 bytes. Deduplicate deliveries on webhook-id.
Vocabulary map
What each orchestrator calls things, and what we call them:
Orchestrator word | Here |
|---|---|
Cookie Munch |
|
Atlas |
|
CCPA |
|
DataGrail |
|
"done" |
|
"failed" |
|
Version 1
Version 1 is unchanged: POST /lookup {email | externalId}, POST /requests {subjectId, type: access|deletion}, GET /requests/{id}, and GET /requests/{id}/export.
It performs a request when it is opened.
It still refuses (
409) to delete a person who belongs to more than one organization, because v1 deletes the whole account.v2's org-scoped deletion is the answer to that case.
resultUrlnow names the org segment of the export path. Before this fix it pointed at a path that answered 404.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.
Cookie Munch Link
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the Cookie Munch executor link |
| |
| Connect the privacy executor to Cookie Munch |
|
Executor Admin
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Privacy executor connection state |
| |
| Set the privacy executor’s deletion mode, retention and completion webhook |
| |
| Change part of the privacy executor’s settings |
| |
| Test the privacy executor key and completion webhook |
|