Skip to content

Salesforce lead sync (n8n)

On AI-Workspace registration-lifecycle events, the backend POSTs the registrant's full current snapshot to an n8n webhook. The n8n workflow creates or updates a Salesforce lead keyed by External_User_ID__c. The backend sends the raw normalized email as user_id; the n8n workflow owns the AIW- prefix and forms External_User_ID__c = AIW-<email> itself (the backend never pre-prefixes — doing so would double the key to AIW-AIW-<email>). This mirrors the existing "EU-CAPTCHA → Salesforce" integration with a separate AI-Workspace workflow and a separate secret.

Not a client-facing API. This is a server-to-server outbound integration. There is no request a browser makes here; the triggers below fire inside the sign-up / profile backend.

Status and GDPR gate

The sync is disabled (a complete no-op) until it is configured — both the webhook URL and the secret must be set (see Configuration). Nothing leaves the platform until a platform admin deliberately turns it on.

Do not enable in production before the privacy notice + data-processing agreement cover a Salesforce CRM recipient (personal data — email, and name once the user adds one — is exported). A separate compliance review owns that decision; the webhook stays off in production until it is cleared. The account-deletion → lead erasure path (event 6, GDPR Art. 17) now ships alongside the create-side events, so enabling the sync no longer exports personal data without a deletion path.

Go-live verification (ordering): the per-user delete↔restore toggle sends deleted:true then deleted:false on independent timers with no backend ordering guarantee (and, on an active-active deployment, from independent sites). Correct convergence of AIW_Account_Deleted__c therefore relies on the n8n workflow applying event_ts last-write-wins to that field (the backend stamps a monotonic millisecond event_ts on every event — the same mechanism the create side already relies on to never regress email_confirmed). Confirm this in the n8n workflow before enabling, or a delete-then-restore race could leave a live account flagged deleted (or vice-versa).

Trigger events

Each event sends the full current snapshot, not a delta. All are best-effort and off-request: the sync runs on a background timer, so a registration or profile save always succeeds even if n8n or Salesforce is down, slow, or returns an error.

# Event (event field) When email_confirmed plan_name
1 registration_started POST /admin/auth/signup/request (email entered, code sent) false — (omitted; no plan chosen yet)
2 email_confirmed POST /admin/auth/signup/verify (code accepted) true — (omitted)
3 account_created the no-card trial grant and the paid Stripe provision (covers the OTP, OAuth, and paid funnels) true the chosen plan
4 profile_updated PATCH /admin/auth/me when the name changes true the current plan
5 plan_changed a Stripe tier switch (customer.subscription.updated) or a trial→paid conversion true the new plan
6 account_deleted a user Art. 17 erasure (DELETE …/erase), a reversible user soft-delete (DELETE /me "Delete Account" or admin DELETE /users/{id}), a tenant soft-delete, a tenant purge, or the automated abandoned-trial reaper (tenant purge + dead signup_intent cleanup) — sends deleted: true true — (omitted)
— account_restored a tenant restore (un-does a tenant soft-delete) or a user restore (POST /users/{id}/restore) — sends deleted: false to revive the lead true the current plan

Events 4 and 5 fire only for the self-registration owner (event 4 gates on the tenant_admin registrant; event 5 resolves the tenant owner) — an invited teammate or a manually-provisioned enterprise owner never mints a lead through them (the self-serve gate fails closed on a missing/unknown plan). The deletion paths (event 6) and the user restore (account_restored with a user) instead apply to any user of a self-serve tenant — the registrant and invited teammates, one lead per person, matching the backfill — because each person has their own lead and their own data to erase/revive. Event 6 is self-serve-gated: for a single-user soft-delete or erase the module reads the surviving tenant's plan; for a tenant soft-delete/purge and the reaper the caller gates on the pre-delete tenant plan. A tenant purge and the reaper mark every affected user's lead deleted individually; a single-user soft-delete/erase marks that one person's lead, and a user restore clears it again.

Reaper (abandoned-trial) deletion. The hourly purge_reaper (cancelled self-serve tenant purge + dead signup_intent cleanup) captures the affected emails before the bulk delete and then fires a single off-request batch of deleted: true events (one timer per sweep, serial delivery — never one timer per row, which would exhaust lua_max_running_timers, and never inline, which would stall the maintenance tick when Salesforce is slow). The reaper never blocks or fails cleanup on a sync error. Residuals (best-effort, no redelivery net): a worker reload in the brief window after a sweep schedules its batch drops that batch's flags (same class as a dropped single deletion); a sustained Salesforce outage makes a full batch's serial delivery run long (bounded by the per-tick purge limits).

The stable key (no duplicates)

The lead key is the normalized email, sent as user_id; the n8n workflow forms External_User_ID__c = AIW-<email> by prepending the AIW- prefix itself. The backend never sends a pre-prefixed id. The normalized email is the one identifier present at every stage — the sign-up intent's email before an account exists, and the stored account email afterwards — and it is idempotent, so requesting a code several times (each a fresh sign-up intent) still resolves to one lead; it is also the backfill's per-user dedup key. The AIW- prefix (added by n8n) keeps these ids distinct from the EU-CAPTCHA (EC-) ids. (An email change would start a new lead; email changes are outside the current event set.)

