epic data yeeter 9000

how to yeet data properly

this easy guide will help satisfy karens worldwide

> be the hack club infra guy
> someone asks for their data to be deleted
> roll your own SQL scripts against program dbs
> pray it dont break anything

help us not have to do that. instead, implement just two endpoints to your program, we handle the rest! save us from having to run arbitrary SQL against your database, and save yourself from dealing with the #fallout.

the short version, if you've never built an api
  1. you add two urls to your app that the yeeter can POST to.
  2. every call comes with a token. a token is a signed note from the yeeter that says "this person, this action, signed by the right people". a library checks the signature for you, so nobody can fake one. you don't need to understand the crypto.
  3. you find the person in your database using the details inside the token.
  4. first call is a practice run to make sure everything works as expected: say what you would delete and change nothing. a human checks to make sure this is legit and the second call is the real deletion.
  5. you answer with counts, never with their personal data.

let your agent do it

if you use a coding agent, copy this prompt, paste it in your repo, and review what it builds. it will ask you for your audience and walk you through what gets deleted before writing anything. you still own the result, so read this page anyway.

show the prompt
Add the Hack Club data yeeter endpoints to this codebase. The spec is at https://yeet.hackclub.com/docs/deletions. Read all of it before you start.

Before writing any code:
1. Ask me for the audience string. Yeeter staff set it for this program, and the token's aud claim must match it exactly.
2. List every table, file store, and external service that holds data about a user. For each one, say whether you would delete it, or keep it detached from the account (things other people depend on, like orders, payouts, or ledger entries), and why. Wait for me to approve that list.

Then build:
- POST /api/privacy/delete and POST /api/privacy/export. There is no request body. The token arrives as Authorization: Bearer <token>.
- Verify the token as an ES256 JWT against https://yeet.hackclub.com/.well-known/jwks.json. Let an env var override that url, but default to it. Check typ JWT, issuer hackclub-privacy, the audience, 30 seconds of clock leeway, exp minus iat no more than 300 seconds, and that the action claim matches the endpoint.
- Store each jti in a database table and reject repeats with 409 {"error": "replayed"}.
- Find the person only from the token's subject claims (emails, slack_id, identity_id). Never read identifiers from the body or query string.
- For delete, run the real deletion inside a transaction, count what it removed, and roll back when dry_run is true. Delete files in external storage only when dry_run is false, before committing.
- Export must never change anything.
- Respond with request_id, found, dry_run, deleted, and retained exactly as the spec shows. Errors look like {"error": "snake_case_code"}.
- Never log tokens, emails, Slack ids, or identity ids. Log only request_id and the outcome.
- Write docs/privacy.md explaining what gets deleted and what gets kept, like the reference implementations linked from the spec.

how a request plays out

  1. someone asks Hack Club to delete their data, and it lands in the yeeter.
  2. the yeeter calls /api/privacy/delete on every program with dry_run: true. you work out exactly what you'd delete and keep, report the counts, and change nothing.
  3. staff read every program's counts. if something looks off (you'd delete 40,000 comments for one person?) they stop here and nothing has been touched.
  4. once they approve, the yeeter calls the same endpoint again with dry_run: false. now you actually delete, and report what you removed.

the dry run and the real run should give the same numbers. the reviewer approved those numbers, so surprising them in the real run is bad.

the endpoints

  • POST /api/privacy/delete nukes everything that belongs to the person. on a dry run it just tells us what it would nuke.
  • POST /api/privacy/export tells us what you have on them. we are not sure how to operate exports yet, so this one should never change anything.

we hit your url with Authorization: Bearer <token> and Accept: application/json, no body. we will give up after 15 seconds and we don't follow redirects.

the token

every request gets a fresh ES256 JWT that lives for 5 minutes max. it's bound to your epic program. here's what one looks like:

a decoded token
// header
{ "alg": "ES256", "typ": "JWT", "kid": "2026-10-11-mrXnQja1" }

// payload
{
  "iss": "hackclub-privacy",
  "aud": "your-program",
  "iat": 1791676800,
  "exp": 1791677100,
  "jti": "01a1296f-…-your-program-delete-1-3f9c2a7b1e04",
  "request_id": "01a1296f-83fb-7bd1-b99f-5d8d3acda667",
  "action": "delete",
  "dry_run": true,
  "subject": {
    "emails": ["someone@example.com"],
    "slack_id": "U0123456789",
    "identity_id": "ident!AbC123"
  }
}
  • aud is your program's audience. more on that right below.
  • request_id is the only thing from this you should log. seriously.
  • dry_run is true on the practice run and false on the real one. if it's true, your database must look exactly the same after the call as before it.
  • jti is unique per call, retries included.

the audience

the audience is just a string that names your program, like flavortown. it's what stops a token meant for one program from working on another. two places have to agree on it:

  • the yeeter. staff type it in when they add your program as a target.
  • your code. you hardcode the same string in your token check.

it has to match exactly. summer-of-making, summer_of_making, and Summer of Making are three different audiences. to keep it boring, use lowercase letters and hyphens, and tell us what you picked when you ask to be added. if they don't match, every call fails with a 401 and nothing gets deleted, so it's annoying but not dangerous.

verifying it

