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:truethendeleted:falseon independent timers with no backend ordering guarantee (and, on an active-active deployment, from independent sites). Correct convergence ofAIW_Account_Deleted__ctherefore relies on the n8n workflow applyingevent_tslast-write-wins to that field (the backend stamps a monotonic millisecondevent_tson every event — the same mechanism the create side already relies on to never regressemail_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 + deadsignup_intentcleanup) captures the affected emails before the bulk delete and then fires a single off-request batch ofdeleted: trueevents (one timer per sweep, serial delivery — never one timer per row, which would exhaustlua_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 ansf_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
2xxthat is empty or decodes to an object with no error signal counts as success; a2xxcarryingsuccess:false/ anerror/ a non-emptyerrorsarray, or a non-JSON / malformed body, is ansf_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_donesettings 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).