Shopify’s reports cover a lot, until finance, operations and marketing each want different numbers. Finance wants revenue after discounts and refunds, reconciled with the accounting system. Operations wants today’s unfulfilled orders. Marketing wants sales by product and discount code. And the warehouse team shouldn’t see margins at all.
The reliable answer is a copy of your orders in your own database, kept current by Shopify’s webhooks, with a view for each team and database permissions deciding who sees what. This tutorial walks through the setup with TypeScript and PostgreSQL. We compiled the code, ran it against PostgreSQL 16, pushed a month of signed test orders through it and built a small dashboard on top, which you’ll see at the end.
How it fits together

The full order history is loaded once with a bulk operation, Shopify’s mechanism for large exports. After that, webhooks notify your app within seconds when an order is created, updated, paid, fulfilled, cancelled or refunded. A nightly reconciliation catches anything a webhook missed, because Shopify documents that delivery isn’t guaranteed.
Dashboards never query Shopify directly. They read your PostgreSQL database, which is fast, isn’t subject to API rate limits, and lets you join orders with data Shopify doesn’t have, such as product costs from your accounting system.
| Option | Good for | Where it falls short |
|---|---|---|
| Shopify’s built-in reports | One person checking sales | Team-specific definitions, hiding numbers from some staff, joining other data |
| A reporting connector into a spreadsheet | Quick monthly analysis | Refresh limits, no access control per row or column |
| Your own synced database (this article) | Several teams, custom metrics, permissions | More to build and run |
Step 1: The app and its scopes
Create an app for your store and grant only what it needs: read_orders, read_products and read_customers, all read-only. By default the API only returns the last 60 days of orders; to load full history you also need read_all_orders, which Shopify may ask you to request with a reason.
Step 2: Tables for orders and deliveries
Store the money fields you report on as numbers, and keep the raw payload as JSON for anything you didn’t anticipate. A second table records every webhook delivery id, so a delivery Shopify sends twice is processed once.
create schema shop;
create table shop.orders (
id bigint primary key, -- Shopify order id
name text not null, -- e.g. #1042
created_at timestamptz not null,
financial_status text,
fulfillment_status text,
currency char(3) not null,
subtotal numeric(12, 2) not null, -- after discounts, edits and refunds; before shipping and tax
discounts numeric(12, 2) not null default 0,
tax numeric(12, 2) not null default 0,
total numeric(12, 2) not null,
refunded numeric(12, 2) not null default 0,
cancelled_at timestamptz,
raw jsonb not null,
synced_at timestamptz not null default now()
);
create index on shop.orders (created_at);
create table shop.webhook_deliveries (
webhook_id text primary key,
topic text not null,
received_at timestamptz not null default now()
);Step 3: Verify every webhook
Anyone can send a POST to your webhook URL, so verify each delivery before trusting it. Shopify signs the raw request body with HMAC-SHA256 using your app’s client secret and sends the base64 signature in the X-Shopify-Hmac-SHA256 header.
import crypto from 'node:crypto';
export function isValidShopifyWebhook(rawBody: Buffer, hmacHeader: string | undefined, secret: string): boolean {
if (!hmacHeader) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(hmacHeader, 'base64');
// timingSafeEqual throws on different lengths, so check that first
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}Step 4: The webhook endpoint
The route reads the raw body, verifies it, skips deliveries it has already seen, upserts the order and replies 200. Shopify gives you five seconds for the whole request, so keep the work small; if you need to do more, queue it and reply first. Register this one route for the orders/create, orders/updated, orders/paid, orders/fulfilled, orders/cancelled and refunds/create topics.
import express from 'express';
import { Pool } from 'pg';
import { isValidShopifyWebhook } from './verify.js';
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
type ShopifyOrder = {
id: number; name: string; created_at: string; currency: string;
financial_status: string | null; fulfillment_status: string | null; cancelled_at: string | null;
current_subtotal_price: string; current_total_discounts: string;
current_total_tax: string; current_total_price: string;
refunds?: { transactions: { kind: string; amount: string }[] }[];
};
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), async (req, res) => {
if (!isValidShopifyWebhook(req.body, req.get('X-Shopify-Hmac-SHA256'), process.env.SHOPIFY_CLIENT_SECRET!)) {
return res.sendStatus(401);
}
const webhookId = req.get('X-Shopify-Webhook-Id')!;
const topic = req.get('X-Shopify-Topic')!;
const seen = await db.query(
'insert into shop.webhook_deliveries (webhook_id, topic) values ($1, $2) on conflict do nothing',
[webhookId, topic]
);
if (seen.rowCount === 0) return res.sendStatus(200); // duplicate delivery
const payload = JSON.parse(req.body.toString('utf8'));
// refunds/create sends a refund, not an order; the next orders/updated carries the new totals
if (topic.startsWith('orders/')) await upsertOrder(payload as ShopifyOrder);
res.sendStatus(200);
});
async function upsertOrder(o: ShopifyOrder) {
const refunded = (o.refunds ?? [])
.flatMap(r => r.transactions)
.filter(t => t.kind === 'refund')
.reduce((sum, t) => sum + Number(t.amount), 0);
await db.query(
`insert into shop.orders (id, name, created_at, financial_status, fulfillment_status, currency,
subtotal, discounts, tax, total, refunded, cancelled_at, raw, synced_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now())
on conflict (id) do update set
financial_status = excluded.financial_status, fulfillment_status = excluded.fulfillment_status,
subtotal = excluded.subtotal, discounts = excluded.discounts, tax = excluded.tax,
total = excluded.total, refunded = excluded.refunded, cancelled_at = excluded.cancelled_at,
raw = excluded.raw, synced_at = now()`,
[o.id, o.name, o.created_at, o.financial_status, o.fulfillment_status, o.currency,
o.current_subtotal_price, o.current_total_discounts, o.current_total_tax,
o.current_total_price, refunded.toFixed(2), o.cancelled_at, JSON.stringify(o)]
);
}
app.listen(3000);To test it, we sent a month of signed orders/create webhooks for a demo outdoor-gear store, then replayed a delivery and sent two forged ones:

