Skip to content
HeyCrustDocs

Search documentation

Search by topic or tool name
Browse documentation
Guides

Framework recipes

Use the current Remix, Node/Express and Rails identification, backfill and uninstall recipes.

On this page

What you need

The matching app framework, an owned HeyCrust app UUID and private CRUST_API_KEY. The examples below come from the current app-generated recipes with a fictional app UUID. Replace it with your app's UUID. Obtain approval before forwarding merchant tokens or deploying production changes.

Steps

  1. Choose identification with transient token use or the token-free uninstall forwarder. Do not combine them blindly.
  2. Adapt the corresponding recipe to your existing auth/webhook framework and environment. The source app page or get_setup_guide provides your actual identifiers.
  3. Keep the auth hook bounded and failure-safe. For serverless, use the host's supported background lifecycle.
  4. Use backfill only as a temporary protected route if you need to accelerate existing contact capture. Run off-peak in pages of 50, follow nextOffset while paging and resolve reported failures. Pagination completion alone is not import success: check sent/failed/skipped and app receipts before removing the route and redeploying. Use the Authorization header, never a query-string key.
  5. Verify actual contact/app receipts after your authorized deployment.

Expected result

Observed contact enrichment and app-specific identification receipt, or signed uninstall contact forwarding. These recipes do not establish affiliate attribution or optional product milestones.

Identify hook

Identify hook: Remix (Shopify template)

javascript
// PERMANENT — runs once per (re)install, right after OAuth.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// Identifies each installing shop to YOUR Crust workspace (https://heycrust.com):
// Crust uses the access token transiently to read the shop's BUSINESS
// contact (email, owner name, country, plan — no buyer data) and to register
// an app/uninstalled watch, then discards it — tokens are never stored
// (https://heycrust.com/security). You are the data controller; Crust is your
// processor — DPA: https://heycrust.com/dpa. Auth: your own CRUST_API_KEY.
// app/shopify.server.ts — add to your shopifyApp({ hooks }) config
hooks: {
  afterAuth: async ({ session }) => {
    shopify.registerWebhooks({ session });

    // One call: contact details + uninstall watch for this shop, from day one.
    // Deliberately NOT awaited + bounded timeout + try/catch: the library awaits
    // afterAuth, and with expiring offline tokens it re-runs for EXISTING
    // merchants on token refresh — a thrown error here becomes a merchant-facing
    // 500, and an awaited call adds Crust's latency to their auth path.
    // SERVERLESS (Vercel/Lambda/Workers): background work dies when the response
    // is sent — use the platform's waitUntil(fetch(...)) instead of this pattern.
    try {
      fetch("https://heycrust.com/api/identify", {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CRUST_API_KEY}` },
        body: JSON.stringify({
          myshopifyDomain: session.shop,
          accessToken: session.accessToken, // transient: shop.json read + watch registration
          appId: "11111111-1111-4111-8111-111111111111",
        }),
        signal: AbortSignal.timeout(5000),
      }).catch(() => {}); // never reject into auth
    } catch { /* never let analytics affect the merchant auth flow */ }
  },
},

Identify hook: Node / Express

javascript
// PERMANENT — runs once per (re)install, right after OAuth.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// Identifies each installing shop to YOUR Crust workspace (https://heycrust.com):
// Crust uses the access token transiently to read the shop's BUSINESS
// contact (email, owner name, country, plan — no buyer data) and to register
// an app/uninstalled watch, then discards it — tokens are never stored
// (https://heycrust.com/security). You are the data controller; Crust is your
// processor — DPA: https://heycrust.com/dpa. Auth: your own CRUST_API_KEY.
// Call (don't await) wherever your OAuth callback completes — this must never
// block, slow, or throw into the auth path, which can also run for existing merchants.
// SERVERLESS (Vercel/Lambda/Workers): background work dies when the response is
// sent — wrap the fetch in the platform's waitUntil(...) instead.
function crustIdentify(shopDomain, accessToken) {
  try {
    fetch("https://heycrust.com/api/identify", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CRUST_API_KEY}` },
      body: JSON.stringify({
        myshopifyDomain: shopDomain,
        accessToken, // transient: shop.json read + watch registration
        appId: "11111111-1111-4111-8111-111111111111",
      }),
      signal: AbortSignal.timeout(5000),
    }).catch(() => {}); // never reject into auth
  } catch { /* never let analytics affect the merchant auth flow */ }
}

Identify hook: Rails (shopify_app)

