handle-refunds
Online page: https://cassette.gg/agents/handle-refunds.md. Fetch it again before you act on it; don't work from a saved copy. Start of every task: https://cassette.gg/agents/guide.md.
The developer is the seller and their storefront contract holds the sales. Cassette never refunds a sale, holds no refund key, can't pause or change a storefront and doesn't decide disputes. Removing a game from Cassette only removes it from discovery. Never tell a buyer that Cassette will refund them or decide their case.
Who can do what:
| Action | Who | How |
|---|---|---|
| Self-refund inside the refund window | the buyer (the wallet that paid) | the game's paywall, or the Cassette library |
| Release a sale's payment after its window | anyone | release(uint256[]), relayed by your key (section 3) |
| Refund any sale, any time | the storefront owner (the human) | refund(uint256) in a sign link (section 4) |
| Allow or deny one wallet's self-refund | the owner | setRefundOverride in a sign link (section 5) |
| Change the refund window, cap or abuse-registry choice | the owner | setRefundPolicy in a sign link, with new documents (section 6) |
Your key is an operator: it can list items and release payments, never refund or change the refund rules. For every owner action you prepare a sign link (protocol.md §6): send it to the human, say what it does, and check the effect on chain afterwards.
0. Requirements
https://cassette.gg/agents/protocol.md: the shell variables ($API,$CFG,$RPC,$CHAIN,$FACTORY,$ROUTER,$USDC,$KEY,$ME), and a sign-in token$TOKEN(§4) for the sales list.SF=storefront.addressincassette.json;cast call $FACTORY "isStorefront(address)(bool)" $SF --rpc-url $RPCistrue(else there's no live storefront yet:https://cassette.gg/agents/publish-game.md, steps 5–7).sf_relay <storefront> <calldata>fromhttps://cassette.gg/agents/sell-items.md, section C (your key's gas-free call).- The storefront as indexed:
curl -s $API/v1/storefronts/$SF | jq '{owner, payout, operators, refundPolicy, referral, sellerRecord}'.
Functions (on $SF):
| Function | Who |
|---|---|
release(uint256[] saleIds) | anyone |
refund(uint256 saleId) | owner |
setRefundOverride(address buyer, uint8 value): 0 none (the policy), 1 always allow, 2 never allow | owner |
setRefundPolicy((uint32 window, uint16 maxSelfRefunds30d, address registry)) | owner |
releasable(uint256) returns (bool), owner() returns (address), refundOverride(address) returns (uint8), refundPolicy() returns ((uint32,uint16,address)) | view |
sale(uint256) returns ((uint256 listingId, address buyer, address recipient, uint64 qty, uint64 releaseAt, uint128 amount, uint128 fee, address feeTo, uint8 status)): status 1 escrowed, 2 released, 3 refunded | view |
1. What buyers get (the template: 6 hours, 3 per 30 days, registry off)
- Every paid sale is held in the storefront (escrow) until the refund window the storefront had at the time of the sale ends. Inside it, the buyer can refund themselves: the full price comes back from escrow and the licence or items are burned. A window of 0 pays the developer at once and allows no self-refund.
- The paywall says it under the Buy button ("Changed your mind? Refund yourself within 6 hours of buying.") and can show a "Refund until …" button while the player's purchase is refundable. The Cassette library shows the same option and the receipt.
- Self-refund is blocked, and the paywall says why before the buyer pays, when: the seller denied this wallet
(
developer-denied), the wallet reached the cap of self-refunds in 30 days across all storefronts from Cassette's factory (ledger-cap), the storefront uses the abuse registry and the wallet is restricted (registry-restricted), the window closed (window-closed), the tokens were moved or used (tokens-gone), it was a gift (gift: the payer isn't the recipient), or the listing is a limited edition (limited-supply). Blocked buyers can still buy, play, and ask the seller for a refund. - Check one sale:
curl -s $API/v1/sales/$SF/<saleId>/self-refund→{ok, reason, refundableUntil, restriction}.
2. See the sales
curl -s "$API/v1/storefronts/$SF/sales?state=escrowed&limit=50" -H "authorization: Bearer $TOKEN" \
| jq -c '.items[] | {saleId, title, buyer, usd: .total.usd, state, refundableUntil}'
# {"saleId":"3","title":"Blob Run","buyer":"0x3C44…","usd":"4.99","state":"escrowed","refundableUntil":"2026-10-30T22:15:00.000Z"}
- Owner or operator keys only (403
forbiddenotherwise).state:escrowed,releasedorrefunded(omit it for all); each item'sstateisescrowed,released,self-refundedorrefunded. Pages:limit≤ 200, thencursor=<nextCursor>. - One sale, no key:
GET $API/v1/sales/$SF/<saleId>. A receipt in the seller's name:GET $API/v1/receipts/$SF/<saleId>orGET $API/v1/receipts/tx/<txHash>. - The API is an index, seconds behind the chain. The chain decides:
cast call $SF "releasable(uint256)(bool)" <saleId>.
3. Release payments (after the window)
Anyone may release, so your key does it, gas-free and without the human. Pay out every releasable sale, up to 40 per call, e.g. once a day:
IDS=$(curl -s "$API/v1/storefronts/$SF/sales?state=escrowed&limit=200" -H "authorization: Bearer $TOKEN" | jq -r '.items[].saleId' \
| while read -r id; do [ "$(cast call $SF "releasable(uint256)(bool)" $id --rpc-url $RPC)" = true ] && echo $id; done \
| head -40 | paste -sd, -)
[ -n "$IDS" ] && sf_relay $SF "$(cast calldata "release(uint256[])" "[$IDS]")"
# {"txHash":"0x…","status":"mined"} (no output: nothing to release yet)
The money goes to the storefront's payout address (the owner's wallet unless they set another). One id that isn't
releasable makes the whole call revert (NotReleasable): check each with releasable as above. Cassette's referral fee
(0% for now) is taken at release, only on sales Cassette referred.
4. Refund a sale (the human signs)
Only the owner refunds. Find the saleId (section 2, or the buyer's receipt), confirm with the human that they want to
refund it, then prepare the link:
SALE=$(curl -s $API/v1/sales/$SF/3) # state, total.units, buyer
OWNER=$(cast call $SF "owner()(address)" --rpc-url $RPC)
jq -n --argjson chain $CHAIN --arg me $ME --arg owner $OWNER --arg sf $SF --arg usdc $USDC --argjson sale "$SALE" \
--arg data "$(cast calldata "refund(uint256)" 3)" '
{v:1, chainId:$chain, purpose:"refund", agent:$me, owner:$owner, calls:[{to:$sf, data:$data}],
summary:["Refund sale #\($sale.saleId): \($sale.total.usd) USDC to \($sale.buyer)"]}
+ if $sale.state == "released" then {approvals:[{token:$usdc, spender:$sf, amount:$sale.total.units}]} else {} end' > req.json
echo "https://cassette.gg/studio/sign#$(jq -cj . req.json | base64 -w0 | tr '+/' '-_' | tr -d '=')"
Send the human the link ("Open it, sign in with the wallet that owns your storefront, check the refund and confirm").
stateescrowed: the money is still in the storefront; nothing else is needed.statereleased: the sale was paid out, so the refund comes from the owner's own wallet: the link also approves exactly that amount of USDC to the storefront, and the wallet must hold it. A referral fee already paid isn't returned.refundedorself-refunded: nothing to do.- The refund burns what the buyer still holds of that sale. After they sign:
curl -s $API/v1/sales/$SF/3 | jq .stateis"refunded"(chain:sale(3)status 3).
5. One buyer's self-refund (the human signs)
DATA=$(cast calldata "setRefundOverride(address,uint8)" 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC 1) # 1 allow, 2 deny, 0 = the policy
Put it in a link like section 4 with purpose:"set-refund-override" (no approvals). Allow: this wallet can always
self-refund inside the window (skips the cap and the abuse registry). Deny: never. 0: back to the storefront's policy.
The developer's decision always beats the abuse registry. Each sale keeps the decision that applied when it was sold,
except that an allow set later still helps the buyer. Check: cast call $SF "refundOverride(address)(uint8)" <buyer>.
6. Change the refund policy (the human signs and adopts)
The refund policy document must say what the storefront does. Ask the human for the new values (window 0–336 hours, i.e. up to 14 days; cap 0–65535, 0 = none; abuse registry on or off), then:
- Update
seller.json, fill and hash the documents again and have the human adopt the new text (https://cassette.gg/agents/set-up-seller.md, steps 2–6 and 8): a new version and a new seller record hash. - Deploy the new documents and record (
https://cassette.gg/agents/host-game.md), then send the link at once: until it's signed, the served documents and the storefront disagree, and paywalls checking them may pause sales. One link with
purpose:"set-refund-policy"(at most 8 calls): the policy, the new seller record, and the new terms hash on each listing:REGISTRY=0x0000000000000000000000000000000000000000 # on: $(jq -r .deployment.abuseRegistry <<<"$CFG") POLICY=$(cast calldata "setRefundPolicy((uint32,uint16,address))" "($((24*3600)),3,$REGISTRY)") RECORD=$(cast calldata "setSellerRecord(string,bytes32)" $RECORD_URI $RECORD_HASH) # + updateListing(id, price, true, "", termsURI, termsHash) per listing (sell-items.md, section B)- After they sign:
cast call $SF "refundPolicy()((uint32,uint16,address))" --rpc-url $RPC→(86400 [8.64e4], 3, 0x0000000000000000000000000000000000000000).
A new policy applies to future sales only; earlier sales keep the terms they were sold under. BadRefundPolicy: a
window over 14 days, or a registry other than Cassette's (deployment.abuseRegistry) or none.
7. Cassette's abuse registry (opt-in)
- It's a list Cassette keeps of wallets that abused self-refund. It can only switch off self-refund, and only at storefronts whose owner opted in. A restricted wallet can still buy, play, keep what it bought and get refunds from the developer. It never affects the developer's own refunds.
- A rule only opens a case; a person reviews every case before a restriction. Restrictions last 30 days (90 for a repeat)
and never more than 90 days. Criteria:
https://cassette.gg/refund-abuse(JSON:GET https://cassette.gg/v1/abuse/criteria). - Check a wallet:
GET https://cassette.gg/v1/abuse/status/<address>→{restriction, selfRefunds30d}. - Appeals belong to the buyer and go to Cassette, not the developer: the buyer replies to the notice they were shown (or
POST https://cassette.gg/v1/abuse/appealssigned in), and someone not involved in the decision answers within 14 days. The developer can always decide for themselves instead: allow that wallet (section 5). - The self-refund cap is separate: plain arithmetic in the storefront, no Cassette decision.
8. Complaints Cassette passes on
Buyers contact the seller first (the contact in seller.json, shown on the paywall and the receipt). When a buyer
writes to Cassette instead, Cassette passes the message on to that contact and may follow up; it doesn't decide the case
and doesn't refund. The developer answers purchase questions, cancellations and refund requests within 14 days.
- Find the sale: the buyer's wallet in section 2, or
GET https://cassette.gg/v1/receipts/tx/<txHash>. - Show the human the facts (what was bought, when, amount,
state) and let them decide. Don't promise anything for them. - Business sellers: a buyer who didn't give the immediate-delivery consent keeps a 14-day right to cancel and must be
refunded within 14 days if they cancel. The consent is the lowest bit of the sale's
ref:REF=$(curl -s $API/v1/sales/$SF/3 | jq -r .ref); echo $(( 0x${REF:2:2} & 1 ))(1 = consented). - Refund: section 4. Allow a self-refund instead: section 5.
Limits on sponsored transactions
Cassette pays the network fee for storefront calls within published caps (GET https://cassette.gg/v1/wallet/rules).
- Batch. A sign link carries at most 8 calls. Owner refunds are one link each: for many refunds, tell the human how
many links to expect before you start. Your relays go one call per request (20 per minute, 500 per day per key);
one
releasepays out up to 40 sales. - Tell the human when the work is large (more than 8 changes): how many signatures, or how many of the day's relays.
- On a refusal, don't retry in a loop (429
rate_limited,relay_budget): wait once for the window the message names and send the same call again, or let the human send it from their own wallet, paying the network fee themselves.
Errors
| Code or revert | Meaning | Fix |
|---|---|---|
403 forbidden (sales list) | this key isn't the storefront's owner or an operator | sign in with the operator key; or the human adds it (setOperator, sign link) |
relay_reverted … NotReleasable | a sale still in its window, or already paid out or refunded | re-check releasable per id |
NotOwner (on the sign page) | the wallet signing isn't the storefront's owner | the human signs with the wallet that owns it (owner()) |
AlreadyRefunded | refunded already | nothing to do |
BadRefundPolicy | window over 336 hours, or a registry other than Cassette's | section 6 |
not_found (/v1/sales/…) | not indexed yet, or no such sale | wait a few seconds; the chain: sale(saleId) |
NotStorefront | that address isn't a storefront from Cassette's factory | check storefront.address in cassette.json |
Code in this document (and paywall.js) is MIT-licensed: SPDX-License-Identifier: MIT, Copyright (c) 2026 Delegas
Labs, S.L. Copy and adapt it freely.