Meta Compliance, Deletion & Legal Pages
This page covers the code that lets UniPulse meet Meta's Platform Terms on data deletion, and the public legal pages Meta's Business Verification and App Review check. The full readiness report is in the Pulse repo at docs/compliance/META_VERIFICATION_READINESS.md.
Components
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/v1/meta/data-deletion | HMAC signed_request | Meta "Data Deletion Request URL". Responds { url, confirmation_code }. |
POST | /api/v1/meta/deauthorize | HMAC signed_request | Meta "Deauthorize Callback URL" |
GET | /api/v1/meta/data-deletion/status/:code | Public | Status for the page at unipulse.tech/data-deletion/status |
DELETE | /api/v1/auth/me | JWT + authLimiter | Self-service account deletion. Body: confirmation (email or phone) and password if the account has one. |
DELETE | /api/v1/accounts/:id | OWNER, ADMIN | Disconnect a social account (was EDITOR+) |
DELETE | /api/v1/whatsapp/disconnect | OWNER, ADMIN | Disconnect WhatsApp; now also wipes the token |
DELETE | /api/v1/workspaces/:id | OWNER | Revokes connections first, then purges FK-less workspace tables. Refuses with 409 ACTIVE_SUBSCRIPTION only while a recurring provider subscription is active; that is dormant today, because no provider creates one. |
signed_request verification
signed_requestis<base64url HMAC-SHA256 signature>.<base64url JSON payload>. The signature is computed over the encoded payload with the app secret and compared in constant time.- The payload must have
algorithm: HMAC-SHA256, a numericissued_atwithin the last 7 days (10 minutes of clock skew allowed), and a numeric app-scopeduser_id. - Secrets are tried in this order:
FACEBOOK_APP_SECRET: UniPulse's own app. The request may match connections in any workspace.- Each active workspace
PlatformCredentialapp secret: a "bring your own app" workspace. The request is confined to the workspaces holding that secret, so one tenant cannot forge a request against another.
- Idempotency:
requestFingerprint= SHA-256(kind+ decoded signature bytes), so base64url padding variants can't replay as new requests, and a deauthorize and a deletion stay distinct. A retried delivery gets the original confirmation code back. If the request is not yet COMPLETED it is re-enqueued; the job id de-duplicates. - Retries: 5 attempts with exponential backoff. A repeatable
sweepjob every 15 minutes re-enqueues requests that are still unfinished. The queue is in the admin Failed Jobs replay registry. - Privacy: the raw Meta user id is never stored. Matching connection ids are resolved at receipt time, and only
HMAC(ENCRYPTION_KEY, user_id)is kept, for correlation.
Meta documents the deletion callback payload. The deauthorize payload format is community-documented, not official. The handler only ends connections, which is safe if Meta ever sends a different shape: the request fails verification and nothing changes.
Ending a connection: social-connection-revocation.service.ts
endSocialConnection(account, reason) runs in two stages:
-
At Meta (best-effort, never throws):
DELETE /{page}/subscribed_appswith the Page token;DELETE /me/permissionswith the user token;- for WhatsApp,
DELETE /{waba}/subscribed_apps.
Each step is skipped if another active connection still uses that Page, Facebook user or WABA.
The whole-grant revoke has two extra safeguards:
- It is skipped when the connection's Facebook user is unknown. Instagram rows created before this change carry no
userFacebookId. - It is skipped when the same UniPulse user has any other active Facebook/Instagram connection, because that connection may hold the same grant.
-
Locally (always):
- Hard-delete the row, unless inbox threads point at it (
ConversationThread.socialAccountIdhas no FK). - If it has threads, or Postgres refuses the delete with
P2003, detach instead:workspaceId = null,isActive = false, tokens, scopes and avatar wiped, metadata reduced to{ detachedFromWorkspaceId, detachedAt, detachReason, metaSubject }. The refusal comes from the RESTRICT foreign keys onpost_publications,schedules,ab_tests,webhook_conversationsandad_campaigns. metaSubjectis HMAC(ENCRYPTION_KEY, user id), so a later Meta deletion request can still find and scrub the row.- For
account_deletionandmeta_data_deletion, the identity is also scrubbed (platformUserId = deleted-<id>, name "Deleted connection").
- Hard-delete the row, unless inbox threads point at it (
A detached row is reclaimed only by the workspace it was detached from. Another workspace gets a fresh row, and the old one is retired (platformUserId = retired-<id>) so it can never publish that workspace's queued posts. On every reconnect, adoptDetachedHistory moves the workspace's inbox threads from detached copies onto the live row. Detached rows are hard-deleted by purgeDetachedAccountsForWorkspace when their workspace is deleted.
A plain prisma.workspace.delete fails with a foreign-key violation for any workspace whose connected accounts have published posts: Postgres cascades into social_accounts before post_publications. purgeWorkspace() avoids this by releasing connections first. It is reproduced against a real database in src/services/__integration__/meta-compliance.integration.test.ts.
Public legal pages
- Content lives in
apps/web/src/legal/docs/*.tsas plain data, with company identity inapps/web/src/legal/company.tsas the single source. LegalPage.tsxrenders it in the SPA. Pages are mounted on both the marketing and app route trees, with no guards.- The
legalPages()plugin invite.config.ts:- injects Organization JSON-LD and a
<noscript>legal-identity block into every HTML response; - writes
dist/<path>.htmlfor each document, with the full text inside#root; - writes
dist/sitemap.xml.
- injects Organization JSON-LD and a
- nginx (
unipulse.techserver) serves them withtry_files $uri $uri.html /index.html. Crawlers that do not run JavaScript, Meta's included, get the full text with HTTP 200.
Configuration
| Variable | Default | Used for |
|---|---|---|
FACEBOOK_APP_SECRET | — | Verifying signed requests from UniPulse's own app |
PUBLIC_SITE_URL | https://unipulse.tech | The status URL returned to Meta |
ENCRYPTION_KEY | — | Keyed hash of the Meta user id |
Meta App Dashboard settings:
- Settings → Basic → User data deletion: Data deletion callback URL
https://api.unipulse.tech/api/v1/meta/data-deletion. - Facebook Login → Settings → Deauthorize callback URL:
https://api.unipulse.tech/api/v1/meta/deauthorize. - Privacy Policy URL:
https://unipulse.tech/privacy-policy. - Terms of Service URL:
https://unipulse.tech/terms-of-service.
Monitoring
- Failed jobs land in the admin Failed Jobs ledger.
GET /admin/healthreportsmetaDataDeletion.stuckRequests: requests not completed within an hour. Meta can disable an app whose callback does not actually delete, so a non-zero count needs attention.- The queue appears in admin queue health as
metaDataDeletion.