Home
Back in Stock

Reports

Five back-in-stock reports, each readable as JSON and exportable as a CSV via an async job.

The back-in-stock reports resource mirrors the preorder reports machinery: every report has a JSON read and a sibling async CSV export. Reads run against read replicas.

Path prefix: /back_in_stock/reports

Report manifest

GET /back_in_stock/reports

Returns the catalog of available reports — { "reports": [{ "name", "description", "aliases", "url" }] } — generated from the live registry so it can't drift from the actual report actions.

Shared filters

All reads accept the same filter set:

ParamTypeNotes
from, toISO 8601 date or timestampWindow — defaults to the last 30 days. A bare date is that whole day in the shop's time zone
rangeall_timeThe shop's whole history, instead of from/to
variant_idintegerSingle Shopify variant id
product_idintegerSingle Shopify product id
channelemail / sms / push
market_idnumeric or gid://shopify/Market/…Single Shopify market
granularityday / week / monthTime-series reports only; default day

Every response echoes the resolved window at the top: "window": { "from": "...", "to": "..." }, or null for range=all_time.

What each number counts

Each number counts what its name says:

  • signups: signups created in the window.
  • pending: signups waiting now. It ignores the window. With double opt-in on, an unconfirmed signup is not waiting yet.
  • notified: signups notified in the window, by when they were notified.
  • conversions: orders attributed to a back-in-stock alert at medium or high confidence, placed in the window.

Where the numbers come from

A request with no variant_id, product_id, channel or market_id reads the same sources as the Back in Stock Overview dashboard, so the numbers match it:

  • range=all_time: the shop's lifetime counters (signups, orders, revenue).
  • A window: the daily rollups, plus today counted live.

A scoped request counts the rows live, because the dashboard's sources have no product, variant, channel or market dimension. Every report returns source: lifetime totals, daily rollups including today, or live. Figures with no dashboard source (notified, pending, distinct customers) are always counted live.

Reads

MethodPathDescription
GET/back_in_stock/reports/summaryTotals for a window.
GET/back_in_stock/reports/signupsTime-series of signups created (day/week/month).
GET/back_in_stock/reports/notificationsTime-series of notifications sent, split by channel, with the emails and delivered SMS behind them.
GET/back_in_stock/reports/conversionsTime-series of orders attributed to alerts, the revenue recovered and the buyers.
GET/back_in_stock/reports/products_in_demandVariants or products ranked by waitlist demand.

Summary

{
  "window": { "from": "...", "to": "..." },
  "currency": "USD",
  "source": "daily rollups including today",
  "totals": {
    "signups": 420,
    "unique_customers": 390,
    "pending": 661,
    "pending_by_channel": { "email": 600, "sms": 61, "push": 0 },
    "notified": 461,
    "notified_customers": 323,
    "last_notified_at": "2026-09-26T14:02:11Z",
    "conversions": 96,
    "conversion_rate": 0.2972,
    "recovered_revenue": "4812.50"
  }
}
  • unique_customers: distinct people among the signups created in the window.
  • notified_customers: distinct people among the signups notified in the window.
  • conversion_rate: conversions / notified_customers, so a person notified for several variants counts once.
  • last_notified_at: the latest send for the scope, whatever the window.

Signups

Per bucket: signups created in it, how many of those are still waiting, and signups notified in it.

{
  "window": { "...": "..." },
  "granularity": "day",
  "source": "daily rollups including today",
  "series": [
    { "date": "2026-06-01", "signups": 24, "pending": 10, "notified": 14 }
  ]
}

date is the bucket's first day in the shop's time zone. Unscoped, signups comes from the daily rollups.

Notifications

Per bucket: notifications sent (by when they were sent) split by channel, blocked sends (by when they were attempted), and the messages behind the sends.

{
  "window": { "...": "..." },
  "granularity": "day",
  "source": "daily rollups including today",
  "series": [
    { "date": "2026-06-01", "sent": 120, "blocked": 4, "email": 100, "sms": 15, "push": 5,
      "emails_sent": 101, "sms_delivered": 14 }
  ]
}

emails_sent counts back-in-stock emails created and sms_delivered counts SMS messages delivered. Unscoped, those two come from the daily rollups, as the Overview chart does.

Note

This is the aggregate view. For the row-level send log — who got each notification, and why a blocked send was stopped — use the read-only notifications resource at GET /back_in_stock/notifications.

Conversions

{
  "window": { "...": "..." },
  "granularity": "day",
  "currency": "USD",
  "source": "daily rollups including today",
  "series": [
    { "date": "2026-06-01", "orders": 8, "recovered_revenue": "401.00", "unique_customers": 7 }
  ]
}

unique_customers counts the distinct buyers in the bucket. From the daily rollups it is summed per day, so a buyer on two days of a week counts twice in that week.

Products in demand

Variants (or products) ranked by demand — the answer to "what should I restock first?". pending is signups waiting now, whatever the window. total is signups created in the window. A row appears when it has either.

ParamNotes
group_byvariant (default) / product
sort_bypending (default) / total / last_requested_at
directiondesc (default) / asc
page, per_pageper_page default 50, max 500
{
  "window": { "...": "..." },
  "group_by": "variant",
  "sort_by": "pending",
  "direction": "desc",
  "rows": [
    {
      "shopify_variant_id": 123,
      "shopify_product_id": 456,
      "variant_title": "Small / Blue",
      "product_title": "Classic Tee",
      "pending": 42,
      "total": 65,
      "last_requested_at": "2026-06-09T08:30:00Z"
    }
  ],
  "meta": { "total_count": 1, "page": 1, "per_page": 50, "total_pages": 1 }
}

With group_by=product, rows have shopify_product_id and product_title and no variant fields. last_requested_at is the latest signup in the window.

CSV exports

Every read has a sibling */export action:

MethodPath
POST/back_in_stock/reports/summary/export
POST/back_in_stock/reports/signups/export
POST/back_in_stock/reports/notifications/export
POST/back_in_stock/reports/conversions/export
POST/back_in_stock/reports/products_in_demand/export

Exports accept the same filters as the corresponding read (plus sort_by, direction, page, per_page where the read supports them). They submit a job and return 202 Accepted:

{
  "job_id": "exp_1a2b3c4d5e6f7a8b",
  "status_url": "/api/v2/external/back_in_stock/reports/exports/exp_1a2b3c4d5e6f7a8b"
}

Polling

GET /back_in_stock/reports/exports/:job_id

status is one of running, ready, failed, cancelled. When ready, the response includes the download URL:

{
  "job_id": "exp_1a2b3c4d5e6f7a8b",
  "status": "ready",
  "download_url": "https://stoq-exports.s3.amazonaws.com/...",
  "file_name": "back_in_stock_signups_2026-06-10.csv",
  "row_count": 420,
  "progress": 100
}

An unknown job_id returns 404. The polling pattern is the same one used across v2 — see Bulk & Async Jobs.

Example: signups by week, then export

curl 'https://app.stoqapp.com/api/v2/external/back_in_stock/reports/signups?from=2026-03-01&to=2026-06-01&granularity=week' \
  -H "X-Auth-Token: $STOQ_API_KEY"
curl -X POST 'https://app.stoqapp.com/api/v2/external/back_in_stock/reports/signups/export' \
  -H "X-Auth-Token: $STOQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"2026-03-01","to":"2026-06-01","granularity":"week"}'
# Poll until status: "ready"
curl 'https://app.stoqapp.com/api/v2/external/back_in_stock/reports/exports/exp_1a2b3c4d5e6f7a8b' \
  -H "X-Auth-Token: $STOQ_API_KEY"