Uninstall: Record & Goodbye Email
This document is the implementation reference for app uninstall handling: recording uninstalled shops in a dedicated table and sending them a goodbye email via Brevo API. Shopify triggers an app/uninstalled webhook when a merchant uninstalls the app.
Prerequisites: Existing app/uninstalled webhook handler (webhooks.app.uninstalled.jsx) that marks the shop as status: "uninstalled". Phase 10 (register users) gives you shopName, shopEmail, shopPhone on Shop for the email recipient. Note: shopName and shopEmail are filled from Shopify on install and in Settings; shopPhone is manual entry in Settings (Admin API 2025-10 does not expose store address/phone).
1. Objective
Section titled “1. Objective”- Record every uninstall in a dedicated database table (for analytics, win-back, and compliance).
- Send a goodbye email to the merchant (using their shop contact email) via Brevo API, with a template that says sorry to see them go and asks for feedback (why they left, how to improve).
Implementation status
Section titled “Implementation status”- Done: uninstall webhook stores
ShopsUninstalledsnapshot and updatesShop.status. - Done:
sendGoodbyeEmail()uses Brevo API and writes success/error fields. - Done:
.env/.env.sampledocument Brevo variables. - Pending per environment: run
npx prisma migrate deploywhere not yet applied.
2. Shopify event: app/uninstalled
Section titled “2. Shopify event: app/uninstalled”- When: Shopify sends a webhook to your registered URL (e.g.
POST /webhooks/app/uninstalled) when a merchant uninstalls the app. - Current behaviour: Your
webhooks.app.uninstalled.jsxalready verifies the webhook, deletes sessions for that shop, and setsShop.status = "uninstalled". The shop row is kept for history. - Current behaviour: On uninstall, the app upserts a
shops_uninstalledrow and triggers a non-blocking goodbye email via Brevo API.
3. New database table: uninstalled shops
Section titled “3. New database table: uninstalled shops”Create a table to store a snapshot of each uninstall (and whether the goodbye email was sent). This keeps an audit trail and avoids re-sending if the webhook retries.
Suggested schema:
| Column | Type | Purpose |
|---|---|---|
id | UUID, PK | Unique row id. |
shop_domain | String, unique | e.g. store.myshopify.com. |
shop_name | String, optional | From Shop.shopName at uninstall time. |
shop_email | String, optional | Recipient for goodbye email (from Shop.shopEmail). |
shop_phone | String, optional | From Shop.shopPhone (typically entered manually in Settings; not provided by Admin API 2025-10). |
plan | String, optional | e.g. free / paid at uninstall. |
uninstalled_at | DateTime | When the webhook was processed. |
goodbye_email_sent_at | DateTime, optional | When the Brevo email was sent (null if send failed or skipped). |
goodbye_email_error | String, optional | Store last error message if send failed (for debugging). |
- Unique on
shop_domain: One row per shop; if the webhook fires again (e.g. retry), you can skip insert or update the same row (e.g. retry email send, updategoodbye_email_sent_at). - No FK to
shops: Keeps uninstall history even if you later purge or anonymise old shop rows.
Prisma sketch:
model ShopsUninstalled { id String @id @default(uuid()) shopDomain String @unique @map("shop_domain") shopName String? @map("shop_name") shopEmail String? @map("shop_email") shopPhone String? @map("shop_phone") plan String? @map("plan") uninstalledAt DateTime @default(now()) @map("uninstalled_at") goodbyeEmailSentAt DateTime? @map("goodbye_email_sent_at") goodbyeEmailError String? @map("goodbye_email_error") @db.Text @@map("shops_uninstalled")}4. Brevo transactional email
Section titled “4. Brevo transactional email”- Use Brevo transactional API with API key authentication.
- Send from backend using
POST https://api.brevo.com/v3/smtp/email.
Environment variables (e.g. in .env):
BREVO_API_KEY— Brevo API keyBREVO_FROM_EMAIL— sender address (e.g.noreply@yourdomain.com)BREVO_FROM_NAME— e.g.AppiFire AI ChatBREVO_REPLY_TO_EMAIL— optional reply-to addressBREVO_REPLY_TO_NAME— optional reply-to name
Flow:
- In the uninstall webhook handler, after persisting the uninstalled-shop row, enqueue or call a small “send goodbye email” function.
- That function loads the row (or receives
shopDomain,shopEmail, etc.), builds the email (see template below), and sends via Brevo API. - If send succeeds, set
goodbye_email_sent_at(and cleargoodbye_email_error). If it fails, setgoodbye_email_errorand leavegoodbye_email_sent_atnull so you can retry later if desired. - Important: The webhook must return 200 quickly. So either send the email asynchronously (e.g. fire-and-forget with
.catch()and log errors) or from a background job; avoid blocking the webhook response on API call latency.
5. Goodbye email template
Section titled “5. Goodbye email template”Subject (example):
We're sorry to see you go — quick feedback?
Body (plain text and/or HTML):
- Short line: Sorry to see you go. We’re sorry that you’ve uninstalled AppiFire AI Chat.
- Ask for feedback:
- “If you have a moment, we’d love to know why you left and how we can make our product better.”
- Optional: link to a simple feedback form (e.g. Typeform, Google Form) or a “reply to this email” CTA.
- Reinstall:
- “Changed your mind? You can reinstall the app from the Shopify App Store anytime.”
- Sign-off:
- e.g. “Thank you for trying us. — The AppiFire AI Chat team”
Personalisation: Use shopName (or shop domain) and send to shopEmail. If shopEmail is missing, skip sending and store a short goodbye_email_error like “No email on file”.
6. Implementation checklist
Section titled “6. Implementation checklist”- Add
ShopsUninstalledmodel to Prisma schema and migration. - In
webhooks.app.uninstalled.jsx:- Fetch shop row and upsert uninstall snapshot by
shop_domain. - Keep existing behaviour: delete sessions and set
Shop.status = "uninstalled". - Trigger goodbye email and persist send success/failure fields.
- Fetch shop row and upsert uninstall snapshot by
- Add Brevo env vars to
.envand.env.sample. - Implement
sendGoodbyeEmailinapp/lib/email.server.js. - Keep webhook non-blocking for email send failures.
- Apply migration in every deployment environment:
npx prisma migrate deploy. - QA uninstall on each target environment/store after deployment.
7. GDPR shop/redact, shops_uninstalled archive & shops_installed_stats
Section titled “7. GDPR shop/redact, shops_uninstalled archive & shops_installed_stats”Shopify sends shop/redact ~48 hours after uninstall (mandatory compliance). It is not the same event as app/uninstalled.
| Concern | Where it lives |
|---|---|
| Compliance handler | app/routes/webhooks.compliance.jsx — topics customers/data_request, customers/redact, shop/redact on POST /webhooks/compliance (see shopify.app.toml compliance_topics). |
Before deleting the Shop row | archiveShopSnapshotForRedact in app/lib/shops-uninstalled-archive.server.js upserts shops_uninstalled with the same rich metric bundle used for live analytics (tenure days, distinct chat days, catalog counts, OpenRouter lifetime totals, subscription snapshot, churn_metrics_json, etc.). |
| Rolling stats while installed | Table shops_installed_stats (one row per shop, FK cascade on shop delete). Refreshed from afterAuth via upsertShopsInstalledStats in app/lib/shops-installed-stats.server.js (throttled ~4 hours, non-blocking). Metrics are built by computeShopAnalyticsSnapshot in app/lib/shop-analytics-snapshot.server.js so installed + redact snapshots stay aligned. There is no HTTP cron for this table — only merchant OAuth revisits (and the throttle window). |
Ops / URLs: Scheduled jobs, privacy & shop analytics — includes the single external cron URL (free-plan free_credits_used reset only) vs webhook-driven analytics.
The early sections of this doc describe the original shops_uninstalled columns for goodbye email; production schema adds many optional snapshot columns filled on shop/redact (and shopify_shop_id is also written on uninstall upsert).
8. File summary
Section titled “8. File summary”| Item | Action |
|---|---|
prisma/schema.prisma | Add ShopsUninstalled model. |
prisma/migrations/ | New migration for shops_uninstalled table. |
app/routes/webhooks.app.uninstalled.jsx | Fetch shop, upsert ShopsUninstalled, trigger goodbye email, keep existing session delete + status update. |
app/lib/email.server.js or app/lib/brevo.server.js | Send goodbye email via Brevo API; template for “sorry to see you go” + feedback ask. |
.env / .env.sample | Document BREVO_API_KEY, BREVO_FROM_EMAIL, BREVO_FROM_NAME (+ optional reply-to vars). |
9. Summary
Section titled “9. Summary”| Topic | Status |
|---|---|
| Event | Shopify app/uninstalled webhook (already registered). |
| New table | shops_uninstalled implemented: snapshot + goodbye send tracking fields. |
Brevo API implemented; send once per uninstall to shop_email; logs errors without blocking webhook. | |
| Webhook | Implemented: verify HMAC, upsert uninstall row, mark shop uninstalled, fire async email update path. |
Next: ensure npx prisma migrate deploy has run in every environment, then run uninstall QA in staging/production stores.