Privacy, GDPR and data requests

Embed
Preview of the card
<a href="https://forumulate.com/guides/privacy-and-gdpr"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/4c657fd5-f71d-4489-b51c-79890b293435/social-card.svg?v=3e7ae33eec8b" alt="Privacy, GDPR and data requests" width="600" height="315"></a>

This page is about the mechanics: which switch, which endpoint, which screen, for each thing a data-protection regime asks an organization to be able to do. It is not legal advice, and it is not our own privacy policy — that is at forumulate.com/policies/privacy and it is the document that actually binds us.

Who is responsible for what

For the people in your organization and the readers of your spaces, you are the controller: you decided to collect their words, their email addresses and their sign-ins. We are your processor, and we hold that data to run the service you asked for.

For your own staff account — your name, your email, your billing — we are the controller, and the policy linked above is the one that applies.

That split is the reason the two halves of this page look different. Your readers' requests come to you and you answer them with the tools below; your own request comes to us.

What is stored about a person

What

Where it comes from

How to see it

Account

Sign-up, or the identity provider they signed in with

GET /api/users/me

Membership and role

An invitation you sent, or a reader sign-in

Members in the console

What they wrote

Pages, posts, replies, comments, reactions

The space it is in

Files they uploaded

The editor, or a reply

Files in the console

Reader sign-in identity

Their provider's subject id, kept so the same person returns to the same member

Members → the member

Consent receipts

Your CMP, if you run one

GET /api/public/orgs/{orgId}/spaces/{spaceId}/consent/receipts

Audit records

Staff actions, kept so a change can be attributed

GET /api/orgs/{orgId}/audit/export.csv

Page views and searches are recorded as aggregates by default, which is why a space's analytics can tell you what readers looked for without telling you who looked.

How a view is counted. A docs page or forum topic counts one view per reader per day. Crawlers, uptime and performance tools, HTTP libraries and browser prefetches are not counted. To tell one reader's repeat visits from another reader's first visit, the server keeps a keyed hash of the signed-in member, or of the address and browser for a signed-out reader. The hash cannot be turned back into either, and it is deleted after two days. A topic's view count is updated every few minutes, so a new view can take up to five minutes to appear.

Access requests (Article 15)

A person can export their own data without asking anybody:

GET /api/users/me/export

It answers with everything that account holds across every organization it belongs to — profile, memberships, content, and the identifiers any linked provider sent. Account → Data & privacy in the console is the same export with a button on it.

For a whole organization, including every space and its content:

GET /api/orgs/{orgId}/export

That one is an organization export, not a personal one: it is the right answer to "give us our data back", and the wrong answer to a subject access request about one reader.

Erasure (Article 17)

POST /api/users/me/delete

Deleting an account removes the account and the personal data attached to it. What a deletion does not silently do is tear the content it wrote out of a space other people are still reading — an answer in a forum thread is part of somebody else's conversation. Decide that deliberately for each case, and say in your own policy which way you go.

Audit records outlive the account on purpose. An audit log that can be erased by the person it describes is not an audit log, and every regime that asks you to keep one says so.

Automating requests end to end

If you run a privacy or consent platform that issues data-subject requests, Forumulate can act as an executor for it: the platform sends the request, we look the person up, run it, and hand back the result.

POST /api/orgs/{orgId}/privacy/executor-key    # mint this organization's credential
GET  /api/orgs/{orgId}/privacy/executor-key    # is one configured?
POST /api/privacy/executor/{orgId}/lookup      # does this person exist here?
POST /api/privacy/executor/{orgId}/requests    # open a request
GET  /api/privacy/executor/{orgId}/requests/{id}         # poll it
GET  /api/privacy/executor/{orgId}/requests/{id}/export  # collect the result

The credential is per organization, and the path carries the organization it belongs to. A key minted for one organization cannot read another — which is the whole point, and is why a key minted before this was scoped has to be re-minted.

Looking a person up

lookup takes the person's email address, the externalId your sign-in provider issued them, or both:

{ "email": "ana@example.com" }
{ "externalId": "user_8812", "externalProvider": "external_jwt" }

It finds every member of the organization however they joined: the owner and invited staff, and readers who signed in through a token from your app (external_jwt), OIDC, a social provider or a password — including a reader who signed in for the first time a moment ago. The address is matched without regard to case. externalId is the value GET /orgs/{orgId}/members reports for that member; externalProvider is optional and narrows the match to one sign-in method. The answer is {"subjectId": "…"} for a member and {"subjectId": null} for anyone else — somebody unknown, and somebody who has an account but is not a member of this organization, read the same.

Minting the credential again retires the previous one on the very next request.

Poll the request until it reports done, then follow the resultUrl it carries. A poll that ignores resultUrl and guesses an address will not find the file.

Receiving your CMP's data-subject requests by webhook

