# AppStorePulse Emails & Flows — Ingestion API Reference

Integrate your Shopify app so AppStorePulse can email your merchants and link
them to billing data. Base URL for all endpoints: `https://www.appstorepulse.com`

## Core concept

Every contact is a **shop**. 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) — it powers billing-based segments and revenue
attribution. Creating a contact without a `shopDomain` is rejected with HTTP
400 and an explanatory error.

## Authentication — two keys

Created in AppStorePulse → Emails & Flows → Settings → API keys.

| Key | Prefix | Where | Notes |
|---|---|---|---|
| Secret | `sk_live_…` | Your backend only | Full ingestion. 600 requests/min. Never ship to a browser. |
| Publishable | `pk_live_…` | Browser SDK | Write-only, locked to the HTTP origins listed on the key. 60 requests/min per visitor IP. |

Pass as a bearer token: `Authorization: Bearer sk_live_xxx`.

**Important trust rule**: contacts FIRST created via the publishable key cannot
receive flow emails until they are confirmed server-side (a secret-key
`identify` for the same email, or a CSV import). This is an anti-abuse
quarantine — the publishable key is public by design. Best practice: always
`identify` from your backend; use the browser SDK for events.

## POST /api/e/identify — create or update a contact

Upserts a contact keyed on `email`. Safe to call repeatedly.

```bash
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"]
  }'
```

Fields:
- `email` (required): the merchant's email.
- `shopDomain` (required on create): the shop's `.myshopify.com` domain.
  Normalized automatically (pasted URLs / mixed case are fine). Must match
  `something.myshopify.com` or the request is rejected with the expected
  format in the error. `externalId` is accepted as a legacy alias.
  May be omitted when updating a contact that already has one.
- `name` (optional): display name, used by merge variables.
- `attributes` (optional object): any key/values; become filterable segment
  fields and `{{merge}}` variables. Shallow-merged on update (new keys win).
- `lists` (optional array of names, at most 20): lists to add the contact to
  (created on demand).
- `alreadyInstalled` (optional boolean): the contact had already installed your
  app before you started sending it to us (a backfill of your existing base).
  Flows triggered by `app_installed` skip these contacts, so a long-time
  merchant who reinstalls later never gets your welcome email. Only written
  when you pass it — a later plain `identify` never resets it.

Response `200`: `{ "ok": true, "subscriber": { "id", "email", "status" } }`
(publishable-key calls always see `"status": "subscribed"` — real status is
only revealed to secret-key callers).

Behavior guarantees:
- Opt-outs are sticky: identify never re-subscribes someone who unsubscribed,
  bounced, or complained. Deleting an opted-out contact leaves a tombstone;
  re-identifying recreates them suppressed.
- Publishable-key identify can set attributes only when CREATING a contact,
  never update them on an existing one (secret key can).
- Publishable-key identify can set `shopDomain` only when the contact has none;
  it never changes an existing one (secret key, CSV and the dashboard can).

## POST /api/e/track — record an event

Events trigger flows and power reports.

```bash
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"
  }'
```

Fields:
- `email` (required): which contact did it (must be identified first).
- `name` (required): the event name. Names starting with `$` or `__` are
  reserved and rejected.
- `properties` (optional object).
- `idempotencyKey` (optional but recommended): "idempotent" means sending the
  same request twice has the same effect as once. Give each real-world action
  ONE stable key (e.g. your database row id). The first request records the
  event; any retry with the same key is recognized and skipped — so network
  retries can't fire a flow twice. Use a NEW key for a genuinely new action,
  never a random value per request.

Response `200`: `{ "ok": true, "eventId": "evt_…", "deduped": false }`.

## Which events to send (recommended conventions)

