automations.help

Reporting & dashboards

Track refund rate and reasons in a dashboard

Your refunds line goes up and all you can see is the dollar total — not what percentage of orders it is, whether it's getting worse, or which products and reasons are driving it. This wires the answer up: a Google Apps Script pulls every refund and its return reason from the Shopify Admin API into a logged-and-normalized refunds tab, alongside an orders tab for the denominator, and a dashboard tab built on QUERY formulas shows refund rate over time, reasons ranked, and the products doing the returning — recalculating on its own as new rows land. Build it yourself with the steps below, or we build it on your own Shopify and Google accounts, reconcile it against your Shopify reports, and hand you the keys so you own it with no monthly fee. Free teardown of your current refund reporting first.

Difficulty
Intermediate
Time to build
3-5 hrs
Updated
  • Shopify
  • Google Sheets

The manual way today

Right now the refund number lives in three disconnected places. Shopify's finance summary tells you the dollar total but not the rate. The reason — if anyone wrote one — sits in a free-text note on each refund or buried in a support thread. And 'which products keep getting returned' is a question nobody can answer without opening orders one at a time. So once a quarter someone exports orders, eyeballs the refunds, and tries to remember whether last month felt worse, with no consistent reason codes to group by because every staffer phrases the note a little differently.

The dollars-refunded total feels like the whole story, so the rate hides — and a refund rate creeping from 4% to 7% is invisible when you only ever look at the raw total, which naturally grows as the store grows. The reasons hide worse. Returns are the single clearest signal you have about a product problem: a size that runs small, a photo that oversells the color, a package that arrives crushed. That signal is scattered across free-text notes that can't be counted, so you end up paying for the same return reason over and over — refund after refund — because the data that would have told you to fix the size chart or the packaging was never captured in a way you could chart.

What running looks like

A Google Sheet that keeps a clean, reason-coded record of every refund and shows what it means. Each refund lands in an append-only log within the polling window — refunded amount, full-or-partial, the product and SKU, and the reason normalized to your own fixed list — and a separate dashboard tab shows refund rate by week against the order count for the denominator (not just raw dollars), reasons ranked by share, the products driving the most returns, the full-versus-partial split, and average days from order to refund. A data-quality cell shows what percentage of refunds came in without a coded reason, so you always know how much to trust the breakdown, and a 'last updated' timestamp means a stalled sync can't masquerade as a quiet week.

01The build

