Automate Shopify sales reports using the GraphQL `orders` query with date filters or, for stores with 1,000+ orders, the `bulkOperationRunQuery` mutation which returns a JSONL file and bypasses rate limits entirely. On Basic plans, use paginated `orders` queries; on higher plans, use `shopifyqlQuery` for analytics-level aggregations. The 1,000-point GraphQL bucket limits non-bulk fetching to ~5-10 pages/second on Standard plans.
API Quick Reference
OAuth 2.0 / Access Token
1,000-point bucket, 50 pts/sec restore; bulk ops: no rate-limit cost
JSON / JSONL (bulk)
Available
Understanding Shopify Sales Reporting via API
Shopify provides two paths for extracting sales data via API. The first is paginated GraphQL `orders` queries with date range filters — available on all plans and suitable for stores with up to a few thousand orders per report period. The second is `bulkOperationRunQuery`, which queues an asynchronous batch export and returns a JSONL file URL — no rate-limit cost, suitable for stores with hundreds of thousands of orders.
For analytics-level reports (revenue by product, conversion rates, traffic sources), Shopify offers `shopifyqlQuery` — a SQL-like query language against Shopify's analytics data warehouse. However, ShopifyQL reporting tables vary significantly by plan: Basic doesn't get full analytics access, Standard gets most tables, and Plus gets the complete analytics dataset.
For financial reconciliation (fees, refunds, payouts, chargebacks), the key is the balance transaction data available via REST `GET /v1/balance_transactions` or the Reporting API for pre-built financial reports. See https://shopify.dev/docs/api/admin-graphql/2026-04/mutations/bulkOperationRunQuery for bulk operation reference.
https://{shop}.myshopify.com/admin/api/2026-04/graphql.jsonSetting Up Shopify API Authentication for Sales Reports
Sales reporting requires read access to orders data. For ShopifyQL analytics queries, you additionally need the read_analytics scope. Create a custom app from the Shopify Dev Dashboard with these scopes.
- 1Go to dev.shopify.com and create a custom app.
- 2Enable Admin API scopes: read_orders, read_analytics, read_reports.
- 3Install the app and copy the generated access token.
- 4Store as SHOPIFY_ACCESS_TOKEN environment variable.
- 5Register a webhook for BULK_OPERATIONS_FINISH to avoid polling bulk operation status.
- 6Set your shop domain: export SHOPIFY_SHOP_DOMAIN='yourstore.myshopify.com'
1import os2import requests34SHOP_DOMAIN = os.environ['SHOPIFY_SHOP_DOMAIN']5ACCESS_TOKEN = os.environ['SHOPIFY_ACCESS_TOKEN']67GQL_URL = f'https://{SHOP_DOMAIN}/admin/api/2026-04/graphql.json'8HEADERS = {'X-Shopify-Access-Token': ACCESS_TOKEN, 'Content-Type': 'application/json'}910# Test: fetch last 3 orders with revenue data11q = '{ orders(first: 3, sortKey: CREATED_AT, reverse: true) { edges { node { id name totalPriceV2 { amount currencyCode } createdAt } } } }'12resp = requests.post(GQL_URL, json={'query': q}, headers=HEADERS)13print(resp.json())Security notes
- •Store access token in environment variables — never in source code.
- •Validate BULK_OPERATIONS_FINISH webhook HMAC before processing bulk results.
- •Order data contains customer PII — handle JSONL result files with appropriate access controls.
- •Delete bulk operation result files after processing — they are publicly accessible by URL.
- •Restrict read_orders scope to reporting contexts only — don't share tokens with systems that don't need financial data.
Key endpoints
/admin/api/2026-04/graphql.jsonGraphQL `orders` query with date range filters — suitable for daily/weekly reports on stores with moderate order volumes.
| Parameter | Type | Required | Description |
|---|---|---|---|
query | string | optional | Filter string: 'created_at:>=YYYY-MM-DD AND created_at:<YYYY-MM-DD AND financial_status:paid' |
first | number | required | Results per page (max 250) |
Request
1{"query": "{ orders(first: 250, query: \"created_at:>=2026-04-01 AND created_at:<2026-05-01 AND financial_status:paid\") { edges { node { id name totalPriceV2 { amount currencyCode } subtotalPriceV2 { amount } totalTaxV2 { amount } lineItems(first: 10) { edges { node { title quantity originalUnitPriceV2 { amount } } } } createdAt } } pageInfo { hasNextPage endCursor } } }"}Response
1{"data": {"orders": {"edges": [{"node": {"id": "gid://shopify/Order/12345", "name": "#1001", "totalPriceV2": {"amount": "145.50", "currencyCode": "USD"}, "subtotalPriceV2": {"amount": "130.00"}, "totalTaxV2": {"amount": "15.50"}, "lineItems": {"edges": [{"node": {"title": "Blue Widget", "quantity": 2, "originalUnitPriceV2": {"amount": "65.00"}}}]}, "createdAt": "2026-04-15T10:30:00Z"}}], "pageInfo": {"hasNextPage": true, "endCursor": "abc123"}}}}/admin/api/2026-04/graphql.jsonGraphQL `bulkOperationRunQuery` mutation — starts an async export of all orders data. Returns a JSONL file URL when complete. No rate-limit cost.
| Parameter | Type | Required | Description |
|---|---|---|---|
query | string | required | GraphQL query to run in bulk — same syntax as regular queries but runs asynchronously |
Request
1{"query": "mutation { bulkOperationRunQuery(query: \"{ orders(query: \\\"created_at:>=2026-04-01\\\") { edges { node { id name totalPriceV2 { amount currencyCode } createdAt lineItems { edges { node { title quantity } } } } } } }\") { bulkOperation { id status } userErrors { field message } } }"}Response
1{"data": {"bulkOperationRunQuery": {"bulkOperation": {"id": "gid://shopify/BulkOperation/11223", "status": "CREATED"}, "userErrors": []}}}/admin/api/2026-04/graphql.jsonGraphQL `currentBulkOperation` query — poll the status of a running bulk operation and get the result file URL when complete.
Request
1{"query": "{ currentBulkOperation { id status errorCode objectCount url } }"}Response
1{"data": {"currentBulkOperation": {"id": "gid://shopify/BulkOperation/11223", "status": "COMPLETED", "errorCode": null, "objectCount": "1543", "url": "https://storage.googleapis.com/shopify-tiers.../bulk-123.jsonl"}}}Step-by-step automation
Query Orders with Date Filters (Small Stores)
Why: For stores with fewer than 1,000 orders per report period, paginated GraphQL queries are simpler than bulk operations.
Use the `orders` query with `created_at` date filters and `financial_status:paid` to get only completed orders. Paginate using the `endCursor` from `pageInfo`. Aggregate revenue, top products, and order counts client-side.
1curl -X POST 'https://mystore.myshopify.com/admin/api/2026-04/graphql.json' \2 -H 'X-Shopify-Access-Token: shpat_your_token' \3 -H 'Content-Type: application/json' \4 -d '{5 "query": "{ orders(first: 250, query: \\"created_at:>=2026-04-01 AND created_at:<2026-05-01 AND financial_status:paid\\") { edges { node { id name totalPriceV2 { amount currencyCode } createdAt lineItems(first: 10) { edges { node { title quantity originalUnitPriceV2 { amount } } } } } } pageInfo { hasNextPage endCursor } } }"6 }'Pro tip: Add `AND financial_status:paid` to the query filter — it excludes pending, refunded, and voided orders from revenue calculations without additional client-side filtering.
Expected result: Array of all paid orders in the date range with line items. At 250 per page, a month with 1,000 orders takes 4 API calls.
Run Bulk Operation for Large-Scale Exports
Why: For stores with thousands of orders, paginated queries consume your GraphQL budget. Bulk operations export everything at no rate-limit cost.
Use `bulkOperationRunQuery` to start an asynchronous export. Only one bulk operation can run at a time per shop. Register the `BULK_OPERATIONS_FINISH` webhook to receive a notification when the export is ready rather than polling the status. The result is a JSONL file where each line is one order record.
1# Start bulk operation2curl -X POST 'https://mystore.myshopify.com/admin/api/2026-04/graphql.json' \3 -H 'X-Shopify-Access-Token: shpat_your_token' \4 -H 'Content-Type: application/json' \5 -d '{6 "query": "mutation { bulkOperationRunQuery(query: \\"{ orders(query: \\\\\\"created_at:>=2026-04-01 AND financial_status:paid\\\\\\") { edges { node { id name totalPriceV2 { amount currencyCode } createdAt } } } }\\") { bulkOperation { id status } userErrors { field message } } }"7 }'Pro tip: Instead of polling, register the `BULK_OPERATIONS_FINISH` webhook once — Shopify will call your endpoint with the result URL when the export is ready, saving repeated API calls.
Expected result: Bulk operation starts with status CREATED, progresses to RUNNING, then COMPLETED with a public JSONL file URL valid for 7 days.
Aggregate Revenue and Build Report
Why: Raw order data needs aggregation to produce the actionable metrics (total revenue, top products, average order value) that make reports useful.
Process the order data (from paginated query or JSONL bulk export) to calculate: total gross revenue, order count, average order value, top 10 products by revenue, revenue by day/week, and refund totals. Format and deliver to Slack, email, or a Google Sheet.
1# Send formatted report to Slack2curl -X POST 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK' \3 -H 'Content-Type: application/json' \4 -d '{5 "text": "Weekly Sales Report",6 "blocks": [{7 "type": "section",8 "text": {"type": "mrkdwn", "text": "*Revenue*: $12,450.00\n*Orders*: 87\n*AOV*: $143.10"}9 }]10 }'Pro tip: Store report snapshots in a database (Supabase/Postgres) for historical trending — comparing this week's AOV to last week's is much more valuable than a standalone number.
Expected result: Structured report object with total revenue, order count, AOV, top products, and daily breakdown ready for delivery to Slack, email, or Google Sheets.
Schedule and Deliver Reports via Cron
Why: Automated delivery on a schedule (daily at 8am, weekly on Monday) turns the raw query into a genuine business intelligence tool.
Run the report generation on a schedule using cron, GitHub Actions, or a serverless scheduler. Deliver via Slack (Incoming Webhooks), email (SendGrid/Resend), or push to Google Sheets (Sheets API). For weekly reports, trigger on Monday mornings covering the previous 7 days.
1# Trigger via cron job or GitHub Actions2# Example: run daily at 8am UTC3# 0 8 * * * python /path/to/automate_shopify_reports.pyPro tip: Use GitHub Actions with a cron schedule (`schedule: - cron: '0 8 * * 1'`) for zero-infrastructure report scheduling — free for public repos, 2,000 minutes/month free for private.
Expected result: Weekly Slack message with total revenue, order count, AOV, and top products delivered every Monday morning.
Complete working code
This script fetches the previous week's paid orders from Shopify GraphQL, aggregates revenue and top products, and posts a formatted summary to Slack. Designed to run as a weekly cron job.
1import os, logging, requests2from datetime import datetime, timedelta3from collections import defaultdict45logging.basicConfig(level=logging.INFO)67SHOP_DOMAIN = os.environ['SHOPIFY_SHOP_DOMAIN']8ACCESS_TOKEN = os.environ['SHOPIFY_ACCESS_TOKEN']9SLACK_WEBHOOK = os.environ['SLACK_WEBHOOK_URL']1011GQL_URL = f'https://{SHOP_DOMAIN}/admin/api/2026-04/graphql.json'12HEADERS = {'X-Shopify-Access-Token': ACCESS_TOKEN, 'Content-Type': 'application/json'}1314def get_period():15 today = datetime.now().date()16 days_since_monday = today.weekday()17 last_monday = today - timedelta(days=days_since_monday + 7)18 last_sunday = last_monday + timedelta(days=7)19 return last_monday.isoformat(), last_sunday.isoformat()2021def fetch_orders(start, end):22 orders, cursor = [], None23 while True:24 after = f', after: "{cursor}"' if cursor else ''25 q = f'{{ orders(first: 250{after}, query: "created_at:>={start} AND created_at:<{end} AND financial_status:paid") {{ edges {{ node {{ totalPriceV2 {{ amount }} lineItems(first: 10) {{ edges {{ node {{ title quantity originalUnitPriceV2 {{ amount }} }} }} }} }} }} pageInfo {{ hasNextPage endCursor }} }} }}'26 resp = requests.post(GQL_URL, json={'query': q}, headers=HEADERS)27 data = resp.json()['data']['orders']28 orders.extend(e['node'] for e in data['edges'])29 if not data['pageInfo']['hasNextPage']:30 break31 cursor = data['pageInfo']['endCursor']32 # Respect rate limits33 throttle = resp.json().get('extensions', {}).get('cost', {}).get('throttleStatus', {})34 if throttle.get('currentlyAvailable', 1000) < 200:35 import time; time.sleep(2)36 return orders3738def aggregate(orders):39 total = sum(float(o['totalPriceV2']['amount']) for o in orders)40 prod = defaultdict(float)41 for o in orders:42 for e in o['lineItems']['edges']:43 n = e['node']44 prod[n['title']] += float(n.get('originalUnitPriceV2', {}).get('amount', 0)) * n.get('quantity', 1)45 top5 = sorted(prod.items(), key=lambda x: x[1], reverse=True)[:5]46 return total, len(orders), total / max(len(orders), 1), top54748def send_slack(text):49 requests.post(SLACK_WEBHOOK, json={'text': text})5051def main():52 start, end = get_period()53 logging.info(f'Fetching orders {start} to {end}')54 orders = fetch_orders(start, end)55 total, count, aov, top5 = aggregate(orders)56 top5_text = '\n'.join(f' {i+1}. {n}: ${r:,.2f}' for i, (n, r) in enumerate(top5))57 message = f'*Weekly Sales Report ({start} to {end})*\nRevenue: ${total:,.2f}\nOrders: {count}\nAOV: ${aov:.2f}\n\n*Top Products:*\n{top5_text}'58 send_slack(message)59 logging.info('Report sent to Slack')6061if __name__ == '__main__':62 main()Error handling
ThrottledGraphQL point bucket exhausted when paginating through large order sets on Standard plans.
Monitor `extensions.cost.throttleStatus.currentlyAvailable` after each request. When it drops below 200 points, add a 2-second sleep. For large stores, switch to `bulkOperationRunQuery` which has no rate-limit cost.
Wait `(requestedCost - currentlyAvailable) / 50` seconds, then retry. Maximum wait ~20 seconds on Standard plan.
currentBulkOperation.status: FAILED, errorCode: ACCESS_DENIEDThe query inside the bulk operation references fields the access token doesn't have permissions for.
Ensure the app has read_orders and read_analytics scopes. Simplify the inner query to only include fields you need — remove any field that requires additional scopes.
Fix the query and scopes, then start a new bulk operation.
Shop is lockedThe Shopify store account is locked — usually fraud review or non-payment.
Contact Shopify support. Wait for the merchant to resolve their account status.
Retry every 30 minutes until the store is unlocked.
A bulk operation for this app is already runningOnly one bulk operation can run simultaneously per shop per app. Attempting to start a second before the first completes returns this error.
Query `currentBulkOperation` before starting a new one. If status is RUNNING or CREATED, wait for it to complete or call `bulkOperationCancel` to cancel it.
Poll currentBulkOperation until status is COMPLETED or FAILED, then start the new operation.
Rate Limits for Shopify Sales Reporting API
| Scope | Limit | Window |
|---|---|---|
| GraphQL Admin API — Standard plan | 1,000 points | Restores at 50 points/second |
| orders query cost (250 per page with lineItems) | ~100-200 points per page | Per request |
| Bulk operations | No rate-limit cost | One operation at a time per app per shop |
1import time23def gql_with_throttle_respect(url, headers, payload):4 resp = requests.post(url, json=payload, headers=headers)5 if resp.status_code == 429:6 time.sleep(2)7 return gql_with_throttle_respect(url, headers, payload)8 resp.raise_for_status()9 result = resp.json()10 throttle = result.get('extensions', {}).get('cost', {}).get('throttleStatus', {})11 available = throttle.get('currentlyAvailable', 1000)12 restore_rate = throttle.get('restoreRate', 50)13 if available < 200:14 sleep_time = (300 - available) / restore_rate15 print(f'Throttle low ({available} pts), sleeping {sleep_time:.1f}s')16 time.sleep(sleep_time)17 return result- For stores with >1,000 orders/period, always use bulkOperationRunQuery instead of paginated queries.
- Register the BULK_OPERATIONS_FINISH webhook instead of polling currentBulkOperation — saves API calls and is more reliable.
- Request only the order fields you need — including all order fields inflates query cost significantly.
- Run reports during off-peak hours (early morning) to avoid competing with real-time operations for GraphQL budget.
- Store historical report snapshots — don't re-fetch Shopify data for trend analysis that could be computed from stored data.
Security checklist
- Store SHOPIFY_ACCESS_TOKEN and SLACK_WEBHOOK_URL in environment variables.
- Validate BULK_OPERATIONS_FINISH webhook HMAC before processing bulk result URLs.
- Bulk operation result files are publicly accessible by URL — download and delete or process immediately; don't store the URL.
- Order data contains customer PII — apply access controls to any stored report files.
- Use read_orders scope only — reporting doesn't need write access.
- Rotate access tokens on a schedule if using expiring offline tokens (refresh window: 90 days).
- Restrict SLACK_WEBHOOK_URL access — it can post to your channel without authentication.
Automation use cases
Daily Revenue Dashboard
beginnerMorning Slack message with previous day's revenue, order count, and AOV compared to same day last week.
Weekly Management Report
intermediateMonday morning email with full week's performance: revenue, orders, top products, and week-over-week growth.
Custom P&L Dashboard
intermediatePush Shopify revenue, refunds, and shipping costs to Google Sheets for month-end P&L calculation.
High-Volume Bulk Export
advancedWeekly full-catalog sales data export for stores with 10K+ orders using bulkOperationRunQuery and JSONL processing.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier available; Starter $19.99/monthZapier's Shopify new order trigger can send formatted Slack messages with order data, but lacks aggregation for period summaries.
- + Zero code
- + Easy Slack/email integration
- + Native Shopify connection
- - No period aggregation — fires per order, not per period
- - Can't do weekly summaries natively
- - Expensive at high order volumes
Make (formerly Integromat)
Free tier (1,000 ops/month); Core $9/monthMake can schedule weekly Shopify order queries and aggregate them with math functions before sending to Slack.
- + Scheduled execution
- + Math/aggregation modules
- + More affordable than Zapier
- - No native period aggregation
- - Complex to set up multi-page pagination
- - Limited GraphQL support
n8n
Self-hosted free; Cloud Starter €20/monthn8n's Shopify node with a Cron trigger and Code node can run the full report pipeline including aggregation.
- + Self-hosted free
- + Full code execution in Code nodes
- + Native cron scheduling
- - Shopify node uses REST, not GraphQL
- - Requires technical setup
- - No built-in aggregation helpers
Best practices
- For stores with >1,000 orders/period, use bulkOperationRunQuery — it has no rate-limit cost and is orders of magnitude faster than paginating.
- Always filter for `financial_status:paid` unless you specifically want pending or refunded orders in your numbers.
- Subscribe to the BULK_OPERATIONS_FINISH webhook rather than polling — it's one-time setup vs repeated API calls.
- Store report snapshots in a database for historical trend analysis — don't re-query Shopify for last month's data when you already have it.
- Compare bulk result URLs against expected order counts (`objectCount`) before processing — a JSONL with fewer lines than orders indicates a partial failure.
- For real-time dashboards, use paginated queries; for scheduled reports, use bulk operations.
- Bulk operation result URLs are valid for 7 days — download and process promptly.
- Use ShopifyQL (`shopifyqlQuery`) on Standard/Advanced/Plus plans for pre-aggregated analytics data — it's faster than computing aggregations yourself.
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building automated Shopify sales reports using GraphQL API (2026-04). I have two approaches: paginated orders query for small periods, and bulkOperationRunQuery for monthly exports. My problem is: [describe issue]. Here's my bulk operation mutation: [paste code]. The currentBulkOperation status returns: [paste]. Help me understand whether to use bulk operations or paginated queries for my store's order volume, and fix any issues in my current implementation.
Build a Shopify sales reporting dashboard that connects to Supabase to store weekly report snapshots. Display charts for weekly revenue trend (last 12 weeks), top products by revenue (bar chart), and daily order volume (area chart). Include a 'Run Report Now' button that calls the Shopify API via a Supabase Edge Function. Use Recharts for visualization and shadcn/ui with Tailwind CSS.
Frequently asked questions
What is the difference between ShopifyQL and regular GraphQL orders queries?
Regular GraphQL `orders` queries return raw order records that you aggregate yourself. ShopifyQL (`shopifyqlQuery`) is Shopify's analytics query language that runs against pre-aggregated analytics tables — it returns already-computed metrics like 'total sales by product this month' without you having to process individual orders. ShopifyQL is available on Shopify Standard and above and is much faster for period summaries. However, it has less granularity than raw order data.
When should I use bulkOperationRunQuery vs regular paginated queries?
Use paginated queries for up to ~1,000 orders — it's simpler and responds synchronously. Switch to bulkOperationRunQuery when you have more than 1,000 orders in your date range, need a full historical export, or are running out of GraphQL budget. Bulk operations have no rate-limit cost, run asynchronously, and return JSONL files that can contain millions of records.
How long do bulk operation result files stay available?
Shopify bulk operation result files are valid for 7 days after the operation completes. Download and process the JSONL file promptly — don't rely on the URL being available a week later. The file is publicly accessible by URL (no auth required) so treat it as sensitive and don't share the URL.
Can I get ShopifyQL analytics data on a Basic plan?
ShopifyQL access varies by plan. Basic plan has limited analytics access — many reporting tables are only available on Shopify Standard, Advanced, and Plus plans. If you're on Basic and need analytics-level aggregations, you'll need to compute them yourself from raw orders data via the GraphQL `orders` query.
What happens when I hit the GraphQL rate limit while generating reports?
Shopify returns HTTP 429 or throttling data in `extensions.cost.throttleStatus`. On Standard plans: 1,000-point bucket, restores at 50 pts/sec. A paginated orders query with 250 records and lineItems can cost 100-200 points. If you're regularly hitting the limit on report days, switch to `bulkOperationRunQuery` which completely bypasses the rate-limit system.
Is the Shopify API free?
The Admin API is free for apps installed on Shopify stores. There are no per-call fees. Your Shopify plan determines the GraphQL rate limit bucket size (Standard: 1,000 pts; Plus: 10,000 pts). Third-party infrastructure (server, cron, Slack) is your own cost.
Can RapidDev help build a custom Shopify reporting system?
Yes. RapidDev has built 600+ apps including Shopify analytics dashboards with real-time P&L tracking, multi-store aggregation, and Google Sheets integrations. We can connect Shopify's bulk export API to your existing BI tools. Book a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation