Skip to content
Back to Resources
Playbook

Every Refund You Issued, With the Reason

Skopx Team
August 5, 2026
11 min read

The finance lead pulls up the Stripe dashboard on the first Monday of the month and sees refunds at 4.1% of gross volume. Last month it was 2.9%. The month before, 2.4%. Three months of climb, roughly forty thousand dollars in the most recent month, and the number is now large enough that someone on the board will ask about it.

So she exports the refund list. What she gets back is exactly what Stripe stores: a charge ID, a customer ID, an amount, a currency, a created timestamp, a status, and a reason field that contains one of three values, duplicate, fraudulent, or requested_by_customer. Nearly every row says requested_by_customer. That field is a Stripe API enum, not an explanation. It tells her the refund was not flagged as fraud and not a double-charge. It tells her nothing about why the customer asked or why anyone said yes.

The actual reasons exist. They are just not in Stripe. They are in a Slack channel called #refund-approvals, where a support agent posts the customer name and the amount and a line or two of context, and a manager replies with a thumbs up or a question or a "yes but let's flag the account". They are in the email thread where the account executive told the customer "we'll make this right" after a failed migration. They are in the Zendesk ticket comment where the agent wrote "third time the export has timed out for them this week, refunding the month".

Every one of those is a sentence a human typed. That is the entire difficulty of this question, and it is why the chart of refund volume has been climbing for three months without anyone being able to say what is driving it.

What the billing record actually holds

It is worth being precise, because the gap is narrower and stranger than people expect.

Stripe stores a reason enum with three possible values, and it stores a metadata object that is empty unless your engineers deliberately wrote something into it at refund time. Most teams do not. When they do, the values are inconsistent, because whoever issued the refund typed the metadata by hand or picked from a dropdown that has drifted out of date. Stripe also records who issued it when the refund came through the dashboard rather than the API, which narrows the search but does not answer it.

So the data warehouse table your analytics team synced from Stripe has everything needed to chart the amount and nothing needed to chart the cause. You can slice refunds by month, by plan, by country, by payment method, by first charge versus renewal. You can build a careful cohort view. You cannot build a single view that says "eleven thousand dollars of last month's refunds trace back to the SSO outage in week two", because the string "SSO outage" appears nowhere in any billing record. It appears in a Slack thread.

Why the BI tool cannot close this

This is not a knock on BI tools. They are good at what they do and they do more than people give them credit for. Power BI has Copilot. Looker has Conversational Analytics. Tableau has Pulse. You can ask all three a question in plain English and get a chart back. The limitation is not the interface.

The limitation is the connector layer. A BI tool answers questions about sources it has modelled, and the sources it models are databases and structured warehouses. Looker connects over SQL dialects, which is to say, to things that speak SQL. Metabase ships nineteen official drivers, and every one of them is a database. A Slack thread has no schema to model. A conversation is not a table, and the quality of the natural language on the front end does not change what is available on the back end.

So the question "why did we issue this refund" is not a hard query for a BI tool. It is an invisible one. The evidence sits outside the sources the tool can see. You can pipe Slack exports into a warehouse table, and some teams have, but then you have a column of raw message text sitting next to a column of amounts and no join key between them, which is the same problem one layer down.

Internal-tools builders have the mirror-image version of this. Retool, Appsmith and Budibase can all build a refunds console quickly, and Retool AppGen will generate one from a prompt. But a panel is built on top of a resource, and a resource is a table or an API endpoint. Point it at the Stripe API and you get a very good refunds table with sorting and filtering and a detail drawer, and the detail drawer shows requested_by_customer, because that is what the endpoint returns. You can wire in the Slack API as another resource, and then you own the hard part yourself: writing the matching logic, and reading forty threads to decide what they say.

What the question actually requires joining

Answering "every refund, with the reason" means connecting three things that share no identifier:

SourceWhat it holdsWhat it lacks
StripeRefund amount, charge ID, customer ID, timestamp, three-value reason enumAny human explanation
Slack #refund-approvalsThe request, the context, the approver, the decisionAmounts that reliably match, customer IDs, any structure
Zendesk or emailThe customer's original complaint, the agent's assessmentConnection to the money

The join is not on a key. It is on a customer name that appears in the Slack message as "Northwind" and in Stripe as "Northwind Logistics Ltd", on a timestamp that is close but not equal because the approval came twenty minutes before the refund was pressed, and on an amount that sometimes matches exactly and sometimes does not because the agent proposed $890 and the manager approved a full $1,200.

