# App Users: your app's sign-ups in the CRM

> The complete guide: connect your app's own sign-in (Dedicated Cloud, Supabase, Firebase, Clerk, Auth0, any OpenID provider or your own code) to your CRM, so everyone who signs up becomes a contact. In one click, by asking the AI in the chat, or from Claude, Codex or Cursor over MCP.

Source: https://support.estage.com/app-users-your-app-s-sign-ups-in-the-crm
Part of the ESTAGE Support help center (https://support.estage.com). Full knowledge base: https://support.estage.com/llms-full.txt

---

## In this guide

1. [What App Users does](#what-app-users-does)
2. [Before you start](#before-you-start)
3. [Four ways to set it up](#four-ways-to-set-it-up)
4. [Step by step: Genesis Dedicated Cloud](#step-by-step-genesis-dedicated-cloud)
5. [Step by step: every other sign-in](#step-by-step-every-other-sign-in)
6. [Everything you can ask the AI in the chat](#everything-you-can-ask-the-ai-in-the-chat)
7. [From Claude, Codex, Cursor or ChatGPT (MCP)](#from-claude-codex-cursor-or-chatgpt-mcp)
8. [The bridge card](#the-bridge-card)
9. [Importing the accounts you already have](#importing-the-accounts-you-already-have)
10. [What happens to each person](#what-happens-to-each-person)
11. [Tags, lists and consent](#tags-lists-and-consent)
12. [What you get in the CRM](#what-you-get-in-the-crm)
13. [Privacy and safety](#privacy-and-safety)
14. [For developers](#for-developers)
15. [Questions people ask](#questions-people-ask)
16. [Troubleshooting](#troubleshooting)

## What App Users does

Your CRM learns about people from your forms, orders and bookings. An app with its **own
sign-in** is different: people create an account in the app, never fill in an Estage form, and
the CRM never hears of them. No list, broadcast, autoresponder or automation can reach them.

**App Users** is a bridge from that sign-in to the CRM:

- **Every new account becomes a contact** the moment it's created, with the tags you choose.
- **The accounts you already have are imported** in one go, without welcome emails.
- **The contact stays linked to the account**: a changed email moves with it, a sign-in updates
  "last signed in", a deleted account is marked, and with one setting the person stops getting
  your emails.
- **A new automation trigger, Signed up in app**, starts an onboarding sequence for each new
  account. See [App sign-ups in the CRM](https://support.estage.com/app-sign-ups-in-the-crm-the-trigger-app).

Your app's sign-in stays exactly as it is. Nobody signs in through Estage, nothing changes for
your users, and a sign-up is never slowed down or blocked by the bridge.

**A typical example.** A SaaS built in Genesis runs on a Dedicated Cloud, where people sign up with
an email code or Google. Connected once, its 11 existing users land in the CRM tagged *App users*.
From then on every new sign-up arrives within a second, and a *Signed up in app* automation sends
them a welcome email and a tip two days later.

## Before you start

| | |
| --- | --- |
| **Where** | Genesis → your project → **Settings → App Users** (in the **AI & Automation** group). Or search Settings for "App Users". |
| **Who** | Someone with edit access to the project and the **App Users** permission in their [role](https://support.estage.com/project-settings-roles). The permission is marked sensitive, so give it only to people you trust with your users' data. |
| **Which apps** | Any app of the project: a Website, a Web app or a Mobile app. The contacts go to the project's CRM. |
| **Which CRM** | The project's own CRM at [crm.estage.com](https://crm.estage.com). Nothing to set up there. |
| **Cost** | No extra charge. Setting it up with the AI uses ordinary AI credits for that chat turn. |

## Four ways to set it up

| Way | Best when |
| --- | --- |
| **Connect to CRM** in Settings → App Users | You want it done now, with a click. Works for every sign-in. |
| **Set it up with AI** in Settings → App Users | The app's code needs a line added (Firebase, OpenID), or you'd rather not click through anything. The button opens the builder chat with the request already written. |
| **Ask in the builder chat**, in your own words | You're already talking to the AI, for example while building a sign-up page. |
| **From an outside AI tool over MCP** | You build with Claude, Claude Code, Codex, Cursor or ChatGPT connected to Genesis. |

All four do exactly the same thing underneath and give the same result.

> **Tip**
>
> You often don't have to start it at all. When the AI builds or changes a sign-up or sign-in in
> your app, it checks whether those people reach your CRM and, if not, offers the bridge in one
> sentence. Say yes and it's done.

## Step by step: Genesis Dedicated Cloud

This is the most common case: a Genesis app whose sign-in runs on its Dedicated Cloud (email and
password, email codes, magic links, Google or any other provider the cloud has turned on).
**There is nothing to do by hand: no SQL, no code, no keys.**

1. **Open Settings → App Users.** Genesis has already looked at the project. A card at the top
   says which sign-in it found and how many accounts it has, with the evidence underneath, for
   example *"Dedicated Cloud is active"* and *"app/login/LoginForm.jsx signs people in with
   supabase.auth"*.
2. **Press Connect to CRM.** A short dialog opens:
   - **New sign-ups**: *"A trigger in your Dedicated Cloud reports every sign-up, profile change
     and deletion the moment it happens. Nothing changes in your app."* Nothing to choose.
   - **Existing accounts**: *Import the N accounts it already has*, on by default. They get no
     welcome emails.
   - **Tag them as**: *App users* by default. Type your own, comma-separated (for example
     `App users, StoreReady`). Missing tags are created.
   - **When someone deletes their account in the app**: *Keep the contact as it is* (default) or
     *Keep the contact, stop emailing them*.
3. **Press Connect.** In a few seconds the dialog says what was done: the trigger is installed and
   the import has started. Press **Done**.
4. **Check the card.** Under **Connected** there is now a card *Genesis Dedicated Cloud* with
   **Live**, the number of contacts, and the last import (*"11 new contacts, 0 linked to existing
   ones"*).
5. **Try it.** Sign up in your app with a new email. Within seconds it shows in the card's
   **Activity** and as a contact in the CRM, with your tags.

Instead of steps 2 and 3 you can press **Set it up with AI**: the builder chat opens and the AI
does the same and tells you what it did.

What Genesis did for you: it added a trigger on the cloud's sign-in table through a regular,
journaled migration (you can see it in **Cloud → History**), and wrote the bridge's address and
secret straight into the database, outside the journal, so the secret never shows up in your
history. Disconnecting removes both again.

> **Note**
>
> A linked app that uses another project's Dedicated Cloud belongs to that project's CRM. App
> Users says *"This app shares the Dedicated Cloud of project …"* and sends you there instead of
> making a second bridge.

## Step by step: every other sign-in

If Genesis didn't find your sign-in, or found several, use **Connect another sign-in** at the
bottom of App Users and pick it. Others it found are listed under **Also found in your app**, each
with its own **Connect**.

### Supabase (a project of your own)

What Genesis does depends on what it can reach:

**The Supabase connector with a management token** (Settings → Connectors → Supabase): the same as
the Dedicated Cloud. **Connect to CRM** installs the trigger through the connector and imports the
existing users with the keys the connector holds. Nothing else to do.

**The connector without a management token, or a Supabase Genesis found in your app's code**:
Genesis knows the project's public address and key, which is enough to check your users' sign-ins.

1. Press **Connect to CRM**, then **Connect**. Genesis adds the identify module to your app and
   shows the one line to add after `createClient(…)`: `bridgeSupabaseAuth(supabase)`.
2. Press **Ask AI to add it**, then **Publish** the app.
3. For the existing users, **Import existing** takes the **Project URL** and the **Secret
   (service_role) key**, or a CSV of `auth.users`.

For the instant database trigger instead, add a management token to the Supabase connector and
connect again, or run the SQL from **Set up by hand** once.

**Nothing Genesis can reach** (a Supabase it doesn't know about):

1. Press **Connect to CRM**, then **Connect**. The result shows the SQL with the bridge's secret
   already filled in. Copy it now: the secret is shown once.
2. Open your project in the Supabase dashboard → **SQL Editor**, paste, **Run**. The script
   enables `pg_net`, creates a private schema `estage_bridge` and a trigger on `auth.users`, and
   sends a test delivery.
3. The card turns **Live** when the test delivery arrives.
4. For the import, **Import existing** takes the **Project URL** and the **Secret (service_role)
   key**, or a CSV exported from Supabase.

Later, **Set up by hand** on the card shows the same SQL (and the script that removes it). Running
it again is safe. If you've lost the secret, **Make a new secret** there fills it in. Then run the
SQL again, because the old secret stops working at once.

> **Tip**
>
> One Supabase database can serve the apps of several Estage projects. Each project connects its
> own bridge, and every sign-up then reaches every one of their CRMs. Removing one project's bridge
> leaves the others working.

### Firebase Authentication

Firebase has no database trigger, so the app hands the signed-in person's ID token to Estage,
which checks it with Google before filing anyone.

1. Press **Connect to CRM**. If Firebase isn't in Connectors, fill in the **Firebase project id**
   (Firebase console → Project settings, for example `my-app-1a2b3`). Genesis fills it in when it
   finds it in your app's config.
2. Press **Connect**. Genesis adds a small protected module to your app,
   `src/lib/estageIdentity.js` (`lib/estageIdentity.js` in a Web app), and shows the one line to
   add next to `getAuth(app)`: `bridgeFirebaseAuth(auth)`.
3. Press **Ask AI to add it** and the AI adds the line. Or ask in the chat yourself.
4. **Publish** your app, so the line reaches your users.
5. For the existing accounts, **Import existing** takes the **Service account key (JSON)**
   (Firebase console → Project settings → Service accounts → Generate new private key; paste it
   or drop the file), or a `firebase auth:export` file.

Optional: tick **Also check for new accounts every 10 minutes**. With the service account key it
picks up new accounts even before the line in your app is published. If you're on Firebase's
Blaze plan, **Set up by hand** also has Cloud Functions that report sign-ups and deletions the
moment they happen.

A token counts as a **sign-up** only when the account is less than 48 hours old. An older
account's first token links the person without starting your *Signed up in app* automations.

### Clerk

1. Press **Connect to CRM**, then **Connect**. The result shows the bridge's events address.
2. In the Clerk dashboard: **Webhooks → Add endpoint**, paste the address and subscribe to
   `user.created`, `user.updated`, `user.deleted` and `session.created`.
3. Copy the endpoint's **Signing secret** (`whsec_…`) and paste it in the card's **Settings →
   Clerk webhook signing secret**. Until then the card says *Waiting for Clerk's webhook signing
   secret*.
4. For the import, **Import existing** takes Clerk's **Secret key** (`sk_live_…`), or a CSV
   export.

### Auth0

1. Press **Connect to CRM**, then **Connect**. Copy the secret shown in the result, along with
   the ready Action code.
2. In Auth0: **Actions → Library → Build Custom**, trigger **Post User Registration**.
3. Add a secret named `ESTAGE_BRIDGE_SECRET` with the bridge's secret, paste the code, **Deploy**,
   and add the Action to the **Post User Registration** flow.
4. Existing users: export them from Auth0 and upload the file in **Import existing**.

### OpenID Connect (Cognito, Kinde, Keycloak and others)

1. Press **Connect to CRM**. Fill in the **Issuer URL** (for example
   `https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_AbC123`) and, if your tokens carry one,
   the **Audience** (the client id they are issued for).
2. Press **Connect**. Genesis adds the identify module and the AI adds one line after sign-in:
   `identifyWithEstage(idToken, { userId })`.
3. Existing users: upload an export (CSV or JSON) in **Import existing**.

Estage checks each token against the issuer's published keys. A token from another app fails that
check.

### Your own sign-in

When your app signs people in with its own code and database:

1. **Connect another sign-in → Your own sign-in → Connect.** Copy the secret from the result.
2. Your server makes one call when someone signs up, changes their profile, signs in or deletes
   their account. **Set up by hand** has ready code: a `curl` call and a Node.js function that
   signs each request. The format is under [For developers](#for-developers).
3. Existing users: your server sends them in batches of up to 500 (`{ "users": [ … ] }`), or you
   upload a CSV with an email column.

Or ask the AI: *"Report every sign-up in my app to my CRM with the Auth Bridge."* It wires the
calls on the server side and keeps the secret out of the browser.

### Which sign-in needs what: summary

| Sign-in | New accounts arrive by | Import existing accounts with | You do |
| --- | --- | --- | --- |
| **Genesis Dedicated Cloud** | A database trigger, instantly | Nothing, automatic | Nothing |
| **Supabase**, connector with a management token | A database trigger, instantly | Nothing, automatic | Nothing |
| **Supabase**, connector without the token, or found in the code | The app hands over the token | Project URL + secret key, or a CSV | One line (the AI adds it), then publish |
| **Supabase** Genesis can't reach | A database trigger, instantly | Project URL + secret key, or a CSV | Run one SQL script once |
| **Firebase** | The app hands over the token; optionally a 10-minute check | Service account JSON, or `firebase auth:export` | One line (the AI adds it), then publish |
| **Clerk** | Clerk's webhook, instantly | Secret key, or a CSV | Add the endpoint, paste the signing secret |
| **Auth0** | An Action, instantly | An export file | Paste the Action |
| **OpenID Connect** | The app hands over the token | An export file | Issuer URL; one line (the AI adds it) |
| **Your own sign-in** | Your server's call | `{ "users": [ … ] }`, or a CSV | One call in your server code |

## Everything you can ask the AI in the chat

The builder's AI has the bridge as its own tools. Ask in plain words, in any language. It checks
first, does the work, and tells you what's done and what (if anything) is left for you.

**Find out where you stand**

- *"Who signs in to my app, and are they in my CRM?"*
- *"Is the CRM bridge working? When did the last sign-up arrive?"*
- *"How many of my app's users are contacts?"*

**Connect**

- *"Connect my app's sign-in to my CRM and import the existing users."*
- *"Connect it, tag everyone as Beta testers, and don't import the old users."*
- *"Connect Firebase. The project id is my-app-1a2b3."*
- *"Connect our Cognito sign-in. The issuer is https://cognito-idp…"*

**Build and connect at once**

- *"Add sign-up with email and Google, and put everyone who signs up in my CRM."*
- *"Make a members area with sign-in, and add new members to my Newsletter list when they agree to
  emails."*

**Import**

- *"Import my app's users into the CRM again."*
- *"Import them and run my new-contact automations for them too."*

**Tags, lists and consent**

- *"Tag everyone from the app as App users and StoreReady."*
- *"Add new sign-ups to the Newsletter list."*
- *"Only add them to the list when they ticked marketing emails. That's saved in
  user_metadata.marketing_opt_in."*

**When someone deletes their account**

- *"When someone deletes their account in the app, stop emailing them."*
- *"Keep emailing people who delete their app account."* (back to keep)

**Pause, fix, remove**

- *"Pause the CRM bridge for now."* / *"Resume it."*
- *"The bridge says Needs attention. Fix it."*
- *"New sign-ups stopped arriving in the CRM. Check it."*
- *"Disconnect the CRM bridge."* (the contacts stay)

**Clerk**

- *"Here's Clerk's webhook signing secret: whsec_…"* The AI saves it on the bridge.

What the AI will **not** do, on purpose:

- It never asks you to paste a service key, a service account or a secret key into the chat. Keys
  for an import are typed in **Settings → App Users**, where they're used once and erased. The
  only key it accepts in the chat is Clerk's webhook signing secret, which can't read anything.
- It never writes its own code to send people to Estage, and never puts a key in your app's code.
  It uses the bridge's module or trigger.
- It connects, changes or disconnects only after you've said so. Checking is free to do anytime.

> **Note**
>
> When you press **Set it up with AI** or another *… with AI* button in Settings, the builder
> opens and the request appears in the chat as soon as the chat has loaded, with the AI's work
> showing live. If you reload the page while the AI is working, the chat picks the running turn
> back up and keeps showing it.

## From Claude, Codex, Cursor or ChatGPT (MCP)

Any AI tool connected to Genesis over MCP gets the same bridge, as five tools. Set up the
connection once, as in [Connect Claude](https://support.estage.com/claude-code) (Codex, Cursor and
ChatGPT work the same way), then ask in your own words, exactly as in the chat above.

Both kinds of connection have the tools:

- **One project**: the address `https://genesis.estage.com/api/agent/<project id>/mcp`.
- **Your whole account**: every tool takes a `projectId`, and the AI finds the project you name.

### The tools

| Tool | What it does | Needs |
| --- | --- | --- |
| `genesis_auth_bridge_status` | Which sign-in the app uses and how many accounts it has, the bridges already connected with their health (live, waiting, error, paused), contacts, last import, and a `suggestion` when people sign in but nothing brings them to the CRM. Read-only. `fresh: true` looks at the project again instead of the minute-old answer. | CRM read |
| `genesis_auth_bridge_connect` | Connects the sign-in and imports existing accounts. Installs the trigger itself on a Dedicated Cloud or a Supabase connector with a management token; otherwise adds the identify module and returns the one line to add (`wiring`), or the webhook steps. | CRM write, plus Cloud write for a Dedicated Cloud, Connectors admin for Supabase |
| `genesis_auth_bridge_import` | Imports (again) the accounts a connected sign-in already has. Nobody is added twice; no automation runs unless asked. | CRM write |
| `genesis_auth_bridge_update` | Pause or resume, tags, lists, consent, what a deleted account means, the 10-minute check, Clerk's signing secret. | CRM write |
| `genesis_auth_bridge_disconnect` | Stops the bridge and removes the trigger. The contacts stay. | CRM write |

### Their parameters

**`genesis_auth_bridge_connect`**

| Parameter | |
| --- | --- |
| `provider` | `dedicated_cloud`, `supabase`, `firebase`, `clerk`, `auth0`, `oidc` or `custom`. Leave it out to use the sign-in Genesis detected. |
| `importExisting` | Import the existing accounts. Default `true`. |
| `tagNames` | Tags for everyone it brings in, created when missing. Default `["App users"]`; `[]` for none. |
| `listIds` | CRM lists for new sign-ups. |
| `consentPath` | Where the profile keeps marketing consent, e.g. `user_metadata.marketing_opt_in`. Lists then only for people who said yes. |
| `onDelete` | `keep` or `suppress`. |
| `poll` | Check for new accounts every 10 minutes (Firebase). |
| `issuer`, `audience` | For OpenID Connect, Auth0 or Clerk tokens. |
| `firebaseProjectId` | Firebase without a connector. |

**`genesis_auth_bridge_update`**: `sourceId` (from status), plus any of `status` (`active` or
`paused`), `tagNames`, `tagIds`, `listIds`, `consentPath`, `onDelete`, `poll`, `providerSecret`
(Clerk's `whsec_…`).

**`genesis_auth_bridge_import`**: `sourceId`, and `runAutomations` (default `false`).

**`genesis_auth_bridge_disconnect`**: `sourceId`.

The connect answer has `ok`, `message` (what to tell you), `live` (how new accounts will arrive
and whether that's done), `import` (whether it started and how many), `wiring` (the line the AI
adds to your app, when there is one) and `next` (anything left for you). An agent never receives
a secret or a provider key in any of them.

### A typical conversation

> **You:** Are my app's users in my CRM?
>
> **Claude** *(calls `genesis_auth_bridge_status`)*: Your app signs people in with its Dedicated
> Cloud (11 accounts), and nothing brings them to your CRM yet. Shall I connect it and import
> them?
>
> **You:** Yes, tag them StoreReady.
>
> **Claude** *(calls `genesis_auth_bridge_connect` with `tagNames: ["StoreReady"]`)*: Done. Every
> new sign-up becomes a contact the moment it happens, and the 11 existing users are being
> imported, without welcome emails.

## The bridge card

Every connected sign-in has a card under **Connected** in Settings → App Users.

**The status**

| Status | Means |
| --- | --- |
| **Live** | Deliveries arrive. Nothing to do. |
| **Waiting for the first sign-up** | Connected, nothing has arrived yet. Normal right after connecting a sign-in that has no trigger: sign up once to see it work. |
| **Needs attention** | The last delivery failed. The card shows the reason and **Fix with AI**. |
| **Paused** | You paused it. New accounts are not filed until you resume. |

**What it shows**: how it syncs (*"A database trigger reports every sign-up, change and
deletion"*), the number of contacts it brought in, what arrived in the last 24 hours, when the
last delivery came, and the last import (new contacts, linked to existing ones, skipped and why).
While an import runs, a progress bar.

**Its buttons**

| Button | |
| --- | --- |
| **Import existing** | Imports the accounts the sign-in already has, or re-imports them. Keys or an export file where the sign-in needs them (never for a Dedicated Cloud). Optional: *Run my "new contact" automations for them*. |
| **Activity** | The last deliveries: when, what (created, updated, signed in, deleted), the email, and whether it was filed or skipped and why. |
| **Set up with AI** | Opens the builder chat with a request to check the bridge and finish or repair whatever it still needs. |
| **Set up by hand** | Only for sign-ins outside Genesis: the steps, the events and identify addresses, the code (SQL, Action, curl, Node.js) and **Make a new secret**. Not shown for a Dedicated Cloud, where there's nothing to do by hand. |
| **Settings** | *Also tag people it brings in*; *When someone deletes their account in the app* (keep, or keep and stop emailing); *Clerk webhook signing secret* for Clerk. |
| **Pause / Resume** | Stops and restarts filing new accounts. |
| **Disconnect** | Stops the bridge after a confirmation. The trigger Genesis installed is removed and the module goes quiet. **Every contact it brought stays in the CRM.** |

## Importing the accounts you already have

An import brings in every account the sign-in already has, as contacts linked to their accounts.

- **Automatic** for a Dedicated Cloud, a Supabase connector with a management token, and a Firebase
  connector: Genesis holds the keys.
- **With keys you type once** in **Import existing**: Supabase (*Project URL* and *Secret
  (service_role) key*), Firebase (*Service account key*, JSON), Clerk (*Secret key*). They're used
  for that import only and erased when it ends. They're never stored and never shown to the AI.
- **With an export file**, from anything:

| File | |
| --- | --- |
| `firebase auth:export` | JSON, as the Firebase CLI writes it |
| Supabase | CSV of the `auth.users` table |
| Clerk | CSV export |
| Any CSV | Needs an `email` column. Optional: `id` (or `uid`, `user_id`), `name` (or `full_name`, `display_name`), `first_name`, `last_name`, `phone`, `created_at`, `last_sign_in_at` |

Up to 50 MB per file and 200,000 accounts per file (a JSON file up to 20 MB; export CSV for more,
or split it).

**What an import does**

- Runs in the background. You can close the page; the card and the CRM show the progress.
- **Starts no automations** by default: no welcome email, no *Contact created*, no *Tag added*,
  no *Signed up in app*. These people signed up long ago. Tick *Run my "new contact" automations
  for them* only if you mean it.
- **Adds nobody twice.** Run it again anytime: people already linked are updated, not copied.
- Ends with a line: *"Last import: 11 new contacts, 0 linked to existing ones · 2 skipped (with
  no email address)"*.

**Why an account can be skipped**

| Reason | |
| --- | --- |
| no email address | Phone-only accounts. The CRM is keyed by email. |
| a role address | postmaster@, abuse@ and similar, which are never a person |
| an invalid address | Not a deliverable email |
| an unconfirmed address | Only when the bridge is set to require confirmed emails |
| an unreadable row | A line in a file that couldn't be read |
| the bridge was paused | Deliveries that arrived while paused |
| a repeated delivery | The same event sent twice, filed once |

## What happens to each person

- **One account, one contact.** The account is recognised by its id in the sign-in, the person by
  their email. A changed email in the app moves the link to the new address instead of making a
  second contact.
- **Nothing you know is overwritten.** Someone already in your CRM, from a form or an order, is
  linked to their account and keeps their name, tags, lists and history. A missing phone number
  is filled in, an existing one is kept.
- **Tags on arrival.** The bridge's tags go on the first time a person arrives through it.
- **New sign-ups start automations**: *Signed up in app*, and your other new-contact automations.
  **Imports don't**, unless you ask.
- **Sign-ins** update *last signed in*, at most once an hour per person.
- **A deleted account** keeps the contact and marks the account *Removed in app*. With *stop
  emailing them* (`suppress`), the address is also suppressed and unsubscribed from the project's
  lists.
- **A contact you delete from the CRM** isn't brought back by their next sign-in.
- **The same email in different projects** gives each project its own contact, with its own tags,
  lists, history, automations and unsubscribes. One project's bridge never changes another
  project's contacts. The person's shared Estage account details (name, phone) are never rewritten
  by a bridge.

## Tags, lists and consent

- **Tags** are set when connecting (**Tag them as**) and changed in the card's **Settings** (*Also
  tag people it brings in*) or by asking the AI.
- **Lists** are added by asking the AI (*"Add new sign-ups to the Newsletter list"*). They apply to
  **new** sign-ups, and the bridge **never adds someone to a list over an unsubscribe**: a suppressed
  email, or anyone who left that list, is left alone.
- **Consent.** If your sign-up form asks for permission to email, tell the AI where it's saved
  (for example `user_metadata.marketing_opt_in`). Then only people who said yes are added to your
  lists. Tags still go on everyone, since tags don't send anything.

## What you get in the CRM

Everything below is in the project's CRM at [crm.estage.com](https://crm.estage.com). The full
guide is [App sign-ups in the CRM](https://support.estage.com/app-sign-ups-in-the-crm-the-trigger-app).

- **Contacts** with your tags, ready for lists, broadcasts, segments and automations.
- **App accounts** on each contact's page: which sign-in, when they signed up and how (email,
  Google…), when they last signed in, *Removed in app*.
- The contact's **Automation Log**: *Signed up in app*, *App account linked*, *App account
  deleted*.
- The **Signed up in app** automation trigger, for every sign-in or only the ones you pick.
- **Settings → Import → Your app's users**: each connected sign-in, its health, and an import of
  its accounts with keys or a file.

## Privacy and safety

- **Your users' sign-in is untouched.** Estage never signs anyone in, sees no password and holds
  no session.
- **Only what a CRM needs is sent**: email, whether it's confirmed, name, phone, when the account
  was created and last used, how the person signs in, and the account's id. Never a password, a
  password hash, a token or session data. The database trigger reads only those columns.
- **A sign-up is never blocked.** If the CRM can't be reached, the trigger and the module give up
  quietly and the app carries on.
- **Tokens** your app hands over are checked with the provider that issued them, used once, and
  never stored or logged. A token from another app is refused.
- **Secrets** are shown once, when they're made, and stored encrypted. Keys for an import are used
  for that import and erased.
- **The AI never sees a secret or a provider key**, in the chat or over MCP.
- **Every delivery is authenticated** (the bridge's secret, a signature, or Clerk's signed webhook)
  and rate-limited. An unsigned request is refused.
- **Who can touch it** is decided by the **App Users** permission and the project role. The AI
  and MCP tools follow the same rules.

## For developers

### The events endpoint

```text
POST https://api.estage.com/api/public/identity/<source id>/events
```

The source id (`isrc_…`) and the full address are in the card's **Set up by hand**.

**Authentication**, one of:

1. `Authorization: Bearer <secret>`, the simplest, for a trigger or a no-code tool.
2. A signature, for your own server:
   `X-Estage-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>`.
   Accepted within 5 minutes of `t`.
3. Standard Webhooks (Svix) headers signed with the provider's secret. That's how Clerk signs.

**Body**

```json
{
  "type": "user.created",
  "id": "evt_123",
  "user": {
    "id": "42",
    "email": "almira@example.com",
    "email_verified": true,
    "name": "Almira Costa",
    "first_name": "Almira",
    "last_name": "Costa",
    "phone": "+15551234567",
    "created_at": "2026-10-26T09:00:00Z",
    "last_sign_in_at": "2026-10-26T09:00:00Z",
    "method": "google",
    "metadata": { "plan": "pro" }
  }
}
```

- `type`: `user.created`, `user.updated`, `user.signed_in`, `user.deleted` (and `user.imported`).
- `id`: the event's id. A retry with the same id is filed once. The headers `X-Estage-Event-Id`,
  `webhook-id` and `svix-id` work too.
- Several at once: `{ "events": [ … ] }`, up to 500.
- Existing accounts: `{ "users": [ … ] }`, up to 500 per call. Treated as an import: no
  automations, no Activity entries.
- Supabase Database Webhook bodies (`INSERT` / `UPDATE` / `DELETE` on `auth.users`) and Clerk
  webhook bodies are understood as they are.

**Answer**: `200 { "accepted": 1, "skipped": 0, "failed": 0, "ignored": 0 }`. Events that aren't
about accounts (such as `organization.created`) are `ignored`, never an error, so the sender
doesn't retry them. Limit: 1,200 deliveries a minute per bridge.

**Node.js, signed**

```js
import crypto from 'node:crypto'

// Call when someone signs up (user.created), changes their profile
// (user.updated), signs in (user.signed_in) or deletes their account (user.deleted).
export async function reportToEstage(type, user) {
  const body = JSON.stringify({
    type,
    id: crypto.randomUUID(),
    user: { id: String(user.id), email: user.email, name: user.name, created_at: user.createdAt },
  })
  const t = Math.floor(Date.now() / 1000)
  const v1 = crypto.createHmac('sha256', process.env.ESTAGE_BRIDGE_SECRET).update(`${t}.${body}`).digest('hex')
  await fetch('https://api.estage.com/api/public/identity/<source id>/events', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Estage-Signature': `t=${t},v1=${v1}` },
    body,
  })
}
```

**curl**

```bash
curl -X POST 'https://api.estage.com/api/public/identity/<source id>/events' \
  -H 'Authorization: Bearer <secret>' \
  -H 'Content-Type: application/json' \
  -d '{"type":"user.created","id":"evt_1","user":{"id":"42","email":"almira@example.com"}}'
```

Keep the secret on your server, in an environment variable such as `ESTAGE_BRIDGE_SECRET`. Never
put it in browser or mobile code.

### The identify endpoint (tokens from the browser)

```text
POST https://api.estage.com/api/public/identity/<source id>/identify
{ "token": "<the signed-in person's ID or access token>", "created_at": "<optional>" }
```

Open to browsers (CORS). The token is checked with its issuer: Supabase's `/auth/v1/user`,
Google's keys for Firebase, the issuer's JWKS for OpenID Connect. The answer is `200 { "ok": true }`
and nothing else; it never reveals a contact. Errors: `401` with `invalid_token`,
`wrong_audience` or `token_expired`; `502 verifier_unavailable`. Limits: 30 a minute per IP,
1,200 per bridge.

### The identify module

For apps that hand tokens over, Genesis adds `src/lib/estageIdentity.js` (`lib/estageIdentity.js`
in a Web app). It's protected: Genesis rewrites it when the bridge changes, so don't edit it.

```js
import { bridgeSupabaseAuth, bridgeFirebaseAuth, identifyWithEstage } from '@/lib/estageIdentity'

bridgeSupabaseAuth(supabase)          // where the Supabase client is made
bridgeFirebaseAuth(auth)              // next to getAuth(app)
identifyWithEstage(idToken, { userId, createdAt })  // any other sign-in, after it succeeds
```

It never throws, never blocks the sign-in, and sends each person at most once per browser every 12
hours. It holds no secret: only the bridge's public identify address.

### The database trigger (Supabase and Dedicated Cloud)

- An `AFTER INSERT OR UPDATE OR DELETE` trigger on `auth.users`, in a private schema
  `estage_bridge` that `anon` and `authenticated` can't read, using `pg_net`.
- Sends only the columns above. An update is sent only when something the CRM keeps changed; a
  sign-in at most once an hour per person.
- Swallows every error: a CRM that's down never blocks a sign-up.
- One row per connected Estage project in `estage_bridge.endpoints`, so one database can report
  to several projects; an unreachable one doesn't stop the others.
- The uninstall script removes this project's row; the trigger and schema go with the last one.

## Questions people ask

**Does my app change? Do my users notice anything?** No. They sign up and sign in exactly as
before. On a Dedicated Cloud or a Supabase with the connector's management token, not a line of
your app changes. Otherwise one line is added after sign-in.

**Do my existing users get a welcome email when I import them?** No, unless you tick *Run my "new
contact" automations for them*.

**Someone signed up with an email that's already a contact from my form. Do I get a duplicate?**
No. The existing contact is linked to the account and keeps everything it had.

**The same person uses two of my projects.** Each project's CRM gets its own contact. Tags, lists,
unsubscribes and automations are per project.

**Can I connect two sign-ins to one project?** Yes, for example a website on Firebase and a mobile
app on Supabase. Each has its own card, and the *Signed up in app* trigger can tell them apart.

**What if I disconnect?** New sign-ups stop arriving. Every contact stays. You can connect again
anytime; nobody is added twice.

**Someone deleted their account in my app. Are they still emailed?** By default the contact stays
as it is. Choose *Keep the contact, stop emailing them* to unsubscribe them automatically.

**Phone-only accounts?** They're skipped, because the CRM is keyed by email.

**Is my users' data safe?** Only CRM fields are sent, deliveries are signed, tokens are checked and
never stored, and secrets are shown once and stored encrypted. See
[Privacy and safety](#privacy-and-safety).

## Troubleshooting

| You see | What to do |
| --- | --- |
| **Waiting for the first sign-up** | Normal right after connecting. Sign up once with a new email and watch **Activity**. Where a line was added to your app (Firebase, OpenID, some Supabase), check it's there and the app is published. |
| **Needs attention** | Read the reason on the card and press **Fix with AI**. |
| *Waiting for Clerk's webhook signing secret* | Paste the endpoint's `whsec_…` in the card's **Settings**, or give it to the AI. |
| Sign-ups stopped arriving | Is the card **Paused**? Otherwise ask the AI: *"New sign-ups stopped reaching my CRM, check the bridge."* |
| Contacts missing after an import | The import line says how many were skipped and why: usually no email or a role address. |
| An import says it needs keys | Type them in **Import existing**, or upload an export file. |
| *This app shares the Dedicated Cloud of project …* | Connect the bridge in that project's App Users. Its CRM gets the sign-ups. |
| You lost a bridge's secret | **Set up by hand → Make a new secret**, then update the place that uses it (SQL, Action, server). The old one stops working at once. |
| The AI started but the chat looks idle | Wait a moment, or reload. The chat picks a running AI turn back up and shows it. |
| No **App Users** in Settings | You need the **App Users** permission in your role. Ask the project owner. |
