Skip to content

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 / StandardWho it coversKey right
GDPR Art. 17 (“Right to Erasure”)EU/EEA/UK residentsDelete personal data on request
CCPA / CPRA §1798.105California residentsDelete personal information
Meta Platform Term 4.bAnyone using Facebook LoginPrivacy policy must explain deletion mechanism
WarmlyYours voluntary commitmentAll users worldwideWe 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.


ChannelInbox / routeOwner
Emailprivacy@warmlyyours.comPrivacy officer (rotates)
WebContact form with topic = “Privacy Request”Customer Service triages, escalates
Postal”Attn: Privacy Officer” at HQOffice manager scans, forwards to inbox
Meta Data Deletion CallbackPOST /webhooks/v1/facebook/data_deletion (handled by Webhooks::V1::Facebook::DataDeletionController) — see §9 for the full lifecycleAutomated; surfaces in /admin/privacy/deletion_requests if a Tier-3 trigger fires or the worker fails

On intake:

  1. Assign a request ID — use the format PR-YYYYMMDD-NN (e.g. PR-20260527-01).
  2. Create a Basecamp todo in the Privacy Requests project, due 30 days from intake.
  3. Reply within 5 business days acknowledging receipt (template in §10).
  4. Tag the email thread with privacy-request so we can audit later.

Request typeWhat the user wantsSection
DeletionErase my data§4–§7
AccessSend me a copy of what you hold§8
CorrectionFix wrong data§8
Opt-outStop marketing / “do not sell”§8
OAuth-only revokeJust unlink my Facebook login§6, step (c) only

Most requests are deletion — the rest of this runbook focuses there.


Rails console is hard-blocked in this repo (see CLAUDE.md). Use mise exec -- bin/rails runner with a script file. Never run privacy work in bin/rails console.

Find candidates by email, then widen if needed:

Terminal window
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:

script/privacy/find_subject.rb
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' }).distinct
puts "\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 too
puts "\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}"
end

Verification steps before deleting anything:

  1. Reply-to-thread match — the request must come from the same email on the account, OR
  2. Order-number challenge — ask the requester for a recent order number / customer reference, OR
  3. Logged-in confirmation — ask them to send a fresh request from inside /my_account (“Account → Help & Support → Privacy Request”), OR
  4. 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:

script/privacy/inventory.rb
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 track
puts " 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.


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:

script/privacy/lock.rb
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.


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

script/privacy/delete_oauth.rb
account = Account.find(ARGV[0].to_i)
account.authentications.find_each do |auth|
puts "Destroying Authentication ##{auth.id} (#{auth.provider}/#{auth.uid})"
auth.destroy!
end

Note: 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.

script/privacy/scrub_tracking.rb
party = Party.find(ARGV[0].to_i)
# Visits — keep the row for aggregate analytics but break PII linkage
Visit.where(user_id: party.id).update_all(
user_id: nil,
marketing_meta: {},
device_meta: {}
)
# Consent prefs live on Party JSONB
party.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:

script/privacy/detach_payments.rb
party = Party.find(ARGV[0].to_i)
# Stripe — delete the Customer object at Stripe; cascades to all PMs
if party.stripe_customer_id.present?
Stripe::Customer.delete(party.stripe_customer_id)
party.update!(stripe_customer_id: nil)
end
# PayPal Vault — delete the vault customer
if 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 locally
party.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
)
end

If 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:

script/privacy/scrub_orders.rb
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 scrubbed
party.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
)
end

Open 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:

script/privacy/scrub_party_pii.rb
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
)

Now that the Party is anonymized and OAuth links are gone, destroy the Account:

script/privacy/destroy_account.rb
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.

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:

script/privacy/purge_versions.rb
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:

DataRetained forWhyWhere
Order + transaction rows (anonymized)7 yearsIRS recordkeeping, warranty, returnsorders, invoices, payments
Aggregated visits rows (PII scrubbed)IndefiniteNo longer personal once user_id and marketing_meta are clearedvisits
Active legal hold recordsUntil matter resolvedLitigation holdPer-case
Fraud signals (disputed-chargeback identifiers)Up to 7 yearsLoss preventionpayments.*_metadata
Backups30–90 daysDisaster recovery only; data expires from rolling backups within retention windowDatabasus 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).


Access (GDPR Art. 15 / CCPA Right to Know)

Section titled “Access (GDPR Art. 15 / CCPA Right to Know)”
script/privacy/export.rb
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).

Use the CRM customer-edit screens — no special tooling. Log what was changed in the Basecamp thread.

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.


Most of these accounts have authentications.provider = 'facebook' and accounts.encrypted_password blank (no password set, OAuth-only). Steps:

  • §6(a) destroys the authentications row → 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 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:

  1. Webhooks::V1::Facebook::DataDeletionController#create verifies the signature via Facebook::SignedRequestVerifier (HMAC-SHA256 against omniauth.facebook_secret)
  2. Idempotently creates a Privacy::DeletionRequest (source: 'facebook_callback') — repeat deliveries for the same UID return the existing row
  3. Returns { url: '/privacy/deletion_status/<code>', confirmation_code: '<code>' } so Meta can show the user a status page
  4. Enqueues Privacy::DataDeletionWorker which:
    • Resolves the FB UID → AuthenticationAccountParty. No match → transitions to no_account_found (terminal)
    • Runs Privacy::ManualReviewDetector for Tier-3 triggers. Any hit → transitions to held_for_review, alerts heatwaveteam@warmlyyours.com
    • Otherwise calls Privacy::ScrubService (Tier 1+2 anonymization), then Privacy::ProcessorDetachWorker (async Stripe/PayPal detach)
    • Transitions to completed on success → sends the user-facing completion email
    • Transitions to failed on error → alerts the team via the admin review queue
  5. 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.

The same admin UI handles non-callback requests:

  1. Create the row in /admin/privacy/deletion_requests (or via Rails runner for bulk) with source: 'manual_email' / 'manual_postal' / 'crm', capturing the requester’s email in data['contact_email'] so the completion email reaches them
  2. The intake alert email fires automatically via the model’s after_create_commit callback
  3. From the admin show page: Approve & start scrub runs the worker against the manually-linked account_id / party_id
  4. The same Tier-3 review gate, scrub, and email lifecycle applies — manual entries are not a separate code path

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

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.

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.


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:

  1. We’ll verify your identity using the email address on file (or, if needed, ask you for an order number).
  2. 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.
  3. 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

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.

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

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.


For every request, the Basecamp todo should end with:

  • Date acknowledged
  • Date verified (or “unverified — closed”)
  • Date executed
  • Linked scripts (paste each script/privacy/*.rb filename 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.


SituationWho
Suspected fraud / unauthorized requestSecurity lead + privacy officer
Bulk request (10+ in a day)Engineering — likely needs a script enhancement, not manual work
Legal hold conflictGeneral Counsel
Regulator inquiry (DPA, FTC, CPPA)General Counsel + CEO
Meta app dashboard banner not clearing after 48 hMarketing ops (re-submit the violation response)