Privacy Requests Runbook
How the WarmlyYours team processes data-subject privacy requests (deletion, access, correction, opt-out) end-to-end inside Heatwave.
Public-facing policy: app/views/pages/company/privacy-policy.html.erb — what the user agreed to. Read it before processing any request so your reply doesn’t contradict the published terms.
Legal bases we honor:
| Law / Standard | Who it covers | Key right |
|---|---|---|
| GDPR Art. 17 (“Right to Erasure”) | EU/EEA/UK residents | Delete personal data on request |
| CCPA / CPRA §1798.105 | California residents | Delete personal information |
| Meta Platform Term 4.b | Anyone using Facebook Login | Privacy policy must explain deletion mechanism |
| WarmlyYours voluntary commitment | All users worldwide | We honor deletion requests from anyone, regardless of jurisdiction |
The published policy promises the same process to everyone globally — do not segment by country or US state when triaging.
1. Intake — where requests arrive
Section titled “1. Intake — where requests arrive”| Channel | Inbox / route | Owner |
|---|---|---|
privacy@warmlyyours.com | Privacy officer (rotates) | |
| Web | Contact form with topic = “Privacy Request” | Customer Service triages, escalates |
| Postal | ”Attn: Privacy Officer” at HQ | Office manager scans, forwards to inbox |
| Meta Data Deletion Callback | POST /webhooks/v1/facebook/data_deletion (handled by Webhooks::V1::Facebook::DataDeletionController) — see §9 for the full lifecycle | Automated; surfaces in /admin/privacy/deletion_requests if a Tier-3 trigger fires or the worker fails |
On intake:
- Assign a request ID — use the format
PR-YYYYMMDD-NN(e.g.PR-20260527-01). - Create a Basecamp todo in the Privacy Requests project, due 30 days from intake.
- Reply within 5 business days acknowledging receipt (template in §10).
- Tag the email thread with
privacy-requestso we can audit later.
2. Classify the request
Section titled “2. Classify the request”| Request type | What the user wants | Section |
|---|---|---|
| Deletion | Erase my data | §4–§7 |
| Access | Send me a copy of what you hold | §8 |
| Correction | Fix wrong data | §8 |
| Opt-out | Stop marketing / “do not sell” | §8 |
| OAuth-only revoke | Just unlink my Facebook login | §6, step (c) only |
Most requests are deletion — the rest of this runbook focuses there.
3. Identify and verify the account
Section titled “3. Identify and verify the account”Rails console is hard-blocked in this repo (see
CLAUDE.md). Usemise exec -- bin/rails runnerwith a script file. Never run privacy work inbin/rails console.
Find candidates by email, then widen if needed:
mise exec -- bin/rails runner script/privacy/find_subject.rb 'jane@example.com'script/privacy/find_subject.rb should be a thin throwaway script per request — keep it under version control so reviewers can audit what was searched:
email = ARGV[0] or abort 'usage: find_subject.rb <email>'
accounts = Account.where(email: email).or(Account.where(login: email))puts "Accounts (#{accounts.size}):"accounts.each do |a| puts " ##{a.id} email=#{a.email} login=#{a.login} disabled=#{a.disabled?} party=#{a.party_id}"end
parties = Party.joins(:contact_points).where(contact_points: { detail: email, category: 'email' }).distinctputs "\nParties via ContactPoint match (#{parties.size}):"parties.each { |p| puts " ##{p.id} type=#{p.type} name=#{p.full_name}" }
# OAuth identity may have a different email than the account; check raw uid tooputs "\nAuthentications with this email in extra hash:"Authentication.where('extra::text ILIKE ?', "%#{email}%").find_each do |auth| puts " ##{auth.id} provider=#{auth.provider} uid=#{auth.uid} account=#{auth.account_id}"endVerification steps before deleting anything:
- Reply-to-thread match — the request must come from the same email on the account, OR
- Order-number challenge — ask the requester for a recent order number / customer reference, OR
- Logged-in confirmation — ask them to send a fresh request from inside
/my_account(“Account → Help & Support → Privacy Request”), OR - For Facebook-Login-only accounts — ask them to share the FB profile URL/email; cross-check with
authentications.uid.
If verification fails twice, decline politely (template in §10) and log the request as verification_failed in the Basecamp thread.
4. Inventory — what we hold about this person
Section titled “4. Inventory — what we hold about this person”Before deleting, list everything tied to the Party. This both confirms scope to the user and gives us a checklist:
party = Party.find(ARGV[0].to_i)
records = Merger::CustomerMerger.inventory(party) if party.is_a?(Customer)puts "Inventory for Party ##{party.id} (#{party.type}):"records&.group_by(&:class)&.each do |klass, items| puts " #{klass.name.ljust(30)} #{items.size}"end
# Things Merger::CustomerMerger doesn't trackputs " Visits (party=user_id) #{Visit.where(user_id: party.id).count}"puts " PaperTrail::Version on Party #{PaperTrail::Version.where(item_type: 'Party', item_id: party.id).count}"puts " PaperTrail::Version on Account #{PaperTrail::Version.where(item_type: 'Account', item_id: party.account&.id).count}"puts " PaperTrail::Version on ContactPnt #{PaperTrail::Version.where(item_type: 'ContactPoint', item_id: party.contact_points.pluck(:id)).count}"Merger::CustomerMerger.inventory/1 already enumerates the 31 associated tables we care about (app/services/merger/customer_merger.rb:20). Use it as the source of truth — if a new model is added that holds Party PII, add it to the inventory there so this runbook picks it up automatically.
5. Lock the account first
Section titled “5. Lock the account first”Deletion is destructive and irreversible. Always disable the account before deleting, in a separate step, so an attacker who hijacked the request can’t keep using it while the deletion is being processed:
account = Account.find(ARGV[0].to_i)account.update!(disabled: true)# revoke_all_api_tokens_if_disabled fires automatically (account.rb:633)puts "Locked Account ##{account.id}; API tokens revoked."Wait at least 24 h between locking and deleting unless the user explicitly asks to expedite. This window catches “I changed my mind” reversals.
6. Execute the deletion
Section titled “6. Execute the deletion”This is the substantive step. Each sub-step is a separate runner script so you can stop and resume if something fails. Wrap each in a transaction.
Order matters — start with marketing/tracking (lowest risk), end with account/party (after everything else is detached or anonymized).
(a) Detach OAuth identities
Section titled “(a) Detach OAuth identities”account = Account.find(ARGV[0].to_i)account.authentications.find_each do |auth| puts "Destroying Authentication ##{auth.id} (#{auth.provider}/#{auth.uid})" auth.destroy!endNote: Account.has_many :authentications, dependent: :destroy (account.rb:101) would cascade when we destroy the Account in step (g), but doing it explicitly here means the FB Login OAuth link is broken first — which is the most user-visible deletion outcome.
(b) Anonymize marketing / tracking data
Section titled “(b) Anonymize marketing / tracking data”party = Party.find(ARGV[0].to_i)
# Visits — keep the row for aggregate analytics but break PII linkageVisit.where(user_id: party.id).update_all( user_id: nil, marketing_meta: {}, device_meta: {})
# Consent prefs live on Party JSONBparty.update!(consent_preferences: {})We keep visits.id rows because they feed cohort analytics; only the linkage to the person and any captured marketing identifiers are scrubbed.
(c) Detach payment tokens at the processor
Section titled “(c) Detach payment tokens at the processor”Local rows cascade in step (g), but the processors hold tokens too. Detach them first, while the customer record still exists:
party = Party.find(ARGV[0].to_i)
# Stripe — delete the Customer object at Stripe; cascades to all PMsif party.stripe_customer_id.present? Stripe::Customer.delete(party.stripe_customer_id) party.update!(stripe_customer_id: nil)end
# PayPal Vault — delete the vault customerif party.paypal_vault_customer_id.present? Payment::Apis::Paypal.new.delete_vault_customer(party.paypal_vault_customer_id) party.update!(paypal_vault_customer_id: nil, paypal_vault_token_id: nil)end
# Amazon Pay — tokens are per-charge; nothing to detach at AP, just clear locallyparty.payments.where.not(amazon_pay_charge_permission_id: nil).find_each do |pmt| pmt.update!( amazon_pay_charge_id: nil, amazon_pay_charge_permission_id: nil, amazon_pay_checkout_session_id: nil )endIf a processor call fails (e.g. Stripe customer already deleted), log it and continue — we still want to clear our local references.
(d) Anonymize order / transaction PII (retain row, scrub identity)
Section titled “(d) Anonymize order / transaction PII (retain row, scrub identity)”Orders and payments are retained for 7 years under IRS recordkeeping (see §7). Don’t destroy them — anonymize:
party = Party.find(ARGV[0].to_i)
placeholder_name = "REDACTED-#{party.id}"
# Orders keep customer_id (we need the link for tax/dispute purposes)# but PII fields on snapshots get scrubbedparty.orders.find_each do |order| order.update!( # invoice/billing snapshot fields differ across order types — extend per-model as needed )end
# Payment row PII (cardholder name, billing email captured at the time)party.payments.find_each do |pmt| pmt.update!( first_name: 'REDACTED', last_name: "REDACTED-#{party.id}", email: "redacted-#{party.id}@privacy.warmlyyours.com", paypal_email: nil, paypal_payer_id: nil )endOpen question (escalate to finance): confirm IRS recordkeeping requires the customer identity on retained orders or only the transaction values. If only the latter, we can be more aggressive here. Default to keeping the link with a redacted name until a CPA confirms.
(e) Anonymize addresses and contact points
Section titled “(e) Anonymize addresses and contact points”addresses and contact_points are dependent: :nullify on Party (party.rb:243, 251), so they’d survive Party destruction as orphans. Anonymize in place:
party = Party.find(ARGV[0].to_i)
party.addresses.find_each do |addr| addr.update!( street1: 'REDACTED', street2: nil, street3: nil, city: 'REDACTED', zip: '00000', person_name_override: nil, company_name_override: nil )end
party.contact_points.find_each do |cp| cp.update!(detail: "redacted-#{party.id}-#{cp.id}", state: 'failed')end
party.update!( full_name: "REDACTED-#{party.id}", name1: 'REDACTED', name2: nil, name3: nil, prefix: nil, suffix: nil, dob: nil, job_title: nil, profile_info: {}, source_info: {}, affiliations: {}, gclid: nil)(f) Destroy the Account
Section titled “(f) Destroy the Account”Now that the Party is anonymized and OAuth links are gone, destroy the Account:
account = Account.find(ARGV[0].to_i)account.destroy!We leave the Party row in place — it’s been anonymized, still attached to retained orders. The Account itself (login credentials, devise fields, last_sign_in_at, encrypted_password) is the part that disappears.
(g) Purge paper_trail history
Section titled “(g) Purge paper_trail history”paper_trail captures every change as a YAML blob in versions.object and versions.object_changes. Those blobs contain the pre-redaction PII — so deletion is incomplete until they’re purged:
party = Party.find(ARGV[0].to_i)account_id = party.account&.id # may be nil if (f) already ran
scopes = [ PaperTrail::Version.where(item_type: 'Party', item_id: party.id), PaperTrail::Version.where(item_type: 'Account', item_id: account_id).then { account_id ? _1 : PaperTrail::Version.none }, PaperTrail::Version.where(item_type: 'ContactPoint', item_id: party.contact_points.pluck(:id)), PaperTrail::Version.where(item_type: 'Address', item_id: party.addresses.pluck(:id)), PaperTrail::Version.where(item_type: 'Authentication', item_id: party.account&.authentications&.pluck(:id) || []), PaperTrail::Version.where(item_type: 'Payment', item_id: party.payments.pluck(:id))]
scopes.each { |s| puts "Deleting #{s.count} versions of #{s.first&.item_type}"; s.delete_all }7. Retention exceptions — what we KEEP and why
Section titled “7. Retention exceptions — what we KEEP and why”Document these in your reply to the user (template in §10) so they understand exactly what survives deletion:
| Data | Retained for | Why | Where |
|---|---|---|---|
| Order + transaction rows (anonymized) | 7 years | IRS recordkeeping, warranty, returns | orders, invoices, payments |
Aggregated visits rows (PII scrubbed) | Indefinite | No longer personal once user_id and marketing_meta are cleared | visits |
| Active legal hold records | Until matter resolved | Litigation hold | Per-case |
| Fraud signals (disputed-chargeback identifiers) | Up to 7 years | Loss prevention | payments.*_metadata |
| Backups | 30–90 days | Disaster recovery only; data expires from rolling backups within retention window | Databasus PITR → Cloudflare R2 |
Backup caveat: point-in-time backups will contain pre-deletion data until they age out. We don’t restore individual records from backups to fulfill deletion — we wait for them to roll off. Mention this in the user reply (template).
8. Non-deletion requests
Section titled “8. Non-deletion requests”Access (GDPR Art. 15 / CCPA Right to Know)
Section titled “Access (GDPR Art. 15 / CCPA Right to Know)”party = Party.find(ARGV[0].to_i)out = { party: party.attributes, account: party.account&.attributes&.except('encrypted_password', 'reset_password_token'), contacts: party.contact_points.map(&:attributes), addresses: party.addresses.map(&:attributes), oauth: party.account&.authentications&.map { _1.attributes.slice('provider', 'uid', 'created_at') } || [], orders: party.orders.map { _1.attributes.slice('id', 'reference_number', 'state', 'total', 'created_at') }, consents: party.consent_preferences, visits_count: Visit.where(user_id: party.id).count}
File.write("/tmp/pr-export-#{party.id}.json", JSON.pretty_generate(out))puts "Wrote /tmp/pr-export-#{party.id}.json"Encrypt the JSON before sending (gpg --symmetric --cipher-algo AES256) and share the passphrase over a different channel (text, not email).
Correction
Section titled “Correction”Use the CRM customer-edit screens — no special tooling. Log what was changed in the Basecamp thread.
Opt-out (CCPA “Do Not Sell / Share”)
Section titled “Opt-out (CCPA “Do Not Sell / Share”)”party.update!(consent_preferences: party.consent_preferences.merge( consent_marketing: false, consent_analytics: false, consent_share_opt_out_at: Time.current.iso8601))The browser-side pixels respect consent_preferences via the consent manager, and the server-side CAPI events check the same flag before firing.
9. Special cases
Section titled “9. Special cases”Facebook Login users
Section titled “Facebook Login users”Most of these accounts have authentications.provider = 'facebook' and accounts.encrypted_password blank (no password set, OAuth-only). Steps:
- §6(a) destroys the
authenticationsrow → user can never log in again via FB - The user can ALSO revoke our app from their FB settings (
https://www.facebook.com/settings?tab=applications), which is independent of our deletion - If they only want the OAuth link broken (not full deletion), stop after §6(a). Mark the Basecamp todo as
oauth_only_revoke.
Meta Data Deletion Callback (live)
Section titled “Meta Data Deletion Callback (live)”Meta sends POST /webhooks/v1/facebook/data_deletion with a signed_request form param when a user removes our app from their FB account and elects the deletion option. End-to-end:
Webhooks::V1::Facebook::DataDeletionController#createverifies the signature viaFacebook::SignedRequestVerifier(HMAC-SHA256 againstomniauth.facebook_secret)- Idempotently creates a
Privacy::DeletionRequest(source: 'facebook_callback') — repeat deliveries for the same UID return the existing row - Returns
{ url: '/privacy/deletion_status/<code>', confirmation_code: '<code>' }so Meta can show the user a status page - Enqueues
Privacy::DataDeletionWorkerwhich:- Resolves the FB UID →
Authentication→Account→Party. No match → transitions tono_account_found(terminal) - Runs
Privacy::ManualReviewDetectorfor Tier-3 triggers. Any hit → transitions toheld_for_review, alertsheatwaveteam@warmlyyours.com - Otherwise calls
Privacy::ScrubService(Tier 1+2 anonymization), thenPrivacy::ProcessorDetachWorker(async Stripe/PayPal detach) - Transitions to
completedon success → sends the user-facing completion email - Transitions to
failedon error → alerts the team via the admin review queue
- Resolves the FB UID →
- The team reviews held/failed requests at
/admin/privacy/deletion_requests— approve, decline, or retry without dropping into a Rails runner
Configuration in the Meta App Dashboard (for app 219983831882733): App settings → Basic → User data deletion → Data Deletion Callback URL = https://www.warmlyyours.com/webhooks/v1/facebook/data_deletion. The Instructions URL (/company/privacy-policy#data-deletion) stays configured as a fallback for users who arrive via Meta’s email-the-developer path.
Manual intake (email / postal / web form)
Section titled “Manual intake (email / postal / web form)”The same admin UI handles non-callback requests:
- Create the row in
/admin/privacy/deletion_requests(or via Rails runner for bulk) withsource: 'manual_email'/'manual_postal'/'crm', capturing the requester’s email indata['contact_email']so the completion email reaches them - The intake alert email fires automatically via the model’s
after_create_commitcallback - From the admin show page: Approve & start scrub runs the worker against the manually-linked
account_id/party_id - The same Tier-3 review gate, scrub, and email lifecycle applies — manual entries are not a separate code path
Requester is not an account holder
Section titled “Requester is not an account holder”Lead-form submitters and guest checkouts create a Party but no Account. Treat them as Customers/Leads and run §6(b)–(g), skipping §6(a) and §6(f).
Duplicate accounts
Section titled “Duplicate accounts”If the inventory turns up multiple Parties for the same person (common with guest checkouts later being upgraded to an Account), consider running Merger::CustomerMerger first to consolidate, then process deletion against the surviving customer. Otherwise process each Party independently.
Bot / spam requests
Section titled “Bot / spam requests”If the email is from a bot list or contains obvious template text, ask for verification (§3). If no response within 14 days, close as unverified and drop the thread. Don’t delete in this case — false positives are worse than a missed bot.
10. Reply templates
Section titled “10. Reply templates”Acknowledgment (send within 5 business days)
Section titled “Acknowledgment (send within 5 business days)”Subject: We received your privacy request — PR-YYYYMMDD-NN
Hi [name],
Thanks for reaching out. We’ve received your privacy request (reference PR-YYYYMMDD-NN) on [date].
What happens next:
- We’ll verify your identity using the email address on file (or, if needed, ask you for an order number).
- We’ll complete the request within 30 days of intake. For complex requests we may extend by up to 15 days (GDPR) or 45 days (CCPA) and will let you know if so.
- We’ll email you a confirmation when the work is done, with a summary of what was deleted and what (if anything) we had to retain by law.
Our full privacy policy is at https://www.warmlyyours.com/company/privacy-policy.
— WarmlyYours Privacy Team privacy@warmlyyours.com
Verification request
Section titled “Verification request”Subject: Re: PR-YYYYMMDD-NN — quick verification needed
Hi [name],
Before we delete your data we need to confirm the request came from you. Could you reply to this email with one of:
- An order number from a past WarmlyYours purchase, or
- The full name on your account, or
- The Facebook email you used if you signed in with “Log in with Facebook”
Thanks — we’ll proceed as soon as you reply.
Completion confirmation
Section titled “Completion confirmation”Subject: Your privacy request is complete — PR-YYYYMMDD-NN
Hi [name],
We’ve completed your data deletion request. Here’s a summary:
Deleted:
- Your account profile, login credentials, and saved addresses
- Saved payment tokens (Stripe / PayPal vaults detached at the processor)
- OAuth identity links (Facebook / Google / LinkedIn / Apple)
- Marketing consent records and behavioral tracking data
- Audit history (paper_trail versions) for your account
Retained (anonymized — your name and contact details have been replaced with a placeholder, and the records are no longer used for marketing or analytics):
- Order and transaction history — kept for 7 years to satisfy IRS recordkeeping
- Aggregated, non-identifying analytics counts
Note: rolling backups may contain pre-deletion data for up to 90 days, but those backups are used only for disaster recovery and the data will roll off automatically.
If you have any questions or want to follow up, reply to this email.
— WarmlyYours Privacy Team
Decline (failed verification)
Section titled “Decline (failed verification)”Subject: Re: PR-YYYYMMDD-NN — unable to verify
Hi [name],
We were unable to verify that this request came from the account owner, so we have not deleted any data. If you’d like to try again, reply to this email with one of the verification items in our previous message.
11. Audit trail
Section titled “11. Audit trail”For every request, the Basecamp todo should end with:
- Date acknowledged
- Date verified (or “unverified — closed”)
- Date executed
- Linked scripts (paste each
script/privacy/*.rbfilename used and its commit SHA) - Linked confirmation email message ID
- Any retention exceptions invoked
Keep script/privacy/ files committed (even one-off ones) — reviewers and regulators may ask “how did you process request X?” months later.
12. Escalation
Section titled “12. Escalation”| Situation | Who |
|---|---|
| Suspected fraud / unauthorized request | Security lead + privacy officer |
| Bulk request (10+ in a day) | Engineering — likely needs a script enhancement, not manual work |
| Legal hold conflict | General Counsel |
| Regulator inquiry (DPA, FTC, CPPA) | General Counsel + CEO |
| Meta app dashboard banner not clearing after 48 h | Marketing ops (re-submit the violation response) |
References
Section titled “References”- Public policy:
app/views/pages/company/privacy-policy.html.erb - Inventory helper:
app/services/merger/customer_merger.rb:20 - Account model:
app/models/account.rb - Party model:
app/models/party.rb - Authentication model:
app/models/authentication.rb - Visit model:
app/models/visit.rb - Facebook integration overview:
doc/integrations/FACEBOOK_AD_MANAGEMENT.md - GDPR Art. 12, 15, 17: https://gdpr-info.eu/
- CCPA/CPRA: https://oag.ca.gov/privacy/ccpa
- Meta Platform Terms: https://developers.facebook.com/terms