Usage data
Host one endpoint 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.
With usage data connected, the flow is:
- The store opens a refund case and RefundHalt receives it.
- RefundHalt calls one endpoint on your server with the buyer's IDs.
- Your endpoint 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, step 4, "Usage data"), including a generated prompt you can paste into a coding agent to build the endpoint 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 endpoint 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.- Reply within 10 seconds. Transient failures are retried with backoff, and a case is never lost to a down endpoint: near the deadline we answer with estimates, and the deadline fallback always applies.
Authentication
A simple API key. The dashboard shows your key the moment you open the Usage data step; put it in your server's environment:
REFUNDHALT_USAGE_SECRET=uhsec_…Every call from RefundHalt carries it as a bearer token:
Authorization: Bearer uhsec_…Your server makes one check and replies 401 on mismatch:
import os
if request.headers.get("Authorization") != f"Bearer {os.environ['REFUNDHALT_USAGE_SECRET']}":
return Response(status_code=401)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.
Alternative: push usage events
If you would rather push data to us than host an endpoint, the ingest API
still exists: POST /v1/apps/{app_id}/ingest/usage (batched events) and
POST /v1/apps/{app_id}/ingest/purchases (purchase linking), authenticated
with an API key. Pushed events feed the same decision engine
through configurable usage tiers, and stored events also serve as Google
evidence. The endpoint is the recommended path: it needs no streaming and
your numbers are always current.
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.