RefundHalt

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:

  1. The store opens a refund case and RefundHalt receives it.
  2. RefundHalt calls one endpoint on your server with the buyer's IDs.
  3. Your endpoint replies with that user's real usage numbers.
  4. 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 typeField in your replyMeasured asRule
Images generatedimages_generatedQuantityfully used at 40
Course completedcourse_completedPercentage0 to 100
Ebook downloadedebook_downloadedYes or notrue 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_id is 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.
  • type uses 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_plan is an optional free text label like yearly or weekly; it names the plan in our records and in the dispute statement.
  • ip_address is 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": true marks 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.

On this page