Step 5: Load the history with a bulk operation
Paging through years of orders with ordinary API calls is slow and burns rate limits. A bulk operation runs the query on Shopify’s side and gives you a file to download. Submit it with the GraphQL Admin API:
mutation {
bulkOperationRunQuery(
query: """
{
orders(query: "created_at:>=2024-01-01") {
edges {
node {
id
name
createdAt
cancelledAt
displayFinancialStatus
displayFulfillmentStatus
currentSubtotalPriceSet { shopMoney { amount currencyCode } }
currentTotalDiscountsSet { shopMoney { amount } }
currentTotalTaxSet { shopMoney { amount } }
currentTotalPriceSet { shopMoney { amount } }
totalRefundedSet { shopMoney { amount } }
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}Shopify notifies you through the bulk_operations/finish webhook topic when the export is done; poll the operation’s status as a fallback (with the bulkOperation(id:) query from API version 2026-01, or currentBulkOperation on older versions). The result is a JSONL file, one JSON object per line, at a URL that expires after a week. Stream it line by line into the same upsert you use for webhooks, and the history and the live feed end up in one consistent table.
Step 6: Agree what “revenue” means, in SQL
This causes more arguments than any technical choice. Each team quietly uses a different definition. Write them down once, as views, and label every dashboard number with the view it came from.
| Term | What it includes | Shopify field we use |
|---|---|---|
| Net sales | After discounts, order edits and refunds; before shipping and tax | current_subtotal_price |
| Discounts | Order-level and line-level discounts | current_total_discounts |
| Refunds | Money returned to the customer | Sum of refund transactions |
| Total | What the customer paid, including shipping and tax | current_total_price |
create view shop.daily_sales as
select (created_at at time zone 'America/New_York')::date as day, -- your store's time zone
count(*) as orders,
sum(discounts) as discounts,
sum(refunded) as refunds,
sum(subtotal) as net_sales, -- after discounts and refunds, before shipping and tax
sum(tax) as tax
from shop.orders
where cancelled_at is null
group by 1;
create view shop.open_orders as
select id, name, created_at, fulfillment_status
from shop.orders
where cancelled_at is null
and coalesce(fulfillment_status, 'unfulfilled') <> 'fulfilled';Step 7: Role-based access in the database
Each team gets a database role, and each role is granted only the views or columns it needs. Finance reads everything. Operations gets the open-orders view and order basics, but no money columns. Marketing gets daily sales but not individual orders. Because PostgreSQL enforces this, it holds in exports and ad-hoc queries too, not just in the interface.
create role finance nologin;
create role operations nologin;
create role marketing nologin;
grant usage on schema shop to finance, operations, marketing;
grant select on all tables in schema shop to finance;
grant select on shop.open_orders to operations;
grant select (id, name, created_at, fulfillment_status) on shop.orders to operations;
grant select on shop.daily_sales to marketing;
-- each app user logs in with a role that inherits one of these:
-- create role maria login password '...' in role operations;If you run several stores, add a store_id column and a row-level security policy so each regional manager sees only their store; the role-based finance portal article shows that pattern in detail.
Step 8: What each team sees
We built a small dashboard on the views above. Each tab runs its queries as that team’s database role, so the grants, not the front end, decide what appears.



Building the screens for real
For an internal dashboard, Retool on top of these views is usually the fastest route, with tables, charts, filters and group-based permissions built in. If the dashboard is for wholesale partners or customers, or needs your branding, a Next.js app on the same database is the better fit.
Once it’s running, the useful extras are small: a daily summary in Slack, an alert when a high-value order sits unfulfilled, and an assistant that answers “what were our top ten products last month?” from these views, scoped to the asker’s role. Read more about our work with online stores on the e-commerce page.
What shapes the cost
| Factor | Why it matters |
|---|---|
| Order volume and history | Years of orders mean a larger bulk load and more care with indexes. |
| Number of stores | Several stores or currencies add a store dimension to every view and policy. |
| Other data sources | Joining product costs, ad spend or accounting data adds a sync per source. |
| Teams and roles | Each extra role means views, grants and tests. |
| Retool or custom front end | Retool is quicker internally; a branded or partner-facing app takes longer. |
Mistakes we’d avoid
Sources
Questions we often hear
Does the same approach work for WooCommerce?
Yes. WooCommerce has its own REST API and signed webhooks; the database, views and role grants stay the same.
Will this slow down our store?
No. The app only reads from Shopify and receives webhooks; dashboards run on your own database, not on Shopify’s API.
What happens if a webhook is missed?
Shopify doesn’t guarantee delivery, so a nightly job compares recently updated orders with your copy and fixes any gaps.