How to build it

  1. Pin the metric definition and the reason list before you touch the API

    Needs a human

    Decide two numbers up front, because 'refund rate' means nothing until you say so. The headline rate is refunded orders ÷ total orders over the same window; carry a second, dollar-based rate (refunded amount ÷ net sales) right beside it, because a store with many tiny partial refunds and one with a few full refunds look identical on a count and very different on dollars. Then fix a closed list of about eight reason buckets — for apparel that's usually Too small, Too large, Defective/damaged, Not as described, Wrong item sent, Changed mind, Arrived late, and Other — because a reason you can't put in a fixed column is a reason you can't chart. Write these down; every later step maps to this list.

  2. Make Shopify actually capture a structured reason

    Needs a human

    This is the part everyone skips, and it's why most refund dashboards are just a dollar total. A plain refund (Orders → Refund) records only a free-text note, so if that's your flow you have to enforce one rule the whole team follows: the refund note must begin with a code from your fixed list — 'TOO_SMALL — ...' — or the order gets tagged refund-reason:too-small. Better, switch returns onto Shopify's native Returns flow, where each return line item carries a structured return_reason enum (SIZE_TOO_SMALL, SIZE_TOO_LARGE, DEFECTIVE, WRONG_ITEM, NOT_AS_DESCRIBED, STYLE, COLOR, UNWANTED, OTHER) plus an optional note — a real dropdown captured at the source instead of a sentence you have to parse later. Pick one source of the reason and make it the only one; a dashboard built on inconsistently-written notes will always have a giant 'Other' bar.

  3. Lay out three tabs: orders_log, refunds_log, dashboard

    Needs a human

    Split the sheet so raw data and presentation never mix. orders_log is append-only, one row per order — order_id, order_name, created_at (store-local), net_total, channel — and exists purely to be the denominator for the rate. refunds_log is one row per refunded line item, not per refund, so the product and SKU cuts work: refund_id, line_id, order_id, order_name, refund_created_at, refunded_amount, refund_kind (full/partial), reason (normalized to your list), reason_raw, product_title, sku, quantity, days_since_order, channel, last_synced. One row per line item is the call that lets you answer 'which product gets returned and why' instead of only 'how much money went out the door'. The dashboard tab never holds data — it only reads these two with formulas.

  4. Pull orders and refunds with a time-driven Apps Script

    Runs itself

    From the sheet, open Extensions → Apps Script and write a function that calls the Shopify Admin API on a time-driven trigger every 30 minutes — refund rate is an analytics number you read weekly, so polling is plenty and keeps the whole build to just Shopify and Google. Page through orders with updated_at_min set to the last successful run (saved in PropertiesService), following the Link header until there's no next page, and keep the API token in Script Properties, never in a cell. Append each order to orders_log for the denominator, and for every order that has entries in its refunds[] array, hand each refund to the logging step. If you already run n8n, the refunds/create webhook is the near-real-time alternative — but for this metric the scheduled pull is the simpler, honest default.

  5. Write one normalized row per refunded line item

    Runs itself

    For each refund, walk its refund_line_items and write a row per line, so a three-item refund becomes three rows tied to one refund_id. Pull the refunded amount from the refund's transactions (the money actually returned, which for a partial refund is less than the line's full price), set refund_kind by comparing the refunded total to the order total, and compute days_since_order from the order's created_at. Map the reason in the same pass — Shopify's return_reason enum or your note/tag convention — into your fixed bucket, and write the original text into reason_raw so nothing is lost. Handle the refunds that have no product line at all: a shipping-only refund or a goodwill credit has no refund_line_items, so log it as a single row with reason 'Goodwill/adjustment' rather than dropping it, or your dollar rate will understate.

  6. Upsert on refund_id + line, and bucket anything uncoded

    Runs itself

    Because the poller re-reads recently-updated orders on every run, the same refund will come back around — so before writing, look for the refund_id and line_id already in the log and update that row in place instead of appending a duplicate. A refunded total that keeps growing every 30 minutes is the classic symptom of skipping this. Any reason that doesn't map cleanly to your fixed list goes to a single 'Uncoded' bucket, never silently dropped — that bucket is the honest measure of how well the capture step is working. If 'Uncoded' is 40% of your refunds, the dashboard is telling you to fix the capture, not the products.

  7. Build the dashboard on live QUERY formulas

    Runs itself

    On the dashboard tab, compute the rate by joining the two logs over matching date windows: refunded orders (distinct order_id in refunds_log for the week) ÷ orders (rows in orders_log for the week), with the dollar rate as refunded_amount ÷ net_total beside it. Add the cuts that actually drive decisions — reasons ranked as a share of refunds via a QUERY group-by, the top products and SKUs by both refund count and refunded dollars, the full-versus-partial split, and average days_since_order. Put a SPARKLINE of weekly refund rate across the top so a slow creep is visible at a glance, and drop a single cell showing the 'Uncoded' share as your trust gauge. Because every figure is a formula over the logs, each new row recalculates the whole dashboard on its own.

  8. Backfill once, surface freshness, and know the ceiling

    Needs a human

    Run the script once with a wide updated_at_min to backfill a few months of history so the trend has a baseline, then let the 30-minute trigger keep it current. Put a cell at the top of the dashboard reading the newest last_synced — 'Updated 12 min ago' — so a stalled script shows as a stale timestamp instead of looking like a week with no returns. Mind the two real limits: Apps Script caps a single execution at six minutes, so a large backfill has to run in dated chunks, and a Google Sheet tops out at ten million cells. Past a few hundred refunds a month, that's the honest point to move the log into a database with a BI tool on top — and we'll tell you when you're there rather than selling you a spreadsheet fighting its own size.

02The stack

What it’s built on

Shopify
System of record for orders and refunds. The Admin API's orders endpoint (filtered by updated_at_min) carries each order's refunds[] array with the refunded line items and amounts, and Shopify's native Returns flow attaches a structured return_reason to each returned line — which is what makes a reason you can chart rather than parse out of a free-text note.
Google Sheets
Holds the whole thing: an append-only orders_log and refunds_log, plus a dashboard tab whose QUERY, SPARKLINE, and group-by formulas recalculate the rate and the reason breakdown the instant a row lands. Apps Script — built into Sheets — is the poller, so the entire build lives in tools you already have, with no third service.Google Sheets and Apps Script are free within generous quotas; the honest limits are Apps Script's six-minute-per-run cap and the ten-million-cell ceiling per file. That comfortably covers most stores' refund volume — a very high-volume shop eventually wants a real database behind it.

03Questions

Before you build

Why can't I just use Shopify's refund total?

Because the raw total only ever goes up, so it hides the two things you actually need. It hides the rate — refunds as a share of orders — which is the number that tells you whether returns are getting worse or just keeping pace with growth; a dollar total can climb while your rate is flat or even falling. And it hides the reasons entirely: the finance summary has no idea a product runs small or ships damaged. This build keeps the dollar total but adds the rate and the coded reasons next to it, so you're looking at why money is going out, not just that it is.

Where does the return reason actually come from?

