Get started
Refunds
Giving the money back — and what happens to the buyer's access, which is the part nobody asks about first.
Giving money back is one call. The hard question comes after: does the buyer still have the product? With a digital product they already downloaded it, and the link is still in their inbox.
This page answers both.
The refund is against the payment, not the invoice
An invoice can have several charge attempts and only one took the money. That is the one you refund.
const [payment] = await infi.payments.listForInvoice(invoiceId);
await infi.payments.refund(payment.id, { reason: "customer changed their mind" });Only a confirmed payment can be refunded, and where the money comes from
depends on your collection model. Under BYOP the refund runs on the provider
account you connected: it is your money leaving your account. Under Infi
Managed the charge was received on our structure, so the refund leaves from
there and is deducted from your payout — including future payouts, if the amount
was already paid out.
Full or partial: the amount decides the access
Omitting amount refunds everything. And here is the rule that matters:
| You refund | The buyer's download |
|---|---|
everything (amount omitted) | stops working |
part of it (amount: "5.00") | keeps working |
The logic: a full refund undoes the sale, so the capability the sale created has to die with it. A partial refund does not undo the sale — R$5 back on a R$100 guide is goodwill, not a cancellation — and cutting the file there would punish exactly the customer you just tried to please.
An amount above the total is treated as full, not refused.
When you want the opposite
// refund everything and let them keep the file
await infi.payments.refund(payment.id, { revokeAccess: false });
// cut access even when refunding only part
await infi.payments.refund(payment.id, { amount: "5.00", revokeAccess: true });revokeAccess: false is the policy of plenty of info-products: fighting costs
more than the file. Do not send the field if you do not mean to override —
the derivation above is the right behaviour in almost every case.
What the buyer sees afterwards
Their old link answers 410 Gone, with error_code and message in the
body:
{
"error_code": "download_revoked",
"message": "This download is no longer available: the purchase behind it was refunded."
}410 and not 404, on purpose. Whoever holds the token already proved they hold it, so "this existed" leaks nothing — and a 404 would look like a broken link, which turns a settled refund into a support ticket.
On your page, the grant comes back with revokedAt and stays in the list:
const [grant] = await infi.invoices.deliverable(invoiceId);
if (grant?.revokedAt) showRefundNotice();
else if (grant) showButton(grant.downloadUrl);It does not disappear, because a grant that vanished would look like fulfilment that never ran — two very different problems wearing the same face.
Reading what you refunded
status does not answer how much came back: a partial refund also leaves
the payment as refunded. What answers is refundedAmount.
const p = await infi.payments.get(payment.id);
p.status; // "refunded" — even with only R$5 returned
p.refundedAmount; // "5" — R$5 given back
await infi.payments.refunds(payment.id);
// [{ id, amount: "5.00", createdAt }]The values are decimal strings with no guarantee of two places: "5" and
"5.00" are the same amount. Do not compare the raw text to decide how much
came back.
refunds() returns the individual records, with amount, date and identifier.
The reason field is optional in the response.
Keep the reason in your own system
In sandbox, reason may not come back in the listing even when it was sent with
the refund. If you need it for support or audit, record the reason in your own
system alongside the payment id. Do not depend on that field in the response.
The invoice stays paid
On purpose. The invoice records that it was paid, because it was — and accounting does not erase a fact, it posts the opposite of it. The refund is its own record, with its own reversing entry in the ledger.
Practical consequence: if you sum paid invoices for your sales report, a
refunded sale counts in full. Subtract refundedAmount from the payments.
Prepaid credit does NOT come back
If the purchase was a credit package, the refund returns the money and does not remove the credits — they stay spendable. The wallet only has grant and consume entries, and a refund writes nothing in it.
Why it is not automatic: if the buyer already consumed 800 of 1000 credits there is no obvious answer — and writing the wrong one into a balance is worse than writing nothing. For now, if you sell credit, debit what is left by hand after refunding.
Webhook
A refund emits payment.refunded, with accessRevoked saying whether the
download fell:
{ "paymentId": "…", "invoiceId": "…", "amount": "100.00",
"currency": "BRL", "accessRevoked": true }accessRevoked is there because your own system almost always has access of its
own to cut — a subscription, a feature flag, a Discord role. Subscribe in
webhooks.
A chargeback is the same mechanic, without you
When the buyer disputes at their bank, the provider sends PAYMENT_REFUNDED or
the chargeback event and the same path runs: the payment becomes
charged_back, a reversing entry is posted, and access falls — the network takes
the full amount, so the amount-based derivation revokes. You do not have to do
anything, and you cannot prevent it.
The emitted event is payment.chargeback.
Refunding twice is safe
The reversal path only acts on a confirmed payment. A repeated provider
webhook, or a retry of your own, does not post to the ledger again and does not
rewrite when access fell — and the revocation date is preserved, because that is
what a dispute turns on.