Then, once matched, the reasons have to be read and grouped. Forty Slack threads might carry a dozen distinct root causes expressed in forty different phrasings. "Export kept timing out", "their CSV never finished" and "the download broke again" are the same cause. Grouping them is a reading task, not an aggregation.

That is why this question has stayed unanswered in most companies. Someone tries it manually once, spends two days, produces a slide, and never does it again.

Doing it in Skopx

Skopx connects to nearly 1,000 tools, Stripe and Slack and Zendesk and Gmail among them, and to your own databases directly. Because the conversation surfaces are connected the same way the billing surface is, the join above is something you can just ask for.

You would open chat and type something close to this:

Pull every Stripe refund from the last 90 days. For each one, find the Slack thread in #refund-approvals or the Zendesk ticket that led to it, matching on customer name, amount and a timestamp within 24 hours before the refund. Read the thread and tell me the actual reason in plain language. Group the reasons into categories and show me total dollars by category.

It reads the Stripe refund objects, searches the Slack channel and the ticket history around each refund window, proposes the match, reads the prose, and comes back with the categories. Every line it gives you cites the specific message or ticket it came from, so when a number looks wrong you can click through and see the sentence a person actually wrote.

The first pass is a conversation, and it should be. You will look at the grouping and say "merge those two categories" or "the ones tagged onboarding are really billing errors" and refine it. Once the answer is right, you ask for it as a console, and the internal apps feature builds one: you describe the screen in a sentence, it runs the same queries against the tools you already connected, measures what comes back, and renders the page.

What the console shows

Say you asked for a refund reasons console. What renders is a page, not a chart library.

At the top, a row of metrics: total refunded in the period, refund rate against gross volume, number of refunds, and the share that could be matched to a documented approval. That last one matters more than it looks, and I will come back to it.

Below that, a bar chart of refunded dollars by reason category, because reason category is a handful of discrete buckets and a bar is the honest shape for that. Next to it, a line chart of refund dollars by week split by the top three categories, which is where the three-month climb finally resolves into something legible. Usually it is one category doing all the work.

Then the table, one row per refund: date, customer, amount, the reason in a sentence, who approved it, and a link to the source thread. This is the part people actually use. The aggregate tells you where to look, the table tells you what happened.

And on rows that need follow-up, buttons. A button posts a message to the account owner in Slack, or creates a Linear issue for the engineering cause, or sends an email to the customer, using the tools you already connected. Each button is a single action, clicked by a person, with a confirmation before it fires. Nothing runs on its own and nothing is on a timer. The console itself stores nothing: no records, no saved copy of the analysis. It re-reads Stripe and Slack and Zendesk every time someone opens it, and the buttons are the only way anything is written back.

The honest limits

The match is fuzzy, and it will be wrong sometimes. A refund issued on a Friday for a conversation that happened the previous Tuesday will not be found by a 24-hour window. Two customers with similar names will occasionally get crossed. This is why the console shows the matched share as a headline metric rather than hiding it. If, say, three quarters of refunds trace to a documented approval, then the categories describe three quarters of the money, and you should say so out loud when you present them. The unmatched remainder is not noise, it is a finding. It usually means refunds are being issued outside the approval channel, and that is worth a separate conversation.

Reading prose into categories involves judgment, and judgment can be miscalibrated. Check a sample of ten rows against their source threads before you trust the totals. The citations make that a five-minute job rather than a re-investigation.

The SQL side is read-only, always. If you point Skopx at your production Postgres to enrich the customer records, it queries and never writes. Writes happen only through a button someone presses.

And if the reason genuinely was not written down anywhere, no tool recovers it. A refund approved by someone leaning over a desk and saying "yeah, just do it" leaves no trace. What Skopx does is find the reasons that were written down and are simply scattered, which in most companies is the large majority of them.

Close

The refund rate climbing from 2.4% to 4.1% is a fact anyone can see. What it means lives in a few dozen Slack messages, and reading those messages and matching them to the billing records is a job nobody has time to do by hand, which is why it never gets done and why the board question gets answered with a shrug and a plan to "keep an eye on it".

The reasons were written down. Someone typed them at the moment the decision was made, when the context was fresh and specific. They just landed in a different system than the money did. Joining those two systems is the whole job, and it is worth doing once, because the answer is almost never "customers are unhappy". It is usually one broken thing, named plainly in a thread from six weeks ago, that nobody connected to the number.

Share this article

Skopx Team

The Skopx engineering and product team

Related Articles

Stay Updated

Get the latest insights on AI-powered code intelligence delivered to your inbox.