If your consent platform pushes events rather than calling an executor, point its webhook subscription at POST /hooks/consent/{orgId} (served under /api on the public origin). Any CMP works: nothing in the address names a vendor.

  1. Mint the signing secret with POST /orgs/{orgId}/consent/webhook-secret (needs org.manage_settings). It answers { secret, webhookUrl }, with a whsec_ secret shown once; give both to your CMP. You can instead store your CMP's own secret (8 to 300 characters) as webhookSecret on PUT /orgs/{orgId}/consent.

  2. Your CMP signs each delivery with HMAC-SHA256 over the raw body, as hex or base64, optionally sha256= prefixed, in X-Consent-Signature (or X-Webhook-Signature, or CookieMunch's own header). When it also sends X-Consent-Timestamp, a signature over {timestamp}.{body} is accepted too.

  3. POST /orgs/{orgId}/consent/webhook-test sends a correctly signed test event through the same path, so you can confirm the loop before a real one arrives.

A delivery with a bad or missing signature is a 401, and an organization with no secret answers 404. A verified delivery answers { "ok": true } and records when the last event arrived. This webhook carries consent events for your organization only. A dsar.created delivery is refused with a 422 (validation.failed, with details.dsar = ["org_scope_unsupported"]), and nothing happens: erasure and access act on a person's whole account, which can belong to other organizations too, so a key your organization holds cannot trigger them. Data-subject requests are executed by the platform's privacy executor instead (see above). Every other event type is acknowledged and ignored.

Consent is a separate obligation from the ones above, and it is the one your readers actually see. Forumulate does not ship a consent banner of its own and does not pick a vendor for you: you register the CMP you already use, and the reader surfaces honour it.

  • Cookies & consent in the console registers your CMP and its hosts.

  • Receipts are written per space and readable at the public receipts endpoint above, so you can prove what a given reader agreed to.

  • Nothing that waits on consent loads before it is given. A tag that may load must also be able to call home, so registering a vendor widens the page's content-security policy for that vendor and nothing else.

See the five Cookies & consent pages for the setup itself.

Third-party requests: fonts and images

A page Forumulate serves makes no request to anybody else on its own. That matters under the GDPR: loading a font or a picture from another host hands that host the reader's IP address, and a German court has fined a site for doing exactly that with Google Fonts (LG München I, 3 O 17493/20).

  • Fonts are self-hosted. Every font your theme or email brand names — a Google Fonts family from the picker, a stylesheet URL you paste, a font file in your custom CSS — is copied to our storage when you save, and your pages, the editor's previews and your emails load only that copy. Nothing is fetched from Google or your font host when someone reads your site. A font we have not copied yet (a few seconds after a save) shows in a metric-matched fallback face, never from the original host.

  • Images are proxied. A logo, cover, avatar or picture in your content that lives on another site is fetched once by our servers and served from ours. The other site never sees your readers, and that includes your feeds and your emails. "Use Gravatar" copies the picture instead of linking to gravatar.com. Uploading to your asset library is still the most direct way to host an image.

  • What you add yourself stays yours. Your consent manager, analytics tags, chat widget, video and iframe embeds, custom JavaScript, and image hosts you list in the theme's imgOrigins load from where you chose — you added them, and they belong on your own sub-processor list.

Sub-processors

Ours are named in our privacy policy — hosting, DNS and the network in front of the site, object storage for uploads and backups, transactional email, payments, and crash reporting. If your own DPA requires you to list them to your customers, that section is the source.

Anything you connect yourself — an AI provider, a translation provider, a chat widget, an analytics tag — is yours, not ours. Those are bring-your-own-key by design: the credential is yours, the contract is yours, and the data goes to a provider you chose. That is deliberate, and it is also something your own sub-processor list has to say.

Where to put your own answer

Publish your policy as a LEGAL space. It gets versions, effective dates, an acceptance record per member, and a public index — which is what turns "we have a privacy policy" into something you can prove on a date. See Legal spaces and Binding and acceptance.

What's next

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 clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Audit

Operation

Request

What it does

Shortcuts

Audit_list

GET /orgs/{orgId}/audit

Get the audit in an organization

forumulate audit list<br>client.orgs.audit.list()

Audit_exportCsv

GET /orgs/{orgId}/audit/export.csv

File of export (audit)

forumulate audit export<br>client.orgs.audit.exportCsv()

Audit_actions

GET /orgs/{orgId}/audit/actions

List actions in an organization

forumulate audit actions<br>client.orgs.audit.actions()

Operation

Request

What it does

Shortcuts

Consent_getOrg

GET /orgs/{orgId}/consent

Get the consent in an organization

client.privacy.consent.getOrg()

Consent_setOrg

PUT /orgs/{orgId}/consent

Replace the consent in an organization

client.privacy.consent.setOrg()

Consent_rotateWebhookSecret

POST /orgs/{orgId}/consent/webhook-secret

Create the webhook secret in an organization

client.privacy.consent.rotateWebhookSecret()

Consent_executorKeyStatus

GET /orgs/{orgId}/privacy/executor-key

Is a privacy-executor credential configured?

client.privacy.consent.executorKeyStatus()

Consent_rotateExecutorKey

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

Mint this organization’s privacy-executor credential

client.privacy.consent.rotateExecutorKey()

Consent_sendTestEvent

POST /orgs/{orgId}/consent/webhook-test

Create the webhook test in an organization

client.privacy.consent.sendTestEvent()

Consent_purgeOrg

POST /orgs/{orgId}/consent/cache/purge

Purge the cache (consent)

client.privacy.consent.purgeOrg()

Consent_getSpace

GET /orgs/{orgId}/spaces/{spaceId}/consent

Get the consent in a space

client.privacy.consent.getSpace()

Consent_setSpace

PUT /orgs/{orgId}/spaces/{spaceId}/consent

Replace the consent in a space

client.privacy.consent.setSpace()

Consent_purgeSpace

POST /orgs/{orgId}/spaces/{spaceId}/cache/purge

Purge the cache (spaces)

client.privacy.consent.purgeSpace()

Operation

Request

What it does

Shortcuts

ConsentPublic_resolved

GET /public/orgs/{orgId}/spaces/{spaceId}/consent

Get the consent in a space (public reader)

client.privacy.publicConsent.resolved()

ConsentPublic_record

POST /public/orgs/{orgId}/spaces/{spaceId}/consent/receipts

Create a receipt in a space

client.privacy.publicConsent.record()

The Forumulate teamUpdated