Usage data
Host one webhook that RefundHalt calls when a refund case opens. Your numbers become the evidence.
When a customer asks the store for a refund, the store gives you a short window to answer with consumption evidence: Apple's CONSUMPTION_REQUEST (12 hours, Send Consumption Information) and Google's chargeback review (24 hours, orders.reviewrefund). RefundHalt always answers in time. The only question is the quality of the evidence.
It works like a webhook, just in the direction we need: instead of you calling us, we call you the moment a refund comes in, and your reply is the answer. With usage data connected, the flow is:
- The store opens a refund case and RefundHalt receives it.
- RefundHalt POSTs to one webhook on your server with the buyer's IDs.
- Your webhook replies with that user's real usage numbers.
- RefundHalt converts the numbers into the store's evidence format, writes the dispute statement, and answers inside the deadline. If any metric reached its limit, we can decline the refund outright.
Everything is configured visually in the dashboard (app page, under Refund handling, "Set up usage data"), including a generated prompt you can paste into a coding agent to build the webhook for you.
1. Tag purchases with your user ID
This makes every refund case arrive with your own user ID attached.
iOS (StoreKit 2): pass a UUID at purchase time. Apple echoes it back in every notification.
let result = try await product.purchase(options: [
.appAccountToken(user.uuid) // must be a UUID
])Android (Play Billing): pass a one-way hash of your user ID. Google echoes it back in purchase records and chargeback notifications.
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productParams)
.setObfuscatedAccountId(userId.sha256Hex()) // max 64 chars
.build()Never put raw emails or other PII in the obfuscated ID. Google blocks purchases that contain cleartext PII.
2. Define your metrics
In the dashboard you name what your users consume, in plain words, and choose how each metric is measured. Consumption is not always a count, so three measures are supported:
| You type | Field in your reply | Measured as | Rule |
|---|---|---|---|
| Images generated | images_generated | Quantity | fully used at 40 |
| Course completed | course_completed | Percentage | 0 to 100 |
| Ebook downloaded | ebook_downloaded | Yes or no | true or false |
Each metric can also carry its own decline rule (decline when the quantity or percentage reaches a threshold, or when a yes/no metric is yes), and a combinator says how many rules must match: any one, all, or at least N.
These names also feed the written dispute statement, so pick real product language ("Images generated", not "events").
3. The webhook contract
The request is deliberately minimal: one user identifier plus the platform.
{
"type": "usage.lookup",
"test": false,
"platform": "ios",
"user_id": "b48c1f22-…"
}user_idis the value your app attached at purchase in step 1: the appAccountToken UUID on iOS, the obfuscated account hash on Android (rarely, your own user id when the purchase was linked through the RefundHalt API). Look the user up by it.typeuses dot naming (usage.lookup), the same convention as our webhook event types.
Your server replies HTTP 200. Every field in the reply is optional; return what you have:
{
"images_generated": 27,
"course_completed": 63,
"ebook_downloaded": true,
"subscription_plan": "yearly",
"ip_address": "203.0.113.7"
}Reply rules:
- Metric fields follow their measure: quantity and percentage are numbers, yes/no is a JSON boolean.
subscription_planis an optional free text label likeyearlyorweekly; it names the plan in our records and in the dispute statement.ip_addressis optional and only used for Google cases: we geolocate it and attach location evidence. Apple has no field for it, so iOS apps can skip it entirely.- Unknown user: reply 404, or 200 with
{"unknown_user": true}. We fall back to time-based estimates. "test": truemarks the dashboard's connectivity check; reply with realistic sample values.
Response time and reliability
This is a synchronous webhook, so your reply is the answer. The standard is the same as any transactional webhook (Stripe, GitHub, and the like): reply fast, keep the handler small, and do any heavy work out of band.
- Reply within 10 seconds. That is our per-call timeout. In practice a
single indexed lookup by
user_idreturns in a few milliseconds, so aim well under the limit. Do not run slow aggregations in the request path. - If your webhook is slow, erroring, or down, we retry with backoff on
the job queue. Timeouts,
429, and5xxare treated as transient. - A refund is never blocked on your server. We keep retrying only while there is comfortable time before the store deadline (Apple 12 hours, Google 24 hours, minus a safety margin). After that we stop waiting and answer with a time-based estimate, so the case is always answered in time.
- You do not need to be highly available. A brief outage costs you evidence quality on the few cases that land during it, never a missed deadline.
Usage key
The dashboard shows your key the moment you open the Usage data step. It is a
per-app verification key (prefix rhu_): we send it so your server can confirm
a call is really from RefundHalt. It is separate from the account API keys on
the API keys page, which authenticate your calls to us. Put it in your server's
environment:
REFUNDHALT_USAGE_KEY=rhu_…Every call from RefundHalt carries it as a bearer token:
Authorization: Bearer rhu_…Your server makes one check and replies 401 on mismatch:
import os
if request.headers.get("Authorization") != f"Bearer {os.environ['REFUNDHALT_USAGE_KEY']}":
return Response(status_code=401)If the key ever leaks, regenerate it in the dashboard (Usage data → Usage key
→ Regenerate) and update REFUNDHALT_USAGE_KEY on your server. The old key
stops working immediately.
4. What happens with the numbers
Each metric becomes a consumption percentage: quantity maps to
value / fully_used_at, percentage is used directly, and yes/no maps to
100% or 0%. The highest one is what we report (capped at 100%). When your
decline rules match per the combinator (any one, all, or at least N), we
ask the store to decline; your explicit policy rules still take priority.
From there the two stores accept very different evidence, and we speak
each store's language:
On Apple
Apple's consumption payload is exactly five fields, numbers and flags only. There is no field for text, usage events, IPs, or locations.
- Refund preference: decline when a limit is reached, otherwise your policy default.
- Consumption percentage: Apple accepts it for consumables, one-time purchases, and non-renewing subscriptions only. For auto-renewing subscriptions (weekly, monthly, yearly) Apple calculates it itself from elapsed time and rejects supplied values, so there your metrics drive only the preference.
- Delivered and free-sample flags: from your refund policy settings.
On Google
Google's chargeback review accepts rich evidence alongside the verdict:
- Up to 10 usage events with timestamps, your IP data, and coarse locations from iplocate.
- A dispute statement built from your named metrics, the purchase, and the IP location, for example "Images generated: 42 of the 40 included". Two modes, chosen in the dashboard: Standard template (deterministic) or AI polished (same facts, rewritten for flow by Gemini; any problem falls back to the template). A validator guarantees the AI version keeps every number from the draft.
Privacy
Send only opaque IDs and numbers, never PII. Apple requires informed
customer consent before consumption data is shared (the
customer_consented flag in your refund policy);
when consent is off we do not respond to consumption requests at all, per
Apple's guidance.