Recipe: data deletion requests
A privacy-page form your product is legally bound to answer — the hosted page takes the request, a signed webhook hands it to your backend, and your backend confirms before deleting.
The shape of the flow
A deletion request is a legal obligation, so the flow is built on the delivery that
always fires — submission.created — never on the Gate, which is allowed to say no.
- The person submits their email on a hosted Noonmark page. Your site only links to it.
- Noonmark stores the Submission and POSTs a signed
submission.createddelivery to your backend. - Your backend verifies the signature, then emails the address a confirmation link — the page is public, so a request proves nothing about who sent it.
- A click on the link is your proof. Queue the deletion and answer within your legal window (a month under GDPR; LGPD expects a response in 15 days).
Step 1 — the Form
Create a survey Form named for the job — one required email Field, an optional
reason textarea if you want it. The survey kind gives you a hosted public page, so
your product's privacy policy just links to it:
Request deletion of your data → https://noonmark.dev/f/your-handleNothing to build on your site. The hosted page also registers WebMCP tools, so a browser agent can file the request for its person.
A survey Form never enters the Gate flow — there is no
submission.accepted here, and that is correct for this job: no evaluator gets to
drop a request the law says you must answer.
Step 2 — the webhook
On the Form's Integrations tab, create a webhook pointing at your backend with the
submission.created event, and keep the secret in your environment:
| Setting | Value |
|---|---|
| URL | https://your-app.example/api/noonmark/deletion |
| Event | submission.created |
| Secret | goes in your env as NOONMARK_WEBHOOK_SECRET |
Step 3 — receive and verify
The complete receiving route. Verify the signature over the raw body before parsing anything, reject stale timestamps, and record the delivery id — deliveries are retried, and a deletion flow must not start twice.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text(); // the raw body, before any parsing
const ts = req.headers.get("x-noonmark-timestamp") ?? "";
const sent = req.headers.get("x-noonmark-signature") ?? "";
// Reject anything older than five minutes.
if (Math.abs(Date.now() - Number(ts)) > 300_000) {
return new Response(null, { status: 401 });
}
const mac = createHmac("sha256", process.env.NOONMARK_WEBHOOK_SECRET!)
.update(`${ts}.${raw}`)
.digest("hex");
const expected = Buffer.from(`sha256=${mac}`);
const actual = Buffer.from(sent);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return new Response(null, { status: 401 });
}
const { event, submission } = JSON.parse(raw);
if (event !== "submission.created") {
return new Response(null, { status: 204 });
}
// Idempotency: the same delivery id must not start a second flow.
const deliveryId = req.headers.get("x-noonmark-delivery");
await startDeletionFlow(submission.payload.email, deliveryId);
return new Response(null, { status: 204 });
}Step 4 — confirm before you delete
startDeletionFlow is yours, and its first move is never the deletion. The public
page accepts any address anyone types, so:
- Send a confirmation email to the address, carrying a signed, expiring link.
- Only a click from that inbox proves the requester controls the address.
- Then queue the deletion, and record when the request arrived — the legal clock started at step 1, not at the click.
Delivery is best effort — three attempts, no queue. Every request is stored in the Studio regardless, so treat the Submissions list as the source of truth and reconcile against it: a webhook that never landed is still a request you owe an answer to.
Testing the loop
Open the public page, submit an address you control, and watch the same request arrive in the Studio and at your endpoint. The webhooks page covers headers, retries and the delivery log.
What did you think of this content?