Refunds and Disputes
How to refund an order from the admin, what a partial or full refund changes, what happens when a provider fails a refund later, and how disputes are handled. It ends with the webhook events to enable and what each provider’s refund call looks like.
Not verified live. Refund calls and dispute events for Stripe, PayPal, Razorpay and Polar are covered by tests with recorded request and response shapes, and the whole flow runs end to end with the mock gateway. None of them has run against a live sandbox yet. Before you rely on them, refund a test-mode payment from the admin with each provider you use, and open and close a test dispute where the provider lets you.
Refund an order
Open the order in Orders. The Refunds section shows what was refunded so far, what is left, every refund with its credit note, and any dispute.
- Choose An amount or Whole items or shipping.
- An amount is spread over the order’s items in proportion to what is left of each, the shipping charge last. An item refunded down to zero gives back all its units.
- Whole items refund what is left of each item you tick. Tick Shipping to refund what is left of the shipping charge, on its own or with items.
- Either way, a physical item that has not shipped goes back to stock when it is refunded in full.
- Tick Disable the licenses of the refunded items if the buyer should lose them. A partial refund keeps licenses active unless you tick it.
- Add an internal reason if you like, and refund.
- Refunding everything that is left asks you to type the order number first.
The form keeps one request key per refund. If the provider doesn’t answer (a network error or an outage), nothing is recorded, the amount stays reserved, and sending the same refund again retries the same provider call: the provider can’t refund twice because every call carries the idempotency key vw-refund-<refundId>. A refusal from the provider gives the reserved amount back and shows the provider’s reason.
The admin route behind the form is commerce/refunds/create:
{ "orderId": "42", "amountMinor": 1500, "reason": "Duplicate", "revoke": false, "requestKey": "rf_8c2f0a1b" }
{ "orderId": "42", "lines": [{ "orderItemId": "57", "revoke": true }], "requestKey": "rf_8c2f0a1c" }
commerce/refunds/list returns an order’s refunds, credit notes, disputes and what is left per item and of the shipping charge, counting refunds that are still being applied.
A refund always uses the payment mode the order was paid in, not the store’s current mode: its credentials, its API host (PayPal and Polar have separate sandbox hosts) and its webhook secret. A test-mode order stays refundable after the store goes live, as long as the test keys are still saved, and it is never sent to the live keys. When the keys of the order’s mode are missing, the refund is refused with "This payment was made in test mode, and Stripe has no test credentials" (or the live equivalent), and nothing is recorded. Turning off automatic renewal and canceling a provider subscription (after a refund or a dispute) work the same way, in the mode the subscription was started in. A provider webhook signed with the other mode’s secret is verified with that secret and applied only to checkouts of that mode.
You can still refund in the provider’s dashboard. The provider’s webhook records that refund the same way, so the order, the credit notes and the reports look the same.
What a refund does
Every refund is one row in refunds and one entry in the checkout’s refund ledger, keyed by the refund id. The admin route and the provider’s webhook meet on the claim provider-refund:<provider>:<providerRefundId>, so a refund is recorded once whichever arrives first. Our own refunds carry our refund id to the provider (metadata, notes or a custom id), so their webhook names the row. A webhook that names a provider refund without our id is a refund made in the provider’s dashboard and becomes its own provider row, even when an admin refund of the same amount is waiting. Only a webhook that names no refund at all (just a running total) takes over a waiting admin refund of the same amount.
Stripe’s newer API versions send charge.refunded without the charge’s refunds, so Vendwick reads them from Stripe (GET /v1/refunds?charge=…) and records the newest one with its metadata. If Stripe cannot answer, the webhook answers 503 and Stripe sends it again.
A refund the provider reports before it reports the payment (webhooks can arrive out of order) is recorded on the checkout and applied to the order as soon as the payment is recorded and the order exists.
Then a job on the refunds queue, one per refund, applies it in steps that each run once:
- the order:
partially_refundedwhile refunds are below the total,refundedMinor= the sum of its refunds; - the items:
refundedMinorandrefundedQuantityper item; - licenses of items marked to disable are disabled, found by the item that issued them (two plans of the same product in one order never take each other’s licenses), and their subscriptions stop renewing;
- a credit note per refund (with the invoices module on);
- a refund entry in the reports on the day it was recorded (with the reports module on);
- the
orderPartiallyRefundedevent and the customer’s "Part of your order was refunded" email.
Vendwick never refunds more than was paid: an admin refund reserves its amount first, and every ledger entry is capped at what is left.
Two refunds at once
Each item and the shipping charge have their own cap too, and it holds when refunds overlap: two admins, the admin page and the MCP create-refund tool at the same time, or a second refund sent while the first one’s job is still waiting.
- What is left of an item is what was paid for it, minus what its applied refunds took, minus what refunds still in flight have reserved.
- An admin refund reserves its items and shipping on the order in one write before it asks the provider. Of two refunds sent at once, the first reservation wins and the other is refused with "Another refund of the same item or shipping is under way". Nothing reaches the provider for the refused one, its reserved amount is given back, and nothing is restocked or disabled twice.
- A refund by amount is spread over what is left at the moment it reserves, so two amount refunds never collide.
- A refund the provider reports without lines of its own (from its dashboard, or a lost dispute) is spread only over what is left once the refunds in flight are counted.
- When the provider’s webhook for an admin refund arrives first and names only the provider’s refund id, the provider’s row becomes the refund and takes over the admin’s lines (restock and disable), so the admin’s choices still apply.
When a refund gets stuck
Each refund’s job retries on its own. A job that fails every attempt, or stops as uncertain (the full reversal it waits for failed), shows in Needs attention as "Refund jobs that failed" and in the Refunds queue of Orders > Delivery queue (/deliveries?queue=refunds), with its last error. Fix the cause, then Retry now: the job continues from the step it reached and never repeats a finished step. The routes are commerce/refund-jobs/list and commerce/refund-jobs/retry ({ "jobId": "57" }).
When refunds add up to the total
The refund that brings the ledger to the amount paid is the final one. The checkout becomes refunded and the full reversal runs once:
- every license of the order is disabled and its subscription canceled;
- the coupon use is given back, once, so the coupon’s limits and the buyer’s per-customer limit free up (a partial refund keeps it);
- the provider subscription is ended (
cancelSubscriptionNowwhere the gateway has it, else automatic renewal off); - the order becomes
refundedand the "Your refund" email goes out.
The final refund’s credit note covers only what was left, and it is the order’s own credit note number. So the credit notes, the report refunds and the order’s refunded amount always add up to the same figure, and never to more than the order total.
A full refund the provider reports without a refund id or amount records what is left as one refund. A renewal is refunded by its own charge, not the purchase.
Restock is a flag on the refund’s lines; see Physical Products: Shipping, Stock and Shipments for how stock comes back.
When the provider fails a refund later
A refund the provider accepts as pending counts at once. When the provider later reports that it failed or was canceled:
- the refund row becomes
failed, withproviderFailedAtand the provider’s reason; - its ledger entry is taken back, so the checkout’s refunded amount is exactly what the other refunds add up to;
- the order’s and the items’ refunded amounts drop by it, and a
partially_refundedorder with nothing left refunded ispaidagain; - its credit note is marked void. It keeps its number (credit note numbers are never deleted or reused) and stops counting, so the credit notes that are not void, the report refunds and the order’s refunded amount still add up to the same figure;
- the reports take it back on the day the failure arrived (a negative refund entry); the day it was recorded keeps it, so past days never change;
- licenses that refund disabled are re-enabled, but only while they are still disabled by that refund: one an admin, a dispute or another refund changed since stays as it is. A subscription the refund canceled stays canceled (the provider’s subscription cannot be restarted), so the license renews by hand;
- units the refund put back in stock are taken out again, once. If they were sold again meanwhile, the count goes below zero and the order is flagged as a backorder. The order waits to ship again and is back in the to-ship list;
- you get a "Refund failed" email at the new-order address, and the refund shows in Needs attention for 30 days.
The refund that completed the order’s full refund is the exception. The full reversal already ended the provider subscription and gave the coupon use back, which Vendwick cannot undo safely. That refund is marked (providerFailedAt) and you are alerted, but the order stays refunded and its credit note stands. Refund the payment again at the provider, or correct the order by hand.
Disputes
A dispute (chargeback) is one row in disputes, keyed <provider>:<disputeId>.
- Opened: the order’s
disputeStatusbecomesopen, its active licenses are disabled withsuspendedBy: "dispute"and recorded on the dispute, automatic renewal is turned off at the provider, refunds from the admin are blocked, and you get a "Dispute opened" email at the new-order address. - Won: the recorded licenses that are still suspended by the dispute are restored. A license you restored and disabled yourself in between stays disabled, and so does one a refund disabled meanwhile.
- Lost: the disputed amount, capped at what is left after earlier refunds, is recorded as a
disputerefund and goes through the same refund job (or the full reversal, when it completes the total). The suspended licenses stay disabled.
A closed dispute never reopens. A close that arrives before its open creates the dispute already closed; the late open is ignored. Answer the dispute itself in the provider’s dashboard: Vendwick doesn’t submit evidence.
commerce/disputes/list and commerce/disputes/get list and show disputes. Each order’s Refunds section shows its disputes.
Webhook events to enable
| Provider | Refunds | Disputes |
|---|---|---|
| Stripe | charge.refunded, refund.updated, refund.failed, charge.refund.updated (the last three report a refund that failed later) | charge.dispute.created, charge.dispute.closed |
| PayPal | PAYMENT.CAPTURE.REFUNDED (PAYMENT.SALE.REFUNDED for subscriptions). PayPal sends no event for a refund that fails later: check pending PayPal refunds in its dashboard. | CUSTOMER.DISPUTE.CREATED, CUSTOMER.DISPUTE.RESOLVED, PAYMENT.CAPTURE.REVERSED |
| Razorpay | refund.processed, refund.failed | payment.dispute.created, payment.dispute.won, payment.dispute.lost, payment.dispute.closed |
| Polar | order.refunded, refund.created, refund.updated (a failed or canceled status takes the refund back) | Not supported (Polar handles chargebacks as merchant of record) |
| Mock (tests and demo) | payment.refunded, payment.partially_refunded, payment.refund_failed | dispute.created, dispute.won, dispute.lost |
The Payments page lists every event per provider.
What refunding asks of each provider
| Provider | Refunded object | Call | Retry safety |
|---|---|---|---|
| Stripe | The Checkout Session’s payment intent (a renewal: its invoice’s) | POST /v1/refunds | Idempotency-Key |
| PayPal | The order’s capture (a subscription payment: its sale) | POST /v2/payments/captures/{id}/refund or POST /v1/payments/sale/{id}/refund | PayPal-Request-Id |
| Razorpay | The Payment Link’s payment (a subscription: its first paid invoice’s payment) | POST /v1/payments/{id}/refund | Lists the payment’s refunds first |
| Polar | The Polar order (saved from the paid event) | POST /v1/refunds/ | Lists the order’s refunds first |
The refundable id is looked up once and saved on the checkout (paymentRefs). New checkouts save it from the paid event.
Not verified yet
PayPal chargebacks. PayPal can report one chargeback twice: a PAYMENT.CAPTURE.REVERSED (recorded as refund reversal-<capture>) and the dispute’s resolution, and the dispute’s REFUNDED outcome can also overlap a real refund event. Each is recorded under its own id, so until this is checked in the PayPal sandbox they may show as two refund rows. The money cannot be over-counted: every entry is capped at what is left of the amount paid, so the order is refunded at most once in total and the second report records nothing. If you see a dispute refund of 0 next to a reversal, that is the second report being capped.
Only a live sandbox can confirm these:
- every refund call’s exact request and response, and how each provider reports a refund that stays pending;
- Stripe: the refunds lookup by charge on your API version, and the
refund.failedandrefund.updatedpayloads; - PayPal: the subscription transactions lookup used to find the first sale, and the outcome codes of
CUSTOMER.DISPUTE.RESOLVED; - Razorpay: the dispute payloads and the refund list used for retries;
- Polar: the refund endpoint and its
refund.*events (including a failed status), and whether its refund amount excludes the tax Polar adds; - Razorpay: the
refund.failedpayload.