●FUNDING — Rork raises a $15M seed led by Left Lane Capital●RORK MAX — Rork Max generates native Swift apps instead of React Native●PLATFORM — It targets iPhone, iPad, Watch, and Vision Pro, reaching Live Activities and Core ML●GROWTH — Traffic keeps climbing at 743K monthly visits and 85% growth●TEST — The Companion app lets you test on a real device without a paid Apple Developer account●STACK — Built on React Native and Expo for true native experiences, not web wrappers●FUNDING — Rork raises a $15M seed led by Left Lane Capital●RORK MAX — Rork Max generates native Swift apps instead of React Native●PLATFORM — It targets iPhone, iPad, Watch, and Vision Pro, reaching Live Activities and Core ML●GROWTH — Traffic keeps climbing at 743K monthly visits and 85% growth●TEST — The Companion app lets you test on a real device without a paid Apple Developer account●STACK — Built on React Native and Expo for true native experiences, not web wrappers
Answering a Refund Request Within 12 Hours: Handling CONSUMPTION_REQUEST in Cloudflare Workers
How to receive the App Store CONSUMPTION_REQUEST notification for your Rork subscription app in Cloudflare Workers and respond to the Send Consumption Information API within 12 hours — with field-by-field mapping and the operational judgment behind it.
I first noticed it while scanning subscription cancellation reasons: a handful of transactions where a refund went through right after the content had clearly been used up. This was around the time the revenue from my Rork-built apps had finally become predictable on a monthly basis. Refunds are a user's right, and I have no interest in fighting them. But when refunds keep clearing automatically even after someone has obviously consumed everything the purchase offered, it quietly eats into indie revenue.
That was when I first took CONSUMPTION_REQUEST seriously. When a user requests a refund through the App Store, Apple asks your app, "How much did this user consume this purchase?" If you answer honestly within 12 hours, your answer becomes part of how Apple decides the refund. There's no penalty for staying silent — but staying silent means giving up one of the few levers you have against "refund-after-full-use."
This guide builds that response flow entirely inside Cloudflare Workers, covering both how to fill the fields and the judgment behind them. I'll assume you already have a receiver for App Store Server Notifications V2; if you don't, read the self-hosted App Store Server Notifications V2 setup first, and you'll be able to bolt this Worker straight onto it.
When CONSUMPTION_REQUEST arrives, and the deadline
This notification only arrives when several conditions line up. First, you must have enabled Consumption Request delivery under "App Information" in App Store Connect. Second, the user must have filed a refund, and the transaction must be consumable, non-consumable, or a subscription. Auto-renewal transactions are included.
The deadline is 12 hours from the notification's signedDate. Miss it and your response is rejected. If a Cloudflare Worker does heavy synchronous work before returning a receipt response, Apple may time out, so the safe design is to return 200 immediately and push the actual API call to the background. Twelve hours is generous, so handling it behind the receive handler with waitUntil is more than enough.
Item
Detail
Notification type
CONSUMPTION_REQUEST
Response API
PUT /inApps/v1/transactions/consumption/{transactionId}
Deadline
Within 12 hours of signedDate
Hard requirement
Being able to send customerConsented as true
If you don't respond
No penalty, but you lose one refund-deterrent signal
Without customerConsented, your data is ignored
This is the pitfall to internalize before anything else. The Send Consumption Information request has a boolean called customerConsented, and unless it is true, Apple ignores every other field no matter how accurately you fill it in. It is a declaration that "the customer has consented to provide consumption data to Apple."
So before you implement anything, you need to have collected that consent somewhere — in your terms of service, your privacy policy, or onboarding — with language like "we may share usage information with Apple to support fair handling of refund requests." In my own wallpaper and relaxation apps, I placed a link to that clause in the footer of the subscription purchase screen, and treat agreement to the terms as the basis for setting customerConsented. For users from whom I haven't obtained consent, I think it's more honest to skip the response entirely rather than send customerConsented: false.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Build a minimal Cloudflare Workers setup that receives the CONSUMPTION_REQUEST notification and answers the Send Consumption Information API within the 12-hour window — with working code
✦Get a mapping table for filling all twelve fields (consumptionStatus, deliveryStatus, refundPreference, and more) from the subscription and usage logs you already keep in KV
✦Define, as an operational rule, where to draw the line so honest data curbs abusive refunds without blocking refunds for users who genuinely need them
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If you already have a Worker receiving ASSN V2, you only add one branch on notificationType. Assuming signature verification (the JWS x5c chain check) is already done in your receiver, here we focus on the branch and pulling out the transaction ID.
// Cloudflare Worker — ASSN V2 receive handler (excerpt)export default { async fetch(request, env, ctx) { if (request.method !== "POST") return new Response("ok", { status: 200 }); const body = await request.json(); // verifyAndDecodeJWS lives in your receiver (x5c chain check + payload decode) const payload = await verifyAndDecodeJWS(body.signedPayload, env); if (payload.notificationType === "CONSUMPTION_REQUEST") { const tx = await verifyAndDecodeJWS( payload.data.signedTransactionInfo, env ); // Acknowledge immediately; do the real work in the background (12h budget) ctx.waitUntil(handleConsumptionRequest(tx, payload, env)); } // Existing handling for other notificationTypes stays as-is return new Response("ok", { status: 200 }); },};
The key is ctx.waitUntil. Doing KV lookups and a round trip to the App Store Server API before the receipt response risks Apple's timeout. Acknowledge the receipt instantly and move the decision and the response to the background.
Filling the twelve fields from your own logs
The Send Consumption Information request body has twelve fields. Most are "enumerations from 0 to 7" that you can derive mechanically from the subscription history and usage logs you keep in KV. This is the heart of the implementation.
Gather the derivation logic into a function. Use appAccountToken to look up your own user record, then compute each band from the usage log.
async function buildConsumptionBody(tx, env) { const accountToken = tx.appAccountToken; if (!accountToken) return null; // No binding = thin evidence; skip const user = await env.SUBS.get(`user:${accountToken}`, "json"); if (!user || !user.consentedToShare) return null; // No consent = don't send return { customerConsented: true, platform: 1, sampleContentProvided: Boolean(user.usedFreeTrial), appAccountToken: accountToken, consumptionStatus: deriveConsumption(user), // 0–3 deliveryStatus: user.hadDeliveryIssue ? 1 : 0, accountTenure: tenureBucket(user.firstSeenAt), // 0–7 playTime: playTimeBucket(user.totalPlaySeconds), lifetimeDollarsPurchased: dollarsBucket(user.lifetimePurchasedCents), lifetimeDollarsRefunded: dollarsBucket(user.lifetimeRefundedCents), userStatus: user.banned ? 3 : 1, refundPreference: decideRefundPreference(user), // your policy };}// Degree of consumption: decided by how much of the feature was usedfunction deriveConsumption(user) { if (user.featureUseCount === 0) return 1; // not consumed if (user.totalPlaySeconds > 3600) return 3; // fully used return 2; // partially consumed}// Spend band (map a USD-equivalent cent value to 0–7)function dollarsBucket(cents) { const usd = (cents || 0) / 100; if (usd <= 0) return 0; if (usd < 50) return 1; if (usd < 100) return 2; if (usd < 500) return 3; if (usd < 1000) return 4; if (usd < 2000) return 5; if (usd < 3000) return 6; return 7;}
Define the accountTenure and playTime bands the same way, following Apple's enum definitions. Set the boundaries to match your app's reality — but once you decide them, keep them consistent across all fields, which pays off when you re-read your logs later.
Don't set refundPreference to "2" automatically
This is the core of the operational judgment. refundPreference tells Apple whether you, as the developer, lean toward granting the refund: 0=no preference, 1=lean toward granting, 2=lean toward declining, 3=no preference either way.
Tipping this mechanically to 2 (lean toward declining) just because you want fewer refunds is unwise. You end up blocking refunds for users who are genuinely stuck with a defect, and your review stars pay for it. I use a simple rule: only set 2 when there's evidence the content was fully used (consumptionStatus === 3), delivery was fine (deliveryStatus === 0), and the account isn't a habitual refunder. If there's an issue report, usage is minimal, or the purchase was just made, I use 3 (no preference) and leave the call to Apple.
function decideRefundPreference(user) { // Defect or very recent purchase -> stay neutral if (user.hadDeliveryIssue) return 3; const minutesSincePurchase = (Date.now() - user.lastPurchaseAt) / 60000; if (minutesSincePurchase < 15) return 3; // Fully used + clean delivery + not a habitual refunder -> lean decline if (deriveConsumption(user) === 3 && !user.frequentRefunder) { return 2; } // Otherwise express nothing (leave it to Apple) return 0;}
I'd suggest writing this "only emit 2 under narrow conditions" line not as a code comment but in an operational doc. If your future self, six months from now, can remember why a 2 is emitted here, you'll find it much easier to retune the balance between review blowback and revenue protection.
PUT to the App Store Server API
Once the body is built, send it to the consumption endpoint of the App Store Server API. Authentication is the same ES256 JWT as your other Server API calls. If your receiver already has a JWT generator, reuse it.
async function handleConsumptionRequest(tx, payload, env) { const consumption = await buildConsumptionBody(tx, env); if (!consumption) return; // No consent / no binding -> don't respond const jwt = await makeAppStoreJWT(env); // existing ES256 JWT generator const base = env.USE_SANDBOX ? "https://api.storekit-sandbox.itunes.apple.com" : "https://api.storekit.itunes.apple.com"; const res = await fetch( `${base}/inApps/v1/transactions/consumption/${tx.transactionId}`, { method: "PUT", headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, body: JSON.stringify(consumption), } ); // Success returns 202 Accepted (no body) if (res.status !== 202) { console.error("consumption PUT failed", res.status, await res.text()); // Queue failures in KV and retry within 12h via a Cron Trigger await env.SUBS.put( `retry:consumption:${tx.transactionId}`, JSON.stringify({ consumption, at: Date.now() }), { expirationTtl: 60 * 60 * 12 } ); }}
Success returns 202 Accepted with no body. If you only check res.ok expecting 200, you'll miss it, so treat 202 explicitly as success. On failure, queue it in KV and retry via a Cron Trigger, so a transient Apple hiccup or a Worker blip can still be recovered within the 12-hour budget.
Actually firing CONSUMPTION_REQUEST in Sandbox
People assume this can only be verified in production, but you can fire it in Sandbox. Buy a subscription in the Sandbox environment, request a refund from the tester settings in App Store Connect, and CONSUMPTION_REQUEST flows to the USE_SANDBOX endpoint (api.storekit-sandbox.itunes.apple.com) and your Sandbox notification URL.
At first I had only registered the production notification URL and had forgotten to set the separate Sandbox notification URL in App Store Connect, so my test events landed nowhere and I lost half a day. ASSN V2 has separate fields to register the production and Sandbox notification URLs. Once you add the USE_SANDBOX branch, double-check that both notification URLs are registered. Putting a single arrival log line on notificationType === "CONSUMPTION_REQUEST" in your Worker lets you tell apart "it fired but wasn't processed" from "it never arrived."
Because a refund is a user's legitimate right, the goal of this mechanism isn't to "reduce refunds" as such. It quietly attaches the facts only to claims made after full use, and doesn't get in the way of refunds for people who are genuinely stuck. Running it within the bounds of that restraint is, for an app you intend to keep alive for years, ultimately the most profitable choice. Start by firing CONSUMPTION_REQUEST once in Sandbox with your own hands and confirming it reaches your Worker's logs.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.