Review Refund API deep dive: schema, deadlines, evidence
A field-by-field engineering walkthrough of Google Play's chargeback review event, one-shot response API, evidence model, and failure boundaries.
Google Play's Review Refund API is not a general refund endpoint. It is a one-shot response channel for a specific chargeback review event, with a 24-hour clock and a deliberately narrow evidence schema. That combination makes the wire format easy; making the system correct under redelivery, partial failure, and missing historical evidence is the real engineering job.
Google introduced PendingRefundReviewNotification and orders.reviewrefund in the July 2026 Google Play Developer API release. The documented flow is simple: receive the notification, evaluate the request, and submit a refund preference plus relevant purchase-usage evidence within 24 hours. Google records the first API call for that notification and ignores later calls, even though those later calls still return OK (chargeback guidance).
That last sentence changes the architecture. A successful retry is not proof that the retry's body was accepted. A corrected payload cannot replace an earlier one. The first packet must therefore be complete, deterministic, and treated as irreversible.
The event that opens the review
Review Refund starts with a Real-time Developer Notification (RTDN), not with polling and not with a purchase token. Google Play publishes a message to Cloud Pub/Sub. In the default wrapped push format, the developer notification is base64-encoded in message.data; the Pub/Sub wrapper also carries a messageId (RTDN reference).
After decoding message.data, the relevant payload looks like this:
{
"version": "1.0",
"packageName": "com.example.game",
"eventTimeMillis": "1784023200000",
"pendingRefundReviewNotification": {
"version": "1.0",
"pendingRefundToken": "example-pending-refund-token",
"orderId": "GPA.1234-5678-9012-34567",
"refundReason": 7,
"obfuscatedAccountId": "account-7d9d",
"obfuscatedProfileId": "profile-a32f"
}
}
The nested pendingRefundReviewNotification is mutually exclusive with the subscription, one-time product, voided purchase, and test notification members of the envelope. Google currently documents only CHARGEBACK (7) as a supported pending-review reason, while explicitly telling clients to tolerate future reasons (pending refund review schema). Treat the integer as an evolving enum: validate the shape, record unknown values, and do not silently reinterpret them.
The identifiers are not interchangeable
| Identifier | Scope | What it is for |
|---|---|---|
Pub/Sub messageId | One published message | Transport deduplication. Google recommends checking it before processing duplicate notifications. |
packageName | Android application | Routes the event to the correct app and becomes the first Review Refund path parameter. |
orderId | Purchase transaction | Identifies the reviewed transaction and becomes the second path parameter. |
pendingRefundToken | Pending refund review | Uniquely identifies the review request and must be copied into the request body. |
obfuscatedAccountId / obfuscatedProfileId | Developer-defined account/profile | Correlates the Play purchase with the app's user model when those values were supplied at purchase time. |
Do not substitute a purchase token for orderId. The pending-review event does not contain a purchase token, and orders.reviewrefund is addressed by package name and order ID. This distinction matters for subscriptions: Google documents a new order ID for every auto-renewing subscription transaction, while a purchase token represents the longer-lived subscription purchase relationship (voided purchase notification).
The pending refund token is equally specific. It is neither an OAuth credential nor a durable purchase identifier. Persist it as an opaque, single-purpose identifier tied to the package and order in the notification. A uniqueness constraint on this token gives you semantic deduplication even if the same logical event reaches you through a different Pub/Sub delivery record.
The Review Refund request
The call is server-to-server and requires the androidpublisher OAuth scope:
POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/orders/{orderId}:reviewrefund
The success response has an empty body. The method reference defines three required body fields and two optional evidence fields.
| Field | Required | Contract |
|---|---|---|
pendingRefundToken | Yes | Exact token from the pending-review notification. |
sampleContentProvided | Yes | Whether a free sample, trial, or functionality information was provided before purchase. |
refundPreference | Yes | DECLINE, APPROVE, or NEUTRAL. |
consumptionPercentageMilliunits | No | Integer from 0 through 100000; 45200 means 45.2%. Paid apps may omit it. |
consumptionUsageEvents | No | Up to 1,000 structured usage events; larger arrays are rejected. |
refundPreference is a recommendation, not a command or outcome. APPROVE means the developer prefers a full refund, DECLINE means the developer prefers rejection, and NEUTRAL expresses no preference. The guidance says Google considers the preference and usage evidence when evaluating the chargeback; it does not promise that the preference determines the result.
sampleContentProvided is required even when the answer is false. Keep it as app or offer configuration rather than guessing it during a live review. If its value depends on the exact offer, model that granularity before the event arrives.
What counts as evidence on the wire
Review Refund does not accept PDFs, screenshots, arbitrary attachments, or a free-form representment document. Its evidence surface is a cumulative percentage plus an array of ConsumptionUsageEvent objects. Every event member is optional, but the available members are fixed:
| Event field | Meaning and limit |
|---|---|
obfuscatedAccountId | App account identifier supplied through Play Billing. |
obfuscatedProfileId | App profile identifier supplied through Play Billing. |
consumptionTime | RFC 3339 time when content was consumed, used, downloaded, opened, or streamed. |
ipAddress | IP address from which consumption occurred. |
consumptionItemDescription | Free-form description of the consumed item, maximum 5,000 characters. |
location | Coarse location; regionCode is required when this object is present. |
A complete illustrative request is therefore compact:
{
"pendingRefundToken": "example-pending-refund-token",
"sampleContentProvided": true,
"refundPreference": "DECLINE",
"consumptionPercentageMilliunits": 45200,
"consumptionUsageEvents": [
{
"obfuscatedAccountId": "account-7d9d",
"obfuscatedProfileId": "profile-a32f",
"consumptionTime": "2026-07-13T18:42:11Z",
"ipAddress": "203.0.113.42",
"consumptionItemDescription": "Granted 500 gems; 226 were spent in 14 gameplay actions.",
"location": {
"regionCode": "NL",
"administrativeArea": "North Holland",
"locality": "Amsterdam"
}
}
]
}
The schema's flexibility should not be mistaken for permission to synthesize facts. consumptionTime is an event time, not the time a dispute worker queried a database. An IP address should be the address observed for that consumption event. Google says a coarse location's CLDR regionCode is never inferred by the API and the developer must ensure it is correct. If a field cannot be supported by a recorded event, omit it.
The percentage needs the same discipline. For a 500-unit virtual-currency purchase with 226 units spent, round(226 / 500 × 100000) produces 45200. A subscription, unlock, or mixed bundle may not have an honest denominator. The field is optional; omission is better than inventing a precise-looking number with undefined semantics.
Split the Android and backend responsibilities
The Android client should not call Review Refund. The method uses publisher credentials and belongs on a secure backend. The useful client-side responsibility happens earlier: set stable, app-scoped obfuscatedAccountId and, where applicable, obfuscatedProfileId on the billing flow. Google can then return those identifiers in the pending-review notification and accepts them again on usage events (BillingFlowParams builder).
A practical boundary has four parts:
- Purchase service: persist
packageName,orderId, product/offer context, and the obfuscated account linkage when the purchase is verified. - Entitlement or gameplay services: append delivery, consumption, and session events with event time, optional network/location facts, and an idempotency key.
- RTDN consumer: authenticate delivery, decode the envelope, deduplicate it, bind the package to a tenant, and open one review record keyed by
pendingRefundToken. - Submission worker: assemble a frozen packet, reserve the one-shot operation, obtain an access token, call Review Refund, and finalize without allowing a second worker to race it.
That is also the boundary used in Dispute.app's current integration. Evidence can arrive as a single event at /api/v1/evidence/{packageName}/{orderId}/events or in batches at /api/v1/evidence/events:batch; immutable order context is stored separately. The packet assembler admits actual consumption and active-session events, sorts newest first, caps the list at 1,000, truncates descriptions at 5,000 characters, and derives milliunits only when both consumed units and a positive total-unit context exist. Those are implementation policies around Google's contract, not additional Google fields.
Deadlines: distinguish the contract from your safety clock
Google's wording is to respond within 24 hours of receiving the notification (chargeback guidance). The RTDN envelope separately defines eventTimeMillis as the time the event occurred. The docs do not expose a server-computed deadlineAt field and do not state that eventTimeMillis + 24h is the formal deadline.
For operations, using eventTimeMillis + 24h as a conservative internal deadline is reasonable: queue delay or an outage should not convince your system that it has more time than it really does. Label that as your safety policy, not as a field promised by Google. Record the event time, Pub/Sub publish time when present, HTTP receipt time, and derived internal deadline so incidents can be reconstructed.
Do not schedule the first attempt at hour 23. Evidence should normally be assembled and submitted immediately. Warning and forced-submit thresholds are fallback controls, not the primary workflow. The current Dispute.app clock warns at event time plus 18 hours and force-submits at plus 22 hours, leaving a final buffer before its conservative 24-hour deadline.
First-call-wins changes idempotency
There are two duplicate problems, and solving only one is insufficient.
First, Pub/Sub delivery can repeat. Google recommends deduplicating RTDNs by messageId, and Pub/Sub's exactly-once feature does not apply to push subscriptions (exactly-once delivery limitations). Store a receipt before doing work. Also enforce uniqueness on pendingRefundToken, because transport identifiers and business identifiers solve different races.
Second, Review Refund itself is first-call-wins. Subsequent calls return OK but are ignored. This is unusual semantic idempotency: repeating the operation may be harmless, but the response cannot tell you which body Google retained.
A durable state machine should make the irreversible edge explicit:
open -> assembled -> submitting -> submitted
|
+-> unknown
Persist the exact packet and commit submitting before the HTTP call. Workers should lock or conditionally update only open/assembled rows with no prior submission. If the process fails before sending, returning to assembled is safe. If it times out after sending, receives a 5xx after the request may have been processed, or dies after Google accepts the call but before local finalization, the outcome is ambiguous. Blindly rebuilding and retrying can only hide which packet won.
Our implementation retries failures known to occur before the provider call and explicit rate limiting (429). Other provider 4xx responses are treated as terminal. Network failures and 5xx responses after call initiation move the review to unknown for operator inspection rather than pretending the operation is repeatable. A stale submitting reservation is quarantined for the same reason.
Success is not the dispute outcome
A 2xx response means the Review Refund submission call succeeded; the response body is empty. It does not say whether Google or the issuing bank accepted the recommendation, and it returns no submission resource to query later.
Keep submission state separate from dispute outcome. A later VoidedPurchaseNotification or matching entry from the Voided Purchases API establishes that the purchase was canceled, refunded, or charged back. It is an outcome-side signal, not a synchronous response from Review Refund. Conversely, the Review Refund documentation does not define a dedicated "chargeback won" notification, so absence of an immediate void is not an instant win event.
This boundary also prevents a dangerous endpoint mix-up. orders.refund actively refunds an order; orders.reviewrefund submits a preference and usage evidence for a pending chargeback review. They share an orders namespace and an orderId, but they do not perform the same action.
A production-readiness checklist
Before enabling automated submission, verify the whole path rather than only the final POST:
- RTDN is configured, authenticated, and proven with Google's test notification.
- The decoder tolerates additive fields and records unknown refund reasons.
- Pub/Sub receipts and pending refund tokens have separate uniqueness guarantees.
- Package ownership is checked before tenant data or publisher credentials are selected.
- Order IDs and obfuscated account/profile IDs are captured at purchase time.
- Usage events are timestamped and idempotent before a dispute exists.
- Percentage calculations have an explicit product-specific denominator.
- Packet assembly is deterministic, bounded to 1,000 events, and frozen before submission.
- Only one worker can reserve the provider call; ambiguous outcomes cannot auto-retry.
- Submission success, review expiry, and eventual void outcome are distinct states.
The Review Refund API is small by design. The credible implementation is also small at its outermost boundary: one notification type, one POST, five request fields. The sophistication belongs behind that boundary—in evidence provenance, identity linkage, deadline accounting, and the refusal to turn an ambiguous network result into a second irreversible call.