| Event | Send it from |
|---|---|
| `app_installed` | Your install/OAuth callback (see "The install moment"). Reserved name: contacts marked `alreadyInstalled` are skipped for flows on this event. |
| `app_uninstalled` | Your `app/uninstalled` Shopify webhook |
| `subscription_started` / `plan_changed` / `subscription_cancelled` | Your `app_subscriptions/update` Shopify webhook |
| Your product events (`feature_activated`, `usage_milestone`, …) | Wherever they happen in your app |

## POST /api/e/lists/add and /api/e/lists/remove

```json
{ "email": "owner@shop.com", "list": "VIP" }
```
Adds/removes the contact from a named list (created on demand). Adding can
trigger list-based flows, once per contact per flow. `remove` needs the secret
key: the publishable key can add to lists but never remove.

## Browser SDK (optional — for in-app events)

```html
<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" }); // email defaults to last identify
  asp.addToList("Beta testers");
</script>
```

The publishable key must list your app's origin(s) in its allow-list
(configured when creating the key) or requests are refused.

## The install moment (critical integration)

Shopify's Partner events identify shops by domain only — they never include an
email. So at install time AppStorePulse can see the shop's billing but cannot
email it. Close the gap in your OAuth/install callback, where Shopify's Admin
API gives you the shop's email:

```js
// 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}`,
  }),
});
```

A flow triggered on `app_installed` then sends the welcome email within a
minute. Until a shop is identified, it is billing-tracked but unreachable.

## Backfilling existing merchants

Loop your database through identify with the secret key (600/min → ~36k/hour),
or use CSV import in the dashboard. Columns: `email` (required), `shop_domain`
(required), `name`, `status` (`subscribed` / `unsubscribed`, also `1`/`0` —
unsubscribed rows are imported opted out and stay that way, so opt-outs from
your previous tool carry over); any other column becomes an attribute. The
import marks contacts as already-installed by default (a checkbox), and never
fires flows itself.

When backfilling through `identify` instead, pass `alreadyInstalled: true`.

## Contact management (secret key only)

Read, opt out, and delete contacts — e.g. to honor a merchant's data-deletion
request (GDPR). The publishable key is refused on these endpoints.

```bash
# One contact
curl "https://www.appstorepulse.com/api/e/subscribers?email=owner@shop.com" \
  -H "Authorization: Bearer sk_live_xxx"

# Paginated list (optional: status=subscribed|unsubscribed|bounced|complained)
curl "https://www.appstorepulse.com/api/e/subscribers?limit=100&offset=0" \
  -H "Authorization: Bearer sk_live_xxx"

# Opt a contact out (sticky, like the unsubscribe link; reversible from the dashboard)
curl -X POST https://www.appstorepulse.com/api/e/unsubscribe \
  -H "Authorization: Bearer sk_live_xxx" -H "Content-Type: application/json" \
  -d '{"email":"owner@shop.com"}'

# Hard delete (contact + history). Opted-out addresses leave a tombstone so a
# later identify re-creates them suppressed, never subscribed.
curl -X DELETE https://www.appstorepulse.com/api/e/subscribers \
  -H "Authorization: Bearer sk_live_xxx" -H "Content-Type: application/json" \
  -d '{"email":"owner@shop.com"}'
```

## Errors

All errors are JSON: `{ "error": "<human-readable reason>" }` with proper HTTP
status (400 validation, 401 unknown key, 403 origin not allowed, 413 payload
too large (32 KB cap), 429 rate limited with `Retry-After`).

Common 400s:
- `Every contact must be attached to a shop: pass shopDomain = the shop's
  .myshopify.com domain…` — you omitted `shopDomain` on a new contact.
- `shopDomain must be the shop's .myshopify.com domain (got "…")` — wrong
  format; send `their-shop.myshopify.com`, not a custom domain or store name.
- `email is required` / `Invalid email`.
- `name (event name) is required` (track).

## Merge variables (for email content)

`{{name}}`, `{{email}}`, and any attribute key, e.g. `{{plan}}` or
`{{attributes.plan}}`. Fallbacks: `{{name | there}}`.