Shopify only captures a structured, chartable reason if you use its native Returns flow, where each returned line gets a return_reason from a fixed enum (SIZE_TOO_SMALL, DEFECTIVE, WRONG_ITEM, and so on) plus an optional note. A plain Orders → Refund records only a free-text note, which every staffer writes differently and which can't be grouped. So you either move returns onto the native flow or enforce a coded-note/tag convention on plain refunds. Either way the dashboard normalizes what you choose into your fixed list and buckets anything that doesn't match as 'Uncoded' — so you can see exactly how much of the reason data is missing instead of pretending it's complete.

Is polling every 30 minutes fast enough?

Yes, for this metric. Refund rate is something you read weekly or monthly to spot a trend, not a live counter you watch by the minute, so a refund showing up in the sheet within half an hour is more than current enough — and polling is what keeps the whole build to just Shopify and Google with no third tool. If you genuinely want near-real-time, the refunds/create webhook routed through n8n is the alternative, but it's more moving parts to maintain for a number that doesn't change by the minute.

Won't partial refunds and multi-item returns throw the numbers off?

Not if you log one row per refunded line item and pull the amount from the refund's transactions rather than the line's list price. A partial refund then records exactly what was returned, a multi-item return becomes several rows under one refund_id, and the product and SKU cuts stay accurate. The other half is upserting on refund_id plus line_id so the poller re-reading a recent order doesn't append the same refund twice — get that right and the dashboard tracks Shopify instead of inflating a little on every pass.

What about refunds with no product, like shipping or a goodwill credit?

Those get logged too, as a single row with the reason 'Goodwill/adjustment', because a refund with no refund_line_items still moved real money. If you dropped them, your dollar-based refund rate would quietly understate what you're actually paying out. Keeping them in a clearly-labeled bucket means the dollar rate stays honest while they never pollute the product or return-reason breakdowns, which only count refunds tied to an item.

Can I build this myself, or should you build it?

Every tab, column, API call, reason mapping, and edge case is written out above on purpose — if you've got a few hours and some API comfort, build it yourself and own it outright. If you'd rather not own the Apps Script pagination, the one-row-per-line logging, the upsert-on-refund_id logic, and the QUERY dashboard, we'll build it on the Shopify and Google accounts you already use, reconcile it against your Shopify reports, document every piece, and hand you the keys. You own the workflow and the sheet — no monthly retainer, and nothing stops working if you walk away. It starts with a free 20-minute teardown of your current refund reporting, not a contract.

Support & returns

Build a self-service return portal for Shopify

Right now every return is an email thread: the customer asks, you ask for the order number, you check the policy, you make a label, you wait, you refund. A self-service portal lets the customer do the routine part themselves — enter the order, pick items, get a label — while your policy runs server-side so nobody can return something they shouldn't. Gorgias only hears about the returns that actually need a human. Build it yourself with the steps below — or we build it, wire in the guardrails, and hand it over so you own it outright with no monthly fee. Free teardown of your current returns flow first.

  • Shopify
  • Gorgias

Advanced1-2 weeks

Support & returns

Auto-issue store credit for returns

Every approved return that resolves as store credit ends the same way: an agent works out the amount, opens the customer up, and manually issues a gift card or credit — then writes the 'here's your balance' reply. It's small, it's identical every time, and it happens on every single case. This wires the credit to the return itself: when the package is back and the return is closed, Shopify credits the customer's store-credit account for the right amount and emails them — untouched. Build it yourself with the steps below — or we build it, wire in the guardrails, and hand it over so you own it outright with no monthly fee. Free teardown of your current returns flow first.

  • Shopify
  • Gorgias

Intermediate3-6 hrs

Reporting & dashboards

Build a real-time sales dashboard in Google Sheets

Every week you export a Shopify CSV, paste it into the same spreadsheet, fix the columns, and rebuild the same pivot — to land on a number you could have had automatically. This wires it up instead: each paid order appends itself to a raw log tab in Google Sheets within a minute or two, a scheduled reconcile job catches anything the webhook missed, and a dashboard tab built on QUERY formulas recalculates the moment a row lands. Build it yourself with the steps below, or we build it on your own Shopify and Google accounts, reconcile it against your Shopify Analytics, and hand you the keys so you own it with no monthly fee. Free teardown of the report you rebuild by hand first.

  • Shopify
  • Google Sheets
  • n8n

Intermediate3-5 hrs

Reporting & dashboards

Send a daily revenue digest to Slack

Every morning starts the same way: open Shopify, wait for the dashboard to load, read yesterday's number, close the tab. This does it for you. Once a day it pulls yesterday's orders from Shopify, totals net sales, order count, AOV, and units, compares it to the same weekday last week so the number actually means something, and posts a clean digest to a Slack channel before you've finished your coffee. Build it yourself with the steps below, or we build it on your own accounts, wire the math and the comparison, and hand you the keys so you own it with no monthly fee. Free teardown of what you check by hand every morning first.

  • Shopify
  • Slack

Intermediate2-4 hrs