do NOT skip this
this check is the only thing stopping a random person on the internet from deleting your users. use a jwt library (like jose for js, ruby-jwt for ruby, pyjwt for python) and do every step below. ask us if anything is unclear.
  1. grab the public keys from https://yeet.hackclub.com/.well-known/jwks.json. cache them for ~5 minutes, and refetch (at most every 30 seconds or so) when a token names a kid you haven't seen. that way key rotation just works and you never hear about it.
  2. only accept alg: ES256 with typ: JWT, and check the signature against the key that kid names. if your jwt library lets you skip the alg check, don't.
  3. iss must be hackclub-privacy and aud must be exactly your audience.
  4. check exp and iat with ~30 seconds of leeway, and reject anything where exp − iat is over 300 seconds.
  5. action has to match the endpoint that got called. a delete token sent to /export is a 400.
  6. record the jti and reject repeats. put it in your database, not a cache. ask us how we know: some cache clients report a failed write the same way as "key already exists", so every token looks replayed and you get a wall of 409s.
  7. only trust the claims. never read identifiers from the body or query string. if anyone can POST you an email and get it deleted, that's not a feature.
verifying a token with jose
import { createRemoteJWKSet, jwtVerify } from 'jose'

let jwks = createRemoteJWKSet(new URL('https://yeet.hackclub.com/.well-known/jwks.json'))

export async function verifyPrivacyToken(token: string, endpoint: 'delete' | 'export') {
  let { payload } = await jwtVerify(token, jwks, {
    algorithms: ['ES256'],
    typ: 'JWT',
    issuer: 'hackclub-privacy',
    audience: 'your-program',
    maxTokenAge: 300,
    clockTolerance: 30,
  })
  if (payload.exp! - payload.iat! > 300) throw new Error('invalid_lifetime')
  if (payload.action !== endpoint) throw new Error('action_mismatch')
  // then record payload.jti, and refuse it if it was already recorded
  return payload
}
remembering which tokens you've seen
create table privacy_token_uses (
  jti text primary key,
  used_at timestamptz not null default now()
);

-- first use when this inserts a row; a replay inserts nothing
insert into privacy_token_uses (jti) values ($1) on conflict do nothing;

-- prune rows far older than any token can live
delete from privacy_token_uses where used_at < now() - interval '1 day';

if you do make the jwks url overridable with an env var, thats cool but make sure it defaults to the real one.

finding the person

  • emails come lowercased from the yeeter. compare them case-insensitively against every email column you have on them, including the weird secondary one you forgot about.
  • slack_id and identity_id (the Hack Club Auth ident!… id) can be null since we dont always have them. if they are present, match them against your records too.
  • no match? return 200 with found: false and we wont bug you again about this person.

deleting stuff

  • delete what's theirs. account, profile, content, uploads, sessions, login identities, analytics, and any versioning or audit rows that copy their data. yes, paper_trail counts.
  • keep what other people depend on, detached. orders, payouts, ledger entries, fraud reports, and votes that affect other people's rankings can stay. null the user reference, or keep an anonymous placeholder row with every personal field wiped. list each kind in retained with a reason a reviewer would actually buy.
  • close open work. cancel unfulfilled orders and pending payouts or anything outstanding.
  • fix other people's numbers. recount likes, comments, followers, and votes on stuff you didn't delete.
  • external storage goes first. delete files in s3 or rows in other databases before you commit the main transaction. if that blows up, the retry still finds the person and finishes the job.
  • one code path for both runs. the best dry run does the real deletion inside a transaction, counts what it removed, and rolls back. the numbers a reviewer approves are then the numbers that actually run. a separate "count what we'd delete" function will drift and lie to you eventually.
  • refuse what needs a human. if the account owns something only staff should decide about, like api keys other services depend on, return 409 with manual_review_required instead of guessing.

what to send back

a dry run response
{
  "request_id": "01a1296f-83fb-7bd1-b99f-5d8d3acda667",
  "found": true,
  "dry_run": true,
  "deleted": { "users": 1, "projects": 2, "comments": 14 },
  "retained": [
    {
      "type": "shop orders",
      "count": 3,
      "reason": "kept for accounting and fulfilment; detached from the account"
    }
  ]
}
  • echo request_id and dry_run exactly as the token had them. the yeeter rejects responses that don't.
  • deleted maps a table or record type (100 characters max) to a non-negative row count.
  • each retained entry has a type (100 characters max), a count, and a reason (500 characters max).
  • dont send back pii in the response, just the counts and reasons.

errors and retries

errors look like {"error": "snake_case_code"}. staff see it as something like http_409_replayed, so keep it short and snappy!

  • 408, 425, 429, 500, 502, 503, 504, timeouts, and network errors get retried after 10, 30, 90, and 270 seconds. after 5 attempts it's marked failed.
  • anything else fails immediately and shows up for staff. 401 for a bad token, 400 action_mismatch, 409 replayed, and 409 manual_review_required.
  • 503 when something you depend on, like the jwks or s3, is having a moment, so the yeeter tries again.

logging

log the request_id and the outcome. do not log the token, emails, slack or identity ids, or anything you deleted. logging the email of the person who asked to be forgotten is peak irony. please don't do it.

getting hooked up

  1. ship the endpoints.
  2. ask someone on the infra team to add your program as a target. tell them its name, the audience you hardcoded, and the base url (the yeeter adds /api/privacy/delete itself, so just the bit before that). point it at the server your api actually runs on, not your frontend.
  3. we can help test dry runs for you against alts you hold to make sure you did it right
  4. profit?

reference implementations (steal these)

  • Summer of Making: rails. cascading foreign keys, leftover records detached from the account
  • Flavortown: rails. the ledger is immutable, so account and project rows stay as anonymous placeholders
  • Lapse: fastify + prisma. deletes in foreign key order, s3 cleaned up first