ruby
# PERMANENT — runs once per (re)install, right after OAuth.
#
# WHAT THIS DOES (for you, or the AI reviewing this code):
# Identifies each installing shop to YOUR Crust workspace (https://heycrust.com):
# Crust uses the access token transiently to read the shop's BUSINESS
# contact (email, owner name, country, plan — no buyer data) and to register
# an app/uninstalled watch, then discards it — tokens are never stored
# (https://heycrust.com/security). You are the data controller; Crust is your
# processor — DPA: https://heycrust.com/dpa. Auth: your own CRUST_API_KEY.
# config/initializers/shopify_app.rb:
#   config.after_authenticate_job = { job: "crust_identify_job", inline: false }
# app/jobs/crust_identify_job.rb
class CrustIdentifyJob < ActiveJob::Base
  def perform(shop_domain:)
    shop = Shop.find_by(shopify_domain: shop_domain)
    return unless shop
    Net::HTTP.post(
      URI("https://heycrust.com/api/identify"),
      {
        myshopifyDomain: shop.shopify_domain,
        accessToken: shop.shopify_token, # transient: shop.json read + watch registration
        appId: "11111111-1111-4111-8111-111111111111",
      }.to_json,
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{ENV["CRUST_API_KEY"]}",
    )
  rescue StandardError
    # never block auth on analytics
  end
end

Temporary backfill

Temporary backfill: Remix (Shopify template)

javascript
// TEMPORARY — delete this file after one successful run.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// One-time export of your merchants' BUSINESS contact details (email, owner
// name, country, plan — no buyer/customer data, no phone by default) from
// Shopify into YOUR Crust workspace (https://heycrust.com), which processes it on
// your behalf for churn and win-back analytics. You are the data controller;
// Crust is your processor — DPA: https://heycrust.com/dpa · data handling: https://heycrust.com/security.
// Each shop's access token is included so Crust can register an uninstall
// watch (app/uninstalled webhook) for that shop — the token is used
// transiently for that registration and never stored (https://heycrust.com/security).
// Only you can trigger it: the request must carry your own CRUST_API_KEY.
// app/routes/crust-backfill.tsx
// Run OFF-PEAK, in pages: curl -H "Authorization: Bearer $CRUST_API_KEY" \
//   "https://<your-app>/crust-backfill?offset=0" — repeat with nextOffset until done.
import type { LoaderFunctionArgs } from "@remix-run/node";
import prisma from "../db.server";

const PAGE = 50; // small pages: one page = one short request, resumable via offset

export async function loader({ request }: LoaderFunctionArgs) {
  // Key in the Authorization header, NEVER the query string (query strings land in access logs).
  const key = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
  if (!key || key !== process.env.CRUST_API_KEY) return new Response("unauthorized", { status: 401 });

  // Shopify's Remix template stores installs in the Prisma "session" table.
  const offset = Number(new URL(request.url).searchParams.get("offset") ?? 0);
  const sessions = await prisma.session.findMany({
    where: { isOnline: false }, skip: offset, take: PAGE, orderBy: { id: "asc" },
  });

  let sent = 0, skipped = 0, failed = 0;
  const failures = [];
  for (const s of sessions) {
    if (!s.accessToken) { skipped++; continue; }
    let stage = "shop_read";
    try { // one bad shop must not abort the page — timeouts keep any one shop bounded
      const res = await fetch(`https://${s.shop}/admin/api/2026-01/shop.json`, {
        headers: { "X-Shopify-Access-Token": s.accessToken },
        signal: AbortSignal.timeout(10_000),
      });
      if (!res.ok) { skipped++; continue; } // uninstalled → token already revoked
      const { shop } = await res.json();
      stage = "identify";
      const receipt = await fetch("https://heycrust.com/api/identify", {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
        body: JSON.stringify({
          myshopifyDomain: shop.myshopify_domain,
          email: shop.email,
          ownerName: shop.shop_owner,
          country: shop.country_name,
          shopifyPlan: shop.plan_display_name,
          accessToken: s.accessToken, // transient: registers the uninstall watch, never stored
          appId: "11111111-1111-4111-8111-111111111111",
          // phone: shop.phone, // opt in only if you need it — default is minimal
        }),
        signal: AbortSignal.timeout(10_000),
      });
      if (!receipt.ok || (await receipt.json().catch(() => null))?.ok !== true) {
        failed++; failures.push({ shopDomain: s.shop ?? s.domain, stage, status: receipt.status }); continue;
      }
      sent++;
    } catch { failed++; failures.push({ shopDomain: s.shop ?? s.domain, stage, status: null }); }
  }
  const nextOffset = sessions.length === PAGE ? offset + PAGE : null;
  const body = { processed: sessions.length, offset, sent, skipped, failed, failures, nextOffset,
    paginationComplete: nextOffset === null, done: nextOffset === null && failed === 0,
    retryOffset: failed ? offset : null,
    next: failed ? `fix failures and re-run with ?offset=${offset}` : nextOffset ? `re-run with ?offset=${nextOffset}` : "Pagination finished. Verify receipts and review skipped shops before removing this route." };
  // new Response, not Response.json(): classic Remix v2 (installGlobals) lacks the static helper.
  return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } });
}

