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
- Choose identification with transient token use or the token-free uninstall forwarder. Do not combine them blindly.
- Adapt the corresponding recipe to your existing auth/webhook framework and environment. The source app page or get_setup_guide provides your actual identifiers.
- Keep the auth hook bounded and failure-safe. For serverless, use the host's supported background lifecycle.
- 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.
- 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)
// 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
// 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)
# 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)
// 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
// 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)
# 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)
// 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
// 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)
# 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.