E-commerce

Syncing Shopify orders into your own app: webhooks, bulk exports and role-based revenue views

Syncing Shopify orders into your own app: webhooks, bulk exports and role-based revenue views

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

Architecture: Shopify sends webhooks and a one-off bulk export to the sync service, which upserts into PostgreSQL; finance, operations and marketing dashboards read views through their own database roles
Two ways in (bulk history once, webhooks after that), one table, and a view per team.

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.

OptionGood forWhere it falls short
Shopify’s built-in reportsOne person checking salesTeam-specific definitions, hiding numbers from some staff, joining other data
A reporting connector into a spreadsheetQuick monthly analysisRefresh limits, no access control per row or column
Your own synced database (this article)Several teams, custom metrics, permissionsMore 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.

sql
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.

typescript
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);
}
Constant-time comparison of the decoded signatures, so the check doesn’t leak timing information.

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.

typescript
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);
Raw body for the signature check, delivery ids for deduplication, an upsert so replays are harmless.

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:

Terminal: 227 signed webhooks accepted; a repeated delivery id returns 200 and is skipped; a tampered body and a missing signature both return 401; the database holds 218 orders with net sales of 53,529.41
Real output. The repeat is accepted but skipped, forged requests get 401, and 218 orders (227 minus 9 cancelled) land in the table.

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:

graphql
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.

TermWhat it includesShopify field we use
Net salesAfter discounts, order edits and refunds; before shipping and taxcurrent_subtotal_price
DiscountsOrder-level and line-level discountscurrent_total_discounts
RefundsMoney returned to the customerSum of refund transactions
TotalWhat the customer paid, including shipping and taxcurrent_total_price
Agree this table with finance before building any screen.
sql
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.

sql
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;
Column-level grants: operations can list orders without ever seeing their value.

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.

Finance tab: net sales of $53,529 over 30 days, 218 orders, $2,723 discounts, $837 refunds, a daily net sales chart and the largest orders
Finance sees revenue, discounts, refunds and individual order values. The figures match the terminal output above.
Operations tab: 14 open orders, oldest from Oct 27, a list of orders to fulfill and an error when the screen asks for order totals
Operations sees what needs shipping. Asking for order totals returns “permission denied”.
Marketing tab: daily sales chart for the last 30 days and an error when the screen asks for individual orders
Marketing sees daily totals only. Individual orders, and the customers behind them, stay out of reach.

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

FactorWhy it matters
Order volume and historyYears of orders mean a larger bulk load and more care with indexes.
Number of storesSeveral stores or currencies add a store dimension to every view and policy.
Other data sourcesJoining product costs, ad spend or accounting data adds a sync per source.
Teams and rolesEach extra role means views, grants and tests.
Retool or custom front endRetool is quicker internally; a branded or partner-facing app takes longer.

Mistakes we’d avoid

Sources

Spotted something out of date? Tell us

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.

Want one dashboard for your store?

Tell us which numbers each team needs. On a free 30-minute call we’ll sketch the sync, the revenue definitions and the roles we’d set up.