Temporary backfill: Node / Express

javascript
// TEMPORARY — delete this file after one successful run.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// One-time export of your merchants' BUSINESS contact details (email, owner
// name, country, plan — no buyer/customer data, no phone by default) from
// Shopify into YOUR Crust workspace (https://heycrust.com), which processes it on
// your behalf for churn and win-back analytics. You are the data controller;
// Crust is your processor — DPA: https://heycrust.com/dpa · data handling: https://heycrust.com/security.
// Each shop's access token is included so Crust can register an uninstall
// watch (app/uninstalled webhook) for that shop — the token is used
// transiently for that registration and never stored (https://heycrust.com/security).
// Only you can trigger it: the request must carry your own CRUST_API_KEY.
// routes/crust-backfill.js — run OFF-PEAK, in pages:
//   curl -H "Authorization: Bearer $CRUST_API_KEY" "https://<your-app>/crust-backfill?offset=0"
const PAGE = 50;

app.get("/crust-backfill", async (req, res) => {
  // Key in the Authorization header, NEVER the query string (query strings land in access logs).
  const key = (req.get("authorization") || "").replace(/^Bearer\s+/i, "");
  if (!key || key !== process.env.CRUST_API_KEY) return res.status(401).send("unauthorized");

  const offset = Number(req.query.offset ?? 0);
  const shops = await db.shops.findMany({ skip: offset, take: PAGE }); // adapt to your shop storage
  let sent = 0, skipped = 0, failed = 0;
  const failures = [];
  for (const s of shops) {
    let stage = "shop_read";
    try { // one bad shop must not abort the page — timeouts keep any one shop bounded
      const r = await fetch(`https://${s.domain}/admin/api/2026-01/shop.json`, {
        headers: { "X-Shopify-Access-Token": s.accessToken },
        signal: AbortSignal.timeout(10_000),
      });
      if (!r.ok) { skipped++; continue; }        // uninstalled → token already revoked
      const { shop } = await r.json();
      stage = "identify";
      const receipt = await fetch("https://heycrust.com/api/identify", {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
        body: JSON.stringify({
          myshopifyDomain: shop.myshopify_domain,
          email: shop.email,
          ownerName: shop.shop_owner,
          country: shop.country_name,
          shopifyPlan: shop.plan_display_name,
          accessToken: s.accessToken, // transient: registers the uninstall watch, never stored
          appId: "11111111-1111-4111-8111-111111111111",
          // phone: shop.phone, // opt in only if you need it — default is minimal
        }),
        signal: AbortSignal.timeout(10_000),
      });
      if (!receipt.ok || (await receipt.json().catch(() => null))?.ok !== true) {
        failed++; failures.push({ shopDomain: s.shop ?? s.domain, stage, status: receipt.status }); continue;
      }
      sent++;
    } catch { failed++; failures.push({ shopDomain: s.shop ?? s.domain, stage, status: null }); }
  }
  const nextOffset = shops.length === PAGE ? offset + PAGE : null;
  res.json({ processed: shops.length, offset, sent, skipped, failed, failures, nextOffset,
    paginationComplete: nextOffset === null, done: nextOffset === null && failed === 0,
    retryOffset: failed ? offset : null,
    next: failed ? `fix failures and re-run with ?offset=${offset}` : nextOffset ? `re-run with ?offset=${nextOffset}` : "Pagination finished. Verify receipts and review skipped shops before removing this route." });
});

Temporary backfill: Rails (shopify_app)

