Skip to content

Option A — Uninstall Users: Record & Goodbye Email

This document is the plan for handling app uninstalls: recording uninstalled shops in a dedicated table and sending them a goodbye email via Mailjet SMTP. 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).


  • 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 Mailjet SMTP, with a template that says sorry to see them go and asks for feedback (why they left, how to improve).

  • 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.jsx already verifies the webhook, deletes sessions for that shop, and sets Shop.status = "uninstalled". The shop row is kept for history.
  • New behaviour: Before or after marking the shop uninstalled, (1) copy relevant data into a new shops_uninstalled table, and (2) send the goodbye email via Mailjet SMTP (non-blocking so the webhook returns 200 quickly).

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:

ColumnTypePurpose
idUUID, PKUnique row id.
shop_domainString, uniquee.g. store.myshopify.com.
shop_nameString, optionalFrom Shop.shopName at uninstall time.
shop_emailString, optionalRecipient for goodbye email (from Shop.shopEmail).
shop_phoneString, optionalFrom Shop.shopPhone (typically entered manually in Settings; not provided by Admin API 2025-10).
planString, optionale.g. free / paid at uninstall.
uninstalled_atDateTimeWhen the webhook was processed.
goodbye_email_sent_atDateTime, optionalWhen the Mailjet email was sent (null if send failed or skipped).
goodbye_email_errorString, optionalStore 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, update goodbye_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")
}

  • Option A (recommended for “SMTP relay”): Use Mailjet’s SMTP with credentials from the Mailjet dashboard (SMTP server, port, username = API key, password = Secret key). Send from your backend with a generic Node SMTP client (e.g. nodemailer).
  • Option B: Use Mailjet’s HTTP API (transactional email) if you prefer; the plan below assumes SMTP for simplicity.

Environment variables (e.g. in .env):

  • MAILJET_SMTP_HOST — e.g. in-v3.mailjet.com
  • MAILJET_SMTP_PORT — e.g. 587 (TLS)
  • MAILJET_API_KEY — SMTP username
  • MAILJET_SECRET_KEY — SMTP password
  • MAILJET_FROM_EMAIL — sender address (e.g. noreply@yourdomain.com)
  • MAILJET_FROM_NAME — e.g. AppiFire AI Chat

Flow:

  1. In the uninstall webhook handler, after persisting the uninstalled-shop row, enqueue or call a small “send goodbye email” function.
  2. That function loads the row (or receives shopDomain, shopEmail, etc.), builds the email (see template below), and sends via Mailjet SMTP.
  3. If send succeeds, set goodbye_email_sent_at (and clear goodbye_email_error). If it fails, set goodbye_email_error and leave goodbye_email_sent_at null so you can retry later if desired.
  4. 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 SMTP.

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”.


  • Add ShopsUninstalled model to Prisma schema and run migration.
  • In webhooks.app.uninstalled.jsx:
    • Before or after marking the shop uninstalled, fetch the shop row (shopDomain, shopName, shopEmail, shopPhone, plan).
    • Upsert into ShopsUninstalled (by shop_domain): set shop snapshot, uninstalled_at, leave goodbye_email_sent_at / goodbye_email_error null initially.
    • Keep existing behaviour: delete sessions, set Shop.status = "uninstalled".
  • Add Mailjet SMTP env vars to .env and .env.sample.
  • Implement a sendGoodbyeEmail (or similar) in app/lib/email.server.js (or a dedicated app/lib/mailjet.server.js):
    • Accept { shopDomain, shopEmail, shopName } (and optionally plan).
    • If no shopEmail, return without sending and return a reason (e.g. “No email”).
    • Use nodemailer + Mailjet SMTP to send the goodbye template.
    • Return success/failure so the webhook can set goodbye_email_sent_at or goodbye_email_error.
  • In the webhook: after upserting ShopsUninstalled, call send good bye email (fire-and-forget or small async), then on completion update the row with goodbye_email_sent_at or goodbye_email_error. Ensure webhook returns 200 without waiting for SMTP.
  • Add the goodbye email template (subject + body) as constants or a small template function; support plain text and optionally HTML.
  • QA: Uninstall the app on a test store, confirm one row in shops_uninstalled and that the merchant receives the goodbye email. Re-run webhook (e.g. resend from Partner Dashboard) and confirm no duplicate row and idempotent behaviour.

ItemAction
prisma/schema.prismaAdd ShopsUninstalled model.
prisma/migrations/New migration for shops_uninstalled table.
app/routes/webhooks.app.uninstalled.jsxFetch shop, upsert ShopsUninstalled, trigger goodbye email, keep existing session delete + status update.
app/lib/email.server.js or app/lib/mailjet.server.jsSend goodbye email via Mailjet SMTP (nodemailer); template for “sorry to see you go” + feedback ask.
.env / .env.sampleDocument MAILJET_SMTP_HOST, MAILJET_SMTP_PORT, MAILJET_API_KEY, MAILJET_SECRET_KEY, MAILJET_FROM_EMAIL, MAILJET_FROM_NAME.

TopicPlan
EventShopify app/uninstalled webhook (already registered).
New tableshops_uninstalled: shop domain, name, email, phone, plan, uninstalled_at, goodbye_email_sent_at, goodbye_email_error.
EmailMailjet SMTP (nodemailer); send once per uninstall to shop_email; template: “Sorry to see you go”, ask why they left and how to improve, reinstall CTA.
WebhookVerify HMAC, upsert uninstalled row, mark shop uninstalled, send email asynchronously, return 200.

Next: implement the migration, webhook changes, and Mailjet send + template; then test with an uninstall on a development store.