Send us your merchants and what they do. Emails & Flows takes care of campaigns, event-triggered flows, billing-based segments, and revenue attribution. Agent-readable version: /docs/emails-api.md
A contact must carry both an email and a shopDomain— the shop's .myshopify.com domain. The shop domain is the join key that links the contact to Shopify Partner billing data (paying / on trial / plan / monthly spend), which powers billing segments and revenue attribution. Creating a contact without one is rejected with an explanatory 400.
Two keys, created in AppStorePulse → Emails & Flows → Settings. The secret key (sk_live_…) lives on your backend — full ingestion, 600 requests/min. The publishable key (pk_live_…) is safe in the browser — write-only, locked to the origins you list on it, 60 requests/min per visitor.
Trust rule: contacts first createdwith the publishable key can't receive flow emails until confirmed server-side (a secret-key identify or CSV import) — the publishable key is public by design, so it can't be allowed to enroll strangers into your sending. Best practice: identify from your backend, use the browser SDK for events.
Authorization: Bearer sk_live_your_key_hereUpserts a contact keyed on email. Safe to call repeatedly: re-identifying refreshes name / shopDomain and merges new attribute keys — and it never re-subscribes someone who unsubscribed, bounced, or complained (opt-outs are sticky, tombstoned across deletion).
curl -X POST https://www.appstorepulse.com/api/e/identify \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"email": "owner@shop.com",
"shopDomain": "their-shop.myshopify.com",
"name": "Jane Merchant",
"attributes": { "plan": "growth", "country": "US" },
"lists": ["Onboarding"]
}'email (required).shopDomain (required on create): the .myshopify.com domain. Pasted URLs and mixed case are normalized; anything else is rejected with the expected format. externalId is a legacy alias. Optional when the contact already has one.name: used by merge variables like {{name | there}}.attributes: any key/values — become segment filters and merge variables.lists: list names to add the contact to (created on demand).alreadyInstalled: the contact had already installed your app before you sent them to us (backfill). Flows on app_installed skip these contacts, so a long-time merchant who reinstalls never gets your welcome email.Events trigger flows and feed reports. Identify the contact first.
curl -X POST https://www.appstorepulse.com/api/e/track \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"email": "owner@shop.com",
"name": "app_installed",
"properties": { "source": "oauth_callback" },
"idempotencyKey": "install-their-shop.myshopify.com"
}'idempotencyKeymakes retries safe: the same key is recorded once, so a network retry can't fire a flow twice. Use one stable key per real-world action (e.g. a database row id) — never a random value per request. Event names starting with $ or __ are reserved.
Lists: POST /api/e/lists/add and /api/e/lists/remove with { "email", "list" }. Adding can trigger list-based flows, once per contact per flow. Removing needs the secret key.
Recommended conventions:
app_installed — from your install/OAuth callback (below). Reserved name: contacts marked alreadyInstalled are skipped for flows on this event.app_uninstalled — from your app/uninstalled Shopify webhook.subscription_started / plan_changed / subscription_cancelled — from your app_subscriptions/update Shopify webhook.feature_activated, usage_milestone, anything meaningful in your app.Shopify's Partner events identify shops by domain only — they never include an email. Close the gap in your OAuth/install callback, where the Admin API gives you the shop's email, and your welcome flow fires within a minute of install:
// In your install/OAuth callback, after fetching the shop:
await fetch("https://www.appstorepulse.com/api/e/identify", {
method: "POST",
headers: { "Authorization": "Bearer sk_live_xxx", "Content-Type": "application/json" },
body: JSON.stringify({
email: shop.email,
name: shop.shop_owner,
shopDomain: shop.myshopify_domain,
}),
});
await fetch("https://www.appstorepulse.com/api/e/track", {
method: "POST",
headers: { "Authorization": "Bearer sk_live_xxx", "Content-Type": "application/json" },
body: JSON.stringify({
email: shop.email,
name: "app_installed",
idempotencyKey: `install-${shop.myshopify_domain}`,
}),
});<script src="https://www.appstorepulse.com/sdk/e.js" data-key="pk_live_xxx"></script>
<script>
asp.identify({ email: "owner@shop.com", shopDomain: shop, name: "Jane" });
asp.track("feature_activated", { feature: "bundles" });
asp.addToList("Beta testers");
</script>Read, opt out, and delete contacts — e.g. to honor a merchant's data-deletion request. Secret key only.
# One contact / paginated list
GET /api/e/subscribers?email=owner@shop.com
GET /api/e/subscribers?limit=100&offset=0&status=subscribed
# Opt out (sticky, like the unsubscribe link)
POST /api/e/unsubscribe { "email": "owner@shop.com" }
# Hard delete (contact + history; opted-out addresses leave a tombstone)
DELETE /api/e/subscribers { "email": "owner@shop.com" }Seed existing merchants by looping your database through identify with the secret key (600/min ≈ 36k contacts/hour) with alreadyInstalled: true, or CSV import in the dashboard — columns email + shop_domain(required), name, status (subscribed /unsubscribed, so opt-outs from your previous tool carry over); other columns become attributes. Imports never fire flows themselves. Errors are JSON { "error": "<reason>" }with proper status codes — 400 validation (the message states exactly what's missing or malformed), 401 unknown key, 403 origin not allowed, 413 body over 32 KB, 429 rate limited with Retry-After.
Building with an AI agent? Point it at https://www.appstorepulse.com/docs/emails-api.md — the same reference as plain markdown. Looking for the read-only data API (rankings, reviews, competitors)? That's the REST API.