ruby
# TEMPORARY — delete this file after one successful run.
#
# WHAT THIS DOES (for you, or the AI reviewing this code):
# One-time export of your merchants' BUSINESS contact details (email, owner
# name, country, plan — no buyer/customer data, no phone by default) from
# Shopify into YOUR Crust workspace (https://heycrust.com), which processes it on
# your behalf for churn and win-back analytics. You are the data controller;
# Crust is your processor — DPA: https://heycrust.com/dpa · data handling: https://heycrust.com/security.
# Each shop's access token is included so Crust can register an uninstall
# watch (app/uninstalled webhook) for that shop — the token is used
# transiently for that registration and never stored (https://heycrust.com/security).
# Only you can trigger it: the request must carry your own CRUST_API_KEY.
# config/routes.rb:  get "/crust_backfill", to: "crust_backfill#run"
# Run OFF-PEAK, in pages:
#   curl -H "Authorization: Bearer $CRUST_API_KEY" "https://<your-app>/crust_backfill?offset=0"
# app/controllers/crust_backfill_controller.rb
class CrustBackfillController < ApplicationController
  PAGE = 50

  def run
    # Key in the Authorization header, NEVER the query string (query strings land in access logs).
    key = request.headers["Authorization"].to_s.sub(/\ABearer\s+/i, "")
    return head :unauthorized unless key.present? && key == ENV["CRUST_API_KEY"]

    offset = params[:offset].to_i
    shops = Shop.order(:id).offset(offset).limit(PAGE)  # shopify_app gem's Shop model
    sent = 0
    skipped = 0
    failed = 0
    failures = []
    shops.each do |s|
      stage = "shop_read"
      begin # one bad shop must not abort the page — timeouts keep any one shop bounded
        uri = URI("https://#{s.shopify_domain}/admin/api/2026-01/shop.json")
        http = Net::HTTP.new(uri.host, uri.port)
        http.use_ssl = true
        http.open_timeout = 10
        http.read_timeout = 10
        res = http.get(uri.path, { "X-Shopify-Access-Token" => s.shopify_token })
        unless res.is_a?(Net::HTTPSuccess)
          skipped += 1  # uninstalled → token already revoked
          next
        end
        shop = JSON.parse(res.body)["shop"]
        crust = URI("https://heycrust.com/api/identify")
        chttp = Net::HTTP.new(crust.host, crust.port)
        chttp.use_ssl = true
        chttp.open_timeout = 10
        chttp.read_timeout = 10
        stage = "identify"
        receipt = chttp.post(
          crust.path,
          {
            myshopifyDomain: shop["myshopify_domain"],
            email: shop["email"],
            ownerName: shop["shop_owner"],
            country: shop["country_name"],
            shopifyPlan: shop["plan_display_name"],
            accessToken: s.shopify_token, # transient: registers the uninstall watch, never stored
            appId: "11111111-1111-4111-8111-111111111111",
            # phone: shop["phone"], # opt in only if you need it — default is minimal
          }.to_json,
          { "Content-Type" => "application/json", "Authorization" => "Bearer #{key}" },
        )
        unless receipt.is_a?(Net::HTTPSuccess) && JSON.parse(receipt.body)["ok"] == true
          failed += 1
          failures << { shopDomain: s.shopify_domain, stage: stage, status: receipt.code.to_i }
          next
        end
        sent += 1
      rescue StandardError
        failed += 1
        failures << { shopDomain: s.shopify_domain, stage: stage, status: nil }
      end
    end
    next_offset = shops.length == PAGE ? offset + PAGE : nil
    render json: { processed: shops.length, offset: offset, sent: sent, skipped: skipped, failed: failed, failures: failures,
                   nextOffset: next_offset, paginationComplete: next_offset.nil?, done: next_offset.nil? && failed.zero?,
                   retryOffset: failed.positive? ? offset : nil,
                   next: failed.positive? ? "fix failures and re-run with ?offset=#{offset}" : next_offset ? "re-run with ?offset=#{next_offset}" : "Pagination finished. Verify receipts and review skipped shops before removing this route." }
  end
end

Uninstall forwarder

Uninstall forwarder: Remix (Shopify template)

