Privacy executor contract (v2)

Embed
Preview of the card
<a href="https://forumulate.com/guides/privacy-executor-contract"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/56433869-8696-41ce-bcd8-c9ac93350aa7/social-card.svg?v=3e7ae33eec8b" alt="Privacy executor contract (v2)" width="600" height="315"></a>

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).

  1. Mint the key: POST /api/orgs/{org}/privacy/executor-key (needs org.manage_settings). The key (forumulate_live_exec_…; older keys start fxk_) is shown once. Only its hash is stored, so a lost key is rotated, never recovered. A key lives one year: the mint answer carries expiresAt (365 days from the mint), the organization's owners are e-mailed 30, 7 and 1 days before, and an executor_key.expiring webhook 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}/consent with {"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 its whsec_… here, so neither secret passes through a person; afterwards GET …/privacy/executor-key and the executor block of GET …/consent read enabled: 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-use POST /api/orgs/{org}/privacy/executor-key answered with { secret, executorUrl }. See Becoming a partner platform.

  2. 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: 2 on 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's keyPrefix is the hint forumulate_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.

  3. Authenticate every call with Authorization: Bearer forumulate_live_exec_…. A wrong key, a missing key and an org with no key all get the same 401.

  4. Optional: tell us where to report finished requests: PUT /api/orgs/{org}/privacy/executor with {"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).

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>" }
  • orgProvisioningKey is a Cookie Munch key with dsar: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.

  • cbid defaults 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 forumulate connection 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 answers 409 because a connection appeared in the meantime, Forumulate lists again, clears it and registers once more. A 409 is 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"
}
  • wouldMint is cookiemunch when 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 is rotate-and-send.

  • action is create when Cookie Munch holds no connection of ours, adopt when one already points at this organization's v2 executor (it is re-keyed), and replace when a forumulate connection on this site points elsewhere, such as one registered by hand. existingConnection is that connection, or null.

Headers

Header

Direction

Meaning

Authorization: Bearer forumulate_live_exec_…

in

The org's executor key.

X-Version: 2

in and out

The contract version. Every v2 answer carries it.

Idempotency-Key

in

Required on POST /requests, 16–128 characters, one per request. A replay within 24 hours returns the original request with Idempotent-Replayed: true. The same key with a different body is 409 idempotency.key_reused.

X-Forumulate-Trace or traceparent

in and out

Your trace id (or a W3C traceparent), logged with the request, stored on it, echoed back and sent on its completion webhook. If you send none, we mint one. The header's first name, X-ForgeDeck-Trace, is still accepted, and every answer and webhook carries the id under both names.

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:

  1. subject.iss + subject.sub. These are matched against the reader identities of every sign-in method in this org whose configured issuer is iss, 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 with atlas_byo are matched here, by the Atlas identity's own sub (the sub claim of the Atlas token, with iss the instance's issuer), never by their address and never by your product's user id: send what Atlas calls identity.sub as subject.sub. A reader who first signed in under external_jwt and later under Atlas has two bindings (one per iss), and a request that names either finds the same member. Closure and deletion remove every binding of that member (each (iss, sub) row of reader_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.

  2. subject.ext. This is your product's own account id: the sub of an external_jwt sign-in.

  3. 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.

  4. 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). matchedOn is still status_subscriber, and the erased count is still statusSubscriptions, so existing integrations read it unchanged. The subjectId is then email:<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

email

canary-<token>@canary.cookiemunch.net

subject.iss + subject.sub

https://canary.cookiemunch.net + canary_<token>

subject.ext

canary:<token>

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 canary iss) and its ext all answer found with the same subjectId: canary_ and 24 hex characters, fixed per canary token and per organization. Any other address at the canary domain is no_match. A sub under the canary issuer that is not a canary is no_match too, 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-Key replay (and 409 for a key reused with another body) as real ones, and follow the same states:

    • A deletion or closure is scheduled until executeAfter, with eta equal to it. Without executeAfter it is due one minute later, never after the organization's own grace period.

    • Any other type is pending for a second.

    • When due, it is processing for about a second. A deletion or closure then becomes completed_with_retention (retention audit_log, legal_obligation); any other type becomes completed. An access request's result.downloadUrl (and zipUrl) 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 a 409.

  • 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 /requests and 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, GET answers 404.

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

type

Also accepted

What happens in this organization

access

export, portability, know, dsar

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 (signIn), and the receipts of what they accepted. The export is one JSON document plus one Markdown file per post.

deletion

erase, erasure, delete, forget, right_to_be_forgotten

Everything closure does, plus everything about the person, and their content according to the org's deletionMode (see below). The account itself is deleted only when no other organization still has the person.

closure

close, account_closure, deactivate

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 (iss, sub) binding, the legacy per-method link and every recorded IdP sign-in, whose sessions are revoked and whose reader cookies stop working. A later sign-in under the same (iss, sub) is a new member, never the closed one reattached. Revokes all their sessions when no other organization has them. Their content stays theirs.

restriction

restrict, object

Keeps everything. Suspends the person here (they cannot post and are hidden) until a moderator lifts it.

rectification

rectify, correct, correction, update

Applies data: {name?, fields?}. Custom profile fields are this organization's own records. The display name belongs to the account, so it is changed only when this is the account's only organization.

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 under anonymized.

  • 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": "…" }

status

Terminal

Meaning

pending

no

Accepted, not started.

scheduled

no

Waiting for executeAfter (eta).

processing

no

Running.

completed

yes

Done.

completed_with_retention

yes

Done. Some data is kept on a legal basis, listed in retention.

rejected

yes

Will not be done. The reason is in detail, for example the org's only owner, or a rectification with no data.

no_match

yes

Nobody by that subject in this organization.

cancelled

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, then id). A request that changes while you walk moves to the end, so walking until hasMore is false gives you the latest state of every request.

  • since keeps 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 as since next time.

  • status and type take one value or a comma list, in any case. type takes the five canonical names (not the aliases).

  • limit: 50 by default, 200 at most (a larger value is capped).

  • cursor: page.nextCursor from the previous page. There is no total. Walk on hasMore.

  • An unknown status or type, a malformed since or limit, or a cursor from any other list is a 422 naming 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 export / erase

access / deletion

Atlas erasure

deletion

CCPA know / delete

access / deletion

DataGrail portability

access

"done"

completed, completed_with_retention or no_match

"failed"

rejected or cancelled

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.

  • resultUrl now 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.

Operation

Request

What it does

Shortcuts

CookieMunchLink_status

GET /orgs/{orgId}/privacy/cookiemunch

Get the Cookie Munch executor link

client.privacy.cookieMunchLink.status()

CookieMunchLink_connect

POST /orgs/{orgId}/privacy/cookiemunch/connect

Connect the privacy executor to Cookie Munch

client.privacy.cookieMunchLink.connect()

Executor Admin

Operation

Request

What it does

Shortcuts

ExecutorAdmin_state

GET /orgs/{orgId}/privacy/executor

Privacy executor connection state

client.executorAdmin.state()

ExecutorAdmin_update

PUT /orgs/{orgId}/privacy/executor

Set the privacy executor’s deletion mode, retention and completion webhook

client.executorAdmin.update()

ExecutorAdmin_patch

PATCH /orgs/{orgId}/privacy/executor

Change part of the privacy executor’s settings

client.executorAdmin.patch()

ExecutorAdmin_test

POST /orgs/{orgId}/privacy/executor-key/test

Test the privacy executor key and completion webhook

client.executorAdmin.test()

The Forumulate teamUpdated