Skip to main content

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​

MethodPathAuthPurpose
POST/api/v1/meta/data-deletionHMAC signed_requestMeta "Data Deletion Request URL". Responds { url, confirmation_code }.
POST/api/v1/meta/deauthorizeHMAC signed_requestMeta "Deauthorize Callback URL"
GET/api/v1/meta/data-deletion/status/:codePublicStatus for the page at unipulse.tech/data-deletion/status
DELETE/api/v1/auth/meJWT + authLimiterSelf-service account deletion. Body: confirmation (email or phone) and password if the account has one.
DELETE/api/v1/accounts/:idOWNER, ADMINDisconnect a social account (was EDITOR+)
DELETE/api/v1/whatsapp/disconnectOWNER, ADMINDisconnect WhatsApp; now also wipes the token
DELETE/api/v1/workspaces/:idOWNERRevokes 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_request is <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 numeric issued_at within the last 7 days (10 minutes of clock skew allowed), and a numeric app-scoped user_id.
  • Secrets are tried in this order:
    1. FACEBOOK_APP_SECRET: UniPulse's own app. The request may match connections in any workspace.
    2. Each active workspace PlatformCredential app 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 sweep job 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.
Deauthorize payload

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:

  1. At Meta (best-effort, never throws):

    • DELETE /{page}/subscribed_apps with the Page token;
    • DELETE /me/permissions with 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.
  2. Locally (always):

    • Hard-delete the row, unless inbox threads point at it (ConversationThread.socialAccountId has 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 on post_publications, schedules, ab_tests, webhook_conversations and ad_campaigns.
    • metaSubject is HMAC(ENCRYPTION_KEY, user id), so a later Meta deletion request can still find and scrub the row.
    • For account_deletion and meta_data_deletion, the identity is also scrubbed (platformUserId = deleted-<id>, name "Deleted connection").

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.

Workspace deletion bug fixed here

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.

  • Content lives in apps/web/src/legal/docs/*.ts as plain data, with company identity in apps/web/src/legal/company.ts as the single source.
  • LegalPage.tsx renders it in the SPA. Pages are mounted on both the marketing and app route trees, with no guards.
  • The legalPages() plugin in vite.config.ts:
    • injects Organization JSON-LD and a <noscript> legal-identity block into every HTML response;
    • writes dist/<path>.html for each document, with the full text inside #root;
    • writes dist/sitemap.xml.
  • nginx (unipulse.tech server) serves them with try_files $uri $uri.html /index.html. Crawlers that do not run JavaScript, Meta's included, get the full text with HTTP 200.

Configuration​

VariableDefaultUsed for
FACEBOOK_APP_SECRET—Verifying signed requests from UniPulse's own app
PUBLIC_SITE_URLhttps://unipulse.techThe 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/health reports metaDataDeletion.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.