javascript
// PERMANENT — keep this handler; it runs each time a merchant uninstalls.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// Shopify's app/uninstalled webhook payload is the shop record itself. This
// forwards its BUSINESS contact fields (email, owner name, country, plan — no
// buyer/customer data, no phone) to YOUR Crust workspace (https://heycrust.com) at the
// moment of churn, so win-back drafts always have a recipient — even for
// merchants who never opened the app while the pixel was live. You are the
// data controller; Crust is your processor — DPA: https://heycrust.com/dpa · data
// handling: https://heycrust.com/security. Auth: the request carries your CRUST_API_KEY.
// app/routes/webhooks.app.uninstalled.tsx — the template ships this route; add the forward
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const { shop, payload } = await authenticate.webhook(request);
  const s = payload as {
    myshopify_domain?: string; email?: string; shop_owner?: string;
    country_name?: string; plan_display_name?: string;
  };

  // Forward to Crust FIRST — tokens are already revoked when this fires, so
  // this payload is the last chance to capture the merchant's contact.
  await fetch("https://heycrust.com/api/identify", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CRUST_API_KEY}` },
    body: JSON.stringify({
      myshopifyDomain: s.myshopify_domain ?? shop,
      email: s.email,
      ownerName: s.shop_owner,
      country: s.country_name,
      shopifyPlan: s.plan_display_name,
    }),
  }).catch(() => {}); // never fail the webhook over this — Shopify would retry, then drop it

  // …your existing cleanup (session deletes etc.) stays below…
  return new Response();
};

Uninstall forwarder: Node / Express

javascript
// PERMANENT — keep this handler; it runs each time a merchant uninstalls.
//
// WHAT THIS DOES (for you, or the AI reviewing this code):
// Shopify's app/uninstalled webhook payload is the shop record itself. This
// forwards its BUSINESS contact fields (email, owner name, country, plan — no
// buyer/customer data, no phone) to YOUR Crust workspace (https://heycrust.com) at the
// moment of churn, so win-back drafts always have a recipient — even for
// merchants who never opened the app while the pixel was live. You are the
// data controller; Crust is your processor — DPA: https://heycrust.com/dpa · data
// handling: https://heycrust.com/security. Auth: the request carries your CRUST_API_KEY.
// routes/crust-uninstalled.js — register app/uninstalled to POST /webhooks/app-uninstalled
const crypto = require("crypto");

app.post("/webhooks/app-uninstalled", express.raw({ type: "*/*" }), async (req, res) => {
  // Verify Shopify's HMAC before trusting the payload.
  const digest = crypto.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
    .update(req.body).digest("base64");
  const given = req.get("X-Shopify-Hmac-Sha256") || "";
  const ok = given.length === digest.length &&
    crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(given));
  if (!ok) return res.sendStatus(401);

  const shop = JSON.parse(req.body.toString("utf8"));
  // Forward to Crust FIRST — tokens are already revoked when this fires, so
  // this payload is the last chance to capture the merchant's contact.
  await fetch("https://heycrust.com/api/identify", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CRUST_API_KEY}` },
    body: JSON.stringify({
      myshopifyDomain: shop.myshopify_domain,
      email: shop.email,
      ownerName: shop.shop_owner,
      country: shop.country_name,
      shopifyPlan: shop.plan_display_name,
    }),
  }).catch(() => {}); // never fail the webhook over this

  // …your existing cleanup stays here…
  res.sendStatus(200);
});

Uninstall forwarder: Rails (shopify_app)

ruby
# PERMANENT — keep this handler; it runs each time a merchant uninstalls.
#
# WHAT THIS DOES (for you, or the AI reviewing this code):
# Shopify's app/uninstalled webhook payload is the shop record itself. This
# forwards its BUSINESS contact fields (email, owner name, country, plan — no
# buyer/customer data, no phone) to YOUR Crust workspace (https://heycrust.com) at the
# moment of churn, so win-back drafts always have a recipient — even for
# merchants who never opened the app while the pixel was live. You are the
# data controller; Crust is your processor — DPA: https://heycrust.com/dpa · data
# handling: https://heycrust.com/security. Auth: the request carries your CRUST_API_KEY.
# app/jobs/app_uninstalled_job.rb — the shopify_app gem routes app/uninstalled here
class AppUninstalledJob < ActiveJob::Base
  def perform(shop_domain:, webhook:)
    # Forward to Crust FIRST — tokens are already revoked when this fires, so
    # this payload is the last chance to capture the merchant's contact.
    begin
      Net::HTTP.post(
        URI("https://heycrust.com/api/identify"),
        {
          myshopifyDomain: webhook["myshopify_domain"] || shop_domain,
          email: webhook["email"],
          ownerName: webhook["shop_owner"],
          country: webhook["country_name"],
          shopifyPlan: webhook["plan_display_name"],
        }.to_json,
        "Content-Type" => "application/json",
        "Authorization" => "Bearer #{ENV["CRUST_API_KEY"]}",
      )
    rescue StandardError
      # never fail the webhook over this
    end

    # …your existing cleanup stays here…
  end
end

Troubleshooting

Check your actual framework/session model, app ownership, environment variable and raw Shopify HMAC headers. Backfill skips unusable/revoked merchant tokens; successful pagination is not a guarantee every historical merchant contact was recoverable. Do not allow reporting failures into merchant authentication. See Identify, Uninstall forwarding and Source observations.