Payload

A JSON object. Example (account_created, trial):

{
  "event": "account_created",
  "event_ts": 1759348200123,
  "user_id": "jane@corp.de",
  "email": "jane@corp.de",
  "email_confirmed": true,
  "registration_date": "2026-10-01T18:30:00Z",
  "plan_name": "Free",
  "conversion_type": "AIWS SELF REG",
  "first_name": "Jane",
  "last_name": "Doe",
  "company_name": "[not provided] (AIW_jane@corp.de)",
  "deleted": false,
  "sandbox": false
}
Field Salesforce field Notes
user_id External_User_ID__c the stable lead key — the raw normalized email; n8n prepends the AIW- prefix
email Email always present (normalized)
email_confirmed Email_Address_Confirmed__c false only for registration_started
registration_date AIW_Registration_date__c ISO-8601 UTC; omitted if unknown
plan_name AIW_Plan_Name__c Free / Custom / Enterprise; omitted pre-account (events 1–2)
conversion_type Trigger_Name__c constant "AIWS SELF REG"
first_name / last_name FirstName / LastName split from the single account name; a missing last name → "[not provided]" (Salesforce requires it); first_name omitted when there is only one token
company_name Company never collected → placeholder "[not provided] (AIW_<user_id>)"
deleted AIW_Account_Deleted__c true on account_deleted (event 6); false on every other event (incl. account_restored and a re-registration after deletion)
sandbox — (routing) the sf_sync_sandbox flag; n8n picks the Salesforce target
event, event_ts — the trigger name and a millisecond timestamp so n8n can apply last-write-wins and never regress a confirmed lead back to unconfirmed

The request carries Authorization: <secret> and Content-Type: application/json.

Configuration

DB-backed only (never environment variables), via the global settings API — platform admin (SETTINGS_MANAGE):

Key Meaning
sf_sync_webhook_url the n8n webhook URL (https only)
sf_sync_webhook_secret the Authorization secret — stored encrypted, write-only
sf_sync_sandbox route to the Salesforce Sandbox (true) vs production (false/absent)

The feature is active only when both the URL and the secret are set. The secret is not the EU-CAPTCHA secret — use a dedicated AI-Workspace secret.

Failure handling, retries, and alerting

  • Off-request, pcall-guarded. A failure (or a throw) in the sync can never fail or slow the user's registration / profile save.
  • Short timeouts (2 s connect/send, 5 s read, 10 s overall) and a capped response body.
  • Bounded retry (up to 3 attempts, short backoff) on transient failures — a transport error / timeout, or an HTTP 5xx.
  • No retry on a 4xx (a request-shape error a retry won't fix) or an sf_error (see below).
  • Untrusted response, fail closed. The n8n/Salesforce response is treated as hostile: the body is decoded with a never-throws parser, and "it parsed" is not "it succeeded". Only a 2xx that is empty or decodes to an object with no error signal counts as success; a 2xx carrying success:false / an error / a non-empty errors array, or a non-JSON / malformed body, is an sf_error (alerted, not retried, never treated as success).
  • Alerting (MAP-619). On a permanent failure, a Salesforce rejection, or exhausted retries, a [platform_alert] (signal = sf_sync_failing) fires — at most once per hour while failing, with no URL or secret in the alert fields.
  • Redelivery net. A worker restart that discards an in-flight timer drops that one event; the one-time backfill (below) re-sends every existing user, idempotently, so a dropped create/update event self-heals. A dropped deletion has no such net (a deleted user is gone from the roster), so a failed deletion schedule is escalated to ngx.ERR + a [platform_alert] for a manual mark.

One-time backfill

When the webhook is first configured, every user who registered before go-live has no lead. A one-time backfill (worker 0, at boot, off a zero-delay timer) sends every existing active self-serve user individually (one lead per person) with deleted: false, so Sales sees the back-catalogue.

  • Config-gated. Runs only when the sync is configured (sf.enabled()); never an env var. It fires on the first worker-0 boot after the webhook is set (a reload / redeploy) — the deliberate, deploy-coordinated go-live moment.
  • Fleet-single. Holds the fleet-wide DB lease (GET_LOCK), so on an active-active deployment exactly one site runs the pass — never an N-site webhook burst.
  • Idempotent + resumable. Completion is recorded by a single sf_sync_backfill_done settings flag, written once when the population drains. A crash mid-pass simply re-runs the whole pass on the next qualifying boot; the idempotent SF upsert (keyed by the normalized email) dedupes the re-sends, so it can only converge. It doubles as the redelivery net for dropped create/update events.
  • Scope. Active users of live self-serve tenants only; soft-deleted users/tenants, platform admins, the shared demo identity, and seed/review operator accounts are excluded (they are not genuine signups and would pollute the Sales funnel).