0075: Prism (Targeted API) — Expose In-Progress and Completed Player Offers
STATUS
Accepted
CONTEXT
The Prism offers GraphQL query (POST /v1/offers) returns only offers a player has not yet started. To do this it calls Player API for the player's transactions and uses them in a SuppressOffers pipe to remove any offer the player has already engaged with or completed.
Product now wants the API to also surface a player's in-progress and completed offers, indicating which goals within each offer are complete. This must serve both external publishers and the api project, which dogfoods Prism for its offerwall.
Constraints
- No breaking changes to the existing
offerscontract. Current publisher integrations expect not-started offers only, and their queries/response shape must remain byte-for-byte unchanged unless they opt in. - Reuse existing service boundaries (Player API for engagement data, Offer API for offer data). Prism is a read/aggregation layer and should not absorb
api's offerwall/support/payment logic. - Keep Prism's existing philosophy: return raw offer + engagement data and let consumers format/present.
Key discovery findings (cross-service investigation)
- The data already exists and is already fetched. Player API's transactions endpoint returns a computed
status(started/in-progress/completed/expired) per transaction and acompletedAttimestamp per goal. Prism already requestsstatus => ['completed','in-progress']and currently discards it inSuppressOffers. Join keys: transactionofferId⟷ offerid; goalofferGoalId⟷ offer goalid(both nullable on legacy records; fall back tocampaignId/bundleId). - In-progress/completed offers are transaction-driven, not targeting-driven. Offers a player has engaged with may no longer appear in the targeted
offersresponse (targeting, eligibility, disable, delete). So the authoritative source for "my offers" is the player's transaction list, resolved back to offer data — an inversion of the current "fetch targeted set → annotate" flow. - The "My Offers" payload is a different shape. A sample of the existing offerwall My Offers (
clicks) response carries engagement/support/device fields a browse offer never has. A GraphQL field returns exactly one type, so this cannot ride on theoffersOffertype without polluting it with ~15 always-null fields. - Most of that sample is
api-local, not Offer/Player API — assembled fromapi's ownCampaign/creative/Offerwallmodels,PaymentTermsService,support-requests.*config, a deprecated Player API clicks endpoint (platform/gaid/idfa), andapipresentation formatting. Prism cannot (and should not) reproduce it wholesale. - Disabled/deleted offers remain completable; archived offers do not. In
api, the click/start path gates oncampaign_status == 'active', but the completion path performs no campaign-state check — completion is driven purely by an existing Player API transaction. So an offer started while active stays completable after later pause/delete. Offer API "archived" (archived_at, a post-soft-delete state that exports to cold storage before hard-prune) is genuinely unavailable — and short-lived: in productionoffers:archiveandoffers:prunerun about two hours apart (03:00/15:00 and 05:00/17:00), so an archived row is visible only inside that window and is gone permanently afterwards. - A single-offer lookup exists, but no batch and no archived signal. The public
GET /v1/offers/{id}(OfferController@getOfferById, PUB-296) uses the same auth as/v1/offers(AuthenticationSplitter— bearer +X-App-Id), is app-scoped, and intentionally returns disabled/soft-deleted offers (withTrashed) with astatus(active/disabled/deleted) in the sameformatOfferForResponseshape as the offers list. Gaps for this use case: (a) no batch variant — one call per id; and (b) at the time of writing it did not distinguish archived — archived offers (soft-deleted witharchived_at, pre-hard-prune) were returned asstatus: deleted. PUB-493 has since added a nullablearchived_atto both this endpoint and the batch variant (see Decision 4). (An older internal-keyGET /v1/offer_lookup/{snowflake_id}also exists; the public endpoint is preferable because targeted-api already authenticates to/v1/offersthe same way and gets a list-consistent shape.)
Considered Options
Response shape:
- Option A — Opt-in
statusesargument onoffers(flat list, default[not_started]). Non-breaking only because of the default; overloads one field with two behaviors and cannot express the divergent My Offers type. - Option B — Change
offersreturn type to a status-keyed object. Rejected: breaking — existing queries select fields directly on the list and would fail validation. - Option C — New sibling
playerOffersquery returning a distinctPlayerOffertype (Selected).offersis frozen; a separate query models the different data flow, carries the divergent shape, and matches how consumers split browse vs "my offers" into separate UIs.
Data sourcing:
- Annotate only offers still in the targeted set (unworkable —
SuppressOffersis piped unconditionally, so engaged offers have already been stripped out of it), vs. transaction-driven full set with Offer API lookup for every engaged offer (Selected).
Fetching engaged offers:
- Per-id
Http::pool()over the publicGET /v1/offers/{id}, vs. a new Offer API batch endpoint (Selected).
DECISION
1. Add a playerOffers sibling query; freeze offers
offers keeps its exact meaning (the not-started browse wall). Add:
playerOffers(player_id: String!, language: String): [PlayerOffer]
Returned as a single list (mirroring the offerwall's single clicks array), each item keyed by an is_completed/status marker so clients can tab into in-progress vs completed. Publishers that split not-started vs my-offers into separate views map naturally onto separate queries, and both can be fetched in one HTTP request via GraphQL multiple-root-field selection.
Query / type name: playerOffers / PlayerOffer. Neutral, parallels offers, and reads naturally alongside a player_id argument. Considered and rejected:
| Candidate | Why not |
|---|---|
engagedOffers / EngagedOffer | Most precise — "engaged" is exactly the returned set — but mild jargon for a public API surface. |
myOffers / MyOffer | Matches the offerwall tab and api's framing, but "my" reads oddly in a B2B API that takes an explicit player_id rather than an authenticated "me". |
playerProgress / PlayerOfferProgress | Foregrounds progress and matches PUB-474's "native player-progress endpoint" framing, but reads like a progress summary object rather than a list of full offers. |
The residual objection to the selected name — that playerOffers could be read as "all offers for this player", which is closer to what offers actually returns — is real but weaker than the alternatives' costs, and the field description on the schema carries the distinction.
2. PlayerOffer is a lean, raw type
Include: offer identity + creatives (same subset as Offer), engagement state (is_completed/status, created_at, transaction_id), per-goal is_completed/completed_at, and raw amounts. Exclude (as api-local, presentation-only, or PII, and not derivable from Offer + Player API): the support-request block (wait_time, support_window_*, can_request_support, player_support_*), payment-terms values (campaign_reward_amount, request_device_id), app_name, device identifiers (gaid/idfa/platform), goal minimum_completion_time*, and all formatting. Consumers layer their own presentation, exactly as they do for browse offers.
3. Transaction-driven assembly
For a player: fetch Player API in-progress + completed transactions → resolve offer data for every one of them via the Offer API batch-lookup. The lookup is on the critical path of every playerOffers request, not a fallback for offers that aged out: SuppressOffers is piped unconditionally, so the targeted set has already had engaged offers stripped out of it and never holds them (corrected by PUB-493; earlier drafts of this ADR described the lookup as covering a dropped-out tail). Include disabled/soft-deleted offers (still completable); exclude offers carrying a non-null archived_at, and ids absent from the lookup response (hard-pruned, or not this app's). Because inclusion is transaction-driven, the existence of a transaction already proves the offer was started while active — no start-eligibility re-check is needed. Use Player API's computed transaction status directly rather than re-deriving completion from goal counts. Fold started into in-progress; exclude expired.
Two offers-pipeline behaviors the resolver must reproduce
Both are implemented on the browse query and do not apply automatically to a new root field. Both fail silently, and only against real traffic, which is why they are stated here rather than left to implementation.
- Click-attribution rewrite.
TransformOfferLinksrewritesintegration=offer-apitointegration=targeted-apion offer links, and it runs only inside theofferspipeline. BecauseplayerOffersresolves entirely through the batch lookup and never through that pipeline,PlayerOffer.linkswould otherwise ship with theoffer-apiintegration parameter and misattribute the click traffic. The resolver applies the same rewrite. - Player-id interpolation.
InterpolatePlayerIdDirectivesubstitutes{playerid}from$context->player_id, and returns the raw placeholder when it is unset.Offers.phpsets it explicitly; sincePlayerOfferalso exposeslinks, the new resolver must set it too, or publishers receive a literal{playerid}in their click URLs.
The status list is parameterized per caller, not changed in place
PlayerTransactionsService hardcodes status => ['completed','in-progress'], so started never arrives today — and that service is shared with SuppressOffers. Adding started to its default would change which offers are suppressed from the browse wall: clicked-but-not-yet-progressed offers would begin disappearing from it, which is exactly the behavior change Decision 1 freezes offers to prevent. The status list becomes a per-caller parameter. playerOffers asks for started as well; SuppressOffers keeps today's list byte-for-byte.
playerOffers is scoped to the calling app
PlayerController::listTransactions branches on the DevCycle flag get-pub-id-of-group-app; when it is set, getPlayerTransactionsByAppGroup returns transactions for every app in the publisher's group. The batch lookup is app-scoped through X-App-Id, so sibling-app offers are absent from its response and would be dropped by the ids-are-absent rule — silently losing offers for precisely the multi-app publishers this feature targets. That flag belongs to Player API and is not visible to Prism, so the scope cannot be chosen at the call site: the resolver filters the returned transactions by appId, and playerOffers returns only the calling app's offers. This keeps the lookup's app scope and the response consistent; a genuine cross-app view would need a per-app option on the Player API endpoint and is out of scope here.
Join keys are compared as strings
The types are inconsistent along the join path: StoreTransactionData::$offerId is ?int, TransactionData::$offerId is ?string, GoalData::$offerGoalId is string|int|null, and Offer API returns snowflakes as strings. A strict comparison silently matches nothing rather than erroring, so both sides are cast to string before joining. Both keys are also nullable at ingest, not only on legacy rows, so the real null rate is worth measuring before per-goal completion is promised as complete.
A partial failure is an error; an unknown player is an empty list
SuppressOffers swallows every downstream failure by design — suppression is an optimization, and a failed Player API call degrades to an unsuppressed browse wall. In playerOffers that same call is the response, so inheriting that behavior would return a silently short list, which in a progress or wallet UI is worse than a visible failure. A Player API failure surfaces as a GraphQL error rather than a truncated list. An unknown player, which Player API answers with a 404, resolves to an empty list rather than an error: "this player has engaged with nothing" is a valid answer, not a fault.
4. New Offer API batch lookup (cross-team dependency)
Add a batch variant of the existing public GET /v1/offers/{id} (e.g. GET /v1/offers?ids=… or POST /v1/offers/batch) — same AuthenticationSplitter auth (bearer + X-App-Id) and app scope, same formatOfferForResponse shape, withTrashed, with a sensible batch-size cap (targeted-api chunks beyond it). Reusing that auth means targeted-api needs no new credentials — it already calls /v1/offers this way — and the app-scoped filter is a useful safety bound. Archived state is exposed as a nullable archived_at timestamp — on both this endpoint and the existing single lookup — rather than as a fourth status value, so consumers already switching on status are unaffected. A durable archived status was considered and rejected: archived rows survive only the ~2h window between offers:archive and offers:prune, after which the correct answer is that the row does not exist, which the ids-are-absent rule already covers. Owned by the Offer API team. Shipped in PUB-493 (offer-api#1470, merged 2026-08-27) as GET /v1/offers/batch?ids=…&language=…, capped at 100 ids per request.
5. Defaults
Sort created_at desc; cursor pagination with a default page size (bound the transaction set before the lookup — see Risks). No per-player result cache: offer payloads are cached instead, keyed on adgem_app_id + offer id + language (see Consequences). Schema field names is_completed/completed_at (api maps to its campaign_completed/is_complete).
6. api adoption (separate, non-blocking)
api adds a playerOffers query, maps the lean PlayerOffer → its offerwall clicks shape (replacing its hardcoded is_complete=false/completed_at=null), renders it as a "My Offers" tab, and reflects goal-completion on the browse wall. api keeps owning its local presentation/support/payment layer. Because playerOffers is a new field, api and all publishers are unaffected until they opt in.
CONSEQUENCES
Positive
- Non-breaking: existing
offersintegrations are untouched; the feature is opt-in via a new field. - Honest modeling: a distinct
PlayerOffertype and a transaction-driven resolver reflect the real data flow rather than overloadingoffers. - Dogfood parity: the shape mirrors the existing offerwall My Offers payload, so
apiadoption is largely deletion of a hardcode. - Cleaner completion signal than the current offerwall (Player API
statusvs. hand-rolled goal-count/conversion logic). - Complete "my offers": dropped-out (disabled/deleted) offers are resurfaced, addressing the "where did my in-progress offer go?" gap.
- Auth reuse: offer lookups use the same bearer +
X-App-Idtargeted-api already sends to/v1/offers(app-scoped) — no new credentials or internal-key path — and the batch response shape matches the offers list, minimizing mapping.
Negative / Costs
-
Cross-team dependency: shipping the full set depended on new Offer API work (batch endpoint +
archived_atexposure). Both merged in PUB-493 on 2026-08-27; the Prism-side lookup client followed in PUB-495. -
Extra latency: an Offer API call on every
playerOffersrequest, not just for offers that aged out — the lookup is unconditional (Decision 3). Mitigated by batching, and by caching offer payloads keyed onadgem_app_id+ offer id + language. The per-player@cache(maxAge: 60)used by the browseoffersquery is deliberately not carried over: completion is the moment a player looks, so a per-player result cache would stale exactly the interaction this query exists to serve. Caching the payloads instead keeps completion state live while still absorbing the Offer API cost. The app id belongs in the key because the batch endpoint is app-scoped in content, not merely in access — ids belonging to another app are absent from its response, so a key of offer id + language alone would let one app read a payload another populated. -
History horizon is 120 days, inherited rather than chosen.
playerOffersis transaction-driven, and Player API reports any transaction older than the horizon asEXPIRED— which Decision 3 excludes — so the bound applies without Prism enforcing anything. It is not one knob:MAX_OFFER_IN_PROGRESS_DAYS(default 120) is read in three code paths across two deployments, all evaluated inAmerica/New_Yorkrather than UTC.player-apiPlayerTrxService::transactionsCreatedAfter()— the index-level lower bound, deliberatelyTTL + 1 dayto absorb timezone drift between the cutoff and the persisted keys, and skipped entirely whenexpiredis explicitly requested.player-apiTransactionData::calculateStatus()— the source of truth: anything created beforenow - TTLreturnsEXPIREDregardless of goal state.apiSupportRequestService— filters the transaction list it receives from Player API oncreatedAt > now - TTL, so legacy offerwall "My Offers" is bounded identically.playerOffersis therefore not a regression against the surface it replaces.
Confirmed 120 in production for both services as of 2026-08-31, established per deployment rather than by a single rule — the two assemble their environments differently, so "absent from EB properties" does not settle it on its own.
MAX_OFFER_IN_PROGRESS_DAYSappears exactly once in each tree, at the config line above, and nothing sets it anywhere: not in the Elastic Beanstalk properties (player-api-production54,adgem-api-production99), not in.ebextensions, and not in any predeploy hook. player-api's hook adds onlyAPP_KEY,DEVCYCLE_SDK_KEY,REDIS_HOSTandAPPLICATION_VERSIONbeyond the EB dump;api's additionally pulls 23 named SSM parameters plusDD_VERSION, and its Datadog hook appendsDD_API_KEYandDD_SITE. Both therefore fall back to the code default. Raising it means changing three call sites across two deployments, not one variable. -
Offers can vanish before transactions do, at 90 days.
offers:pruneforceDelete()s soft-deleted rows oncedeleted_atis older thanretention_days(90 by default), so an offer a player started becomes physically unavailable 90 days after it is soft-deleted, andplayerOffersdrops it under the ids-are-absent rule. That puts an expiry on the "dropped-out offers are resurfaced" benefit above, and it compounds with the 120-day transaction horizon: transactions age out at 120 days but offers can vanish at 90, so there is a band in which a player holds a live, non-expired transaction whose offer no longer resolves. Whether 90 days sits inside the real conversion tail is what PUB-573 is examining; the two want sequencing together. -
Prism-surface only: this ADR decides the shape of a GraphQL query on Prism. Whether the same capability reaches non-Prism publishers — Widilo and Circana come at this gap from the Offer API side and want milestone expiry and time remaining — is a distribution question rather than a schema one, and is deliberately left open here (raised by gdeangelis on PUB-474, 2026-08-18).
attribution_window_daysmakes time remaining derivable, so the type may already satisfy them; the delivery mechanism is undecided and wants the Offer API side in the room. -
Legacy records: transactions with null
offerIdcannot be resurfaced via id-keyed lookup and will appear only if still in the targeted set; goal-level completion is imprecise withoutofferGoalId.
Risks
- Silent truncation on heavy players: the Prism lookup client caps a request at
batch_lookup_max_ids(300, three chunks of 100), logging a warning and slicing the remainder — the caller receives a short list with nothing in the response indicating it. TheplayerOfferscontract capslimitat 100, the chunk size, so one page can never exceed one chunk; what closes the gap is the resolver applying that limit to the transaction set before the lookup rather than paging the assembled result. Until it does, a heavy player's cursor can walk a set that was already truncated — the cap bounds the page, not what reacheslookupByIds. No player is known to reach the ceiling today: measured againstanalytics.click_eventson 2026-08-31, across 4,584,289 (player, app) pairs distinct campaigns clicked ran 23 at p99.9 and 255 at the maximum — and distinct clicks are a superset of engaged offers, so the real figure is lower. The headroom is real but not unlimited, and it was measured over the same 120-day window the history horizon bounds — raising that horizon moves the distribution toward the ceiling. The failure is silent when it arrives. - Archived exclusion correctness is resolved:
archived_atis now returned on the lookup, so archived offers are distinguishable from soft-deleted. Treat a non-nullarchived_at, and any requested id absent from the response, as unavailable. - Privacy: device identifiers (
gaid/idfa) present inapi's internal payload are deliberately excluded from the externalPlayerOffer.
NOTES
References
- Related: 0045: Pruning soft-deleted offers to improve query performance (the archive/prune lifecycle this ADR must respect).
- Offer API: public
GET /v1/offers/{id}(OfferController@getOfferById→OfferService::getOfferBySnowflakeIdAndAppId→OfferRepository::findBySnowflakeIdAndAppId,withTrashed, PUB-296); older internalGET /v1/offer_lookup/{offer:snowflake_id};archived_atmigration2026_02_09_000000_add_archived_at_to_offers_table,offers:archive/offers:prunecommands,config/offer-prune.php. - Player API: transactions endpoint
GET /v1/apps/{appId}/players/{adgemUid}/transactions?status[]=,TransactionData::calculateStatus(),GoalData.completedAt. api:SDKController@click+CampaignActiveRule(start gate),SupportRequestController@index→SupportRequestService::serializeTransactions(My Offersclickspayload),TargetedApi::getOffers(dogfood client).- PR #174: docs(adr): Prism (Targeted API) — expose in-progress & completed player offers
Related Issues
- PUB-474 — Prism: native player-progress endpoint — expose in-progress & completed offers (this work).
- PUB-493 — Offer API: batch offer-lookup endpoint (dependency). Merged 2026-08-27.
- PUB-492 — Prism: add
playerOffersquery +PlayerOffertype. - PUB-495 — Prism: Offer API lookup client + archived/404 exclusion.
- PUB-494 — Prism: transaction-driven
playerOffersresolver. Its field scope is the APPENDIX field tables and schema sketch below; the design-spec document this ticket originally deferred to was never committed to any branch, so the reference has been dropped rather than restored after the fact. - PUB-573 — Offer API: soft-delete retention window vs. the real conversion tail (sequencing partner for the 90-day prune consequence).
- PEX-388 —
apiofferwall: adoptplayerOffersfor "My Offers" (Decision 6). On the PEX team, currently in Triage.
Original Author
Micah Wierenga
Approval Date
2026-09-02
Approved By
QSoto, danielsballes
APPENDIX
PlayerOffer field summary
| Include | Source |
|---|---|
id, campaign_id, name, store_id, tracking_type, is_multi_reward, creatives, platform_availability | Offer API (/v1/offers, /v1/offers/{id}, or batch) |
status/is_completed, created_at, transaction_id | Player API transaction |
per-goal is_completed, completed_at, maximum_completion_time_days | Player API goal + Offer API goal metadata |
raw goal amount/payout_usd | Offer API / Player API (consumer formats) |
| Exclude | Reason |
|---|---|
support-request block (wait_time, support_window_*, can_request_support, player_support_*) | api-local config/creative columns |
campaign_reward_amount, request_device_id | api payment-terms + DevCycle |
app_name | api App model |
gaid, idfa, platform | deprecated Player API clicks endpoint + device PII |
goal minimum_completion_time* | not exposed by Offer API |
formatted_amount, formatting | presentation-only |
PlayerOffer schema sketch
PlayerOffer is its own top-level type (GraphQL fields return one type, so playerOffers can't return Offer), but it reuses Offer's shared object types — OfferCreatives, OfferLinks, PlatformAvailability, and OfferGoal — rather than duplicating them. The only genuinely new surface is per-goal completion (added additively to the shared OfferGoal) and a few offer-level engagement fields on the wrapper. Not built from scratch; not an in-place change to Offer.
type Query {
playerOffers(
"Unique identifier of the player/user in your app. Must be 255 characters or fewer."
player_id: String! @rules(apply: ["required", "string", "filled", "max:255"]),
language: String
): [PlayerOffer]
@throttle(name: "playerOffers")
}
"An offer the player has engaged with (in-progress or completed), with per-goal completion."
type PlayerOffer {
# identity & detail — same shape as `Offer`, reusing shared object types
id: String!
campaign_id: Int!
name: String!
tracking_type: String!
total_payout_usd: Float!
total_amount: Float!
is_multi_reward: Boolean!
completion_difficulty: Int!
is_featured_campaign: Boolean!
campaign_vertical: String
store_id: String
start_datetime: String!
updated_at: String
attribution_window_days: Int
creatives: OfferCreatives! # reused as-is
links: OfferLinks! # reused as-is
platform_availability: [PlatformAvailability]! # reused as-is
goals: [OfferGoal]! # reused, now carrying completion fields
# player engagement / progress — the only new fields
status: PlayerOfferStatus! # IN_PROGRESS | COMPLETED (never not-started)
transaction_id: String! # Player API transaction id (correlation / support)
started_at: String! # transaction createdAt
availability: OfferAvailability! # ACTIVE while live; DISABLED/DELETED still completable; ARCHIVED excluded from the list
}
enum PlayerOfferStatus { IN_PROGRESS COMPLETED }
enum OfferAvailability { ACTIVE DISABLED DELETED }
# additive, non-breaking change to the shared goal type
type OfferGoal {
id: String!
amount: Float!
payout_usd: Float!
non_linear: Boolean!
is_purchase_goal: Boolean!
is_attribution: Boolean!
description: String
name: String
order: Int
maximum_completion_time_days: Int # already on `OfferGoal` today; not new
is_completed: Boolean! # NEW — always false on the browse `offers` query
completed_at: String # NEW — null unless completed
}
Design notes:
- Goal type: reuse
OfferGoal+ 2 fields instead of a parallelPlayerOfferGoal— avoids two goal types drifting; cost is two always-false/nullfields on browse goals. statusenum over acampaign_completedboolean — more expressive;apimapsCOMPLETED→ itscampaign_completed.- Earned-so-far is derivable from
goals[].amountwhereis_completed; no separate presentation field. availabilityis optional (surfaces winding-down offers); ARCHIVED offers are excluded regardless.attribution_window_dayswas added toOfferby PUB-601 after this sketch was written. Including it makes time remaining derivable fromstarted_at+ goalmaximum_completion_time_days+ the window, which covers the milestone-expiry use case without a dedicated field — the same derivability argument already made for earned-so-far.- Its own throttle bucket.
RouteServiceProviderregisters exactly three limiters today (offers,links,api), andoffersisLimit::perMinute(2000)->by($request->user()?->id ?: $request->ip()). Sharing theoffersname would make browse and My Offers split a single 2000/min ceiling per publisher, which matters becauseplayerOffersfans out to Player API and Offer API on every request where browse hits neither, so the two queries have very different downstream costs and should not be able to starve each other. PUB-492 must registerRateLimiter::for('playerOffers', ...)alongside the others; Lighthouse resolves@throttle(name:)at request time, so an unregistered limiter fails at runtime rather than at deploy. statsis deliberately omitted.Offer.stats(network_epi/network_epc) is non-null on the browse type, but it describes an offer's attractiveness to a prospective player and has no meaning for one already engaged.
Offer state handling
| Offer API state | In playerOffers? |
|---|---|
| active | Yes |
disabled (disabled_at) | Yes — still completable |
soft-deleted (deleted_at) | Yes — still completable |
archived (non-null archived_at) | No — unavailable; short-lived, ~2h before hard-prune |
| hard-pruned (absent from lookup response) | No |
Reference: existing offerwall "My Offers" payload
For context, this is one item from the clicks array the api offerwall returns (its internal My Offers response), captured from a development environment — it is representative of the shape, not a verbatim production response, so treat the field values as illustrative. It is included to make concrete why PlayerOffer is a deliberately lean subset: the support-request, payment-terms, device-identifier, and formatting fields below are api-local/presentation/PII and are not carried by PlayerOffer (see the field tables above). Goals trimmed for brevity.
Sample clicks[] offer object (Coin Master)
{
"app_id": 13,
"app_name": "Mobile App Survey (1509)",
"icon": "https://cdn.adgem.com/campaigns/30298/campaign-offerwall-creatives/icons/BXivQseJ5UBdyiX",
"name": "Coin Master",
"store_id": "406889139",
"OS": { "android": null, "ios": null, "web": null, "min_ios": null, "max_ios": null, "min_android": null, "max_android": null },
"description": "Join players around the world in attacks, spins and raids to build your viking village to the top!",
"short_description": "Complete Multiple Goals to Earn!",
"basic_requirements": [
{ "description": "Must be a new user. Install required for eligibility.", "order": 1 },
{ "description": "Must complete within 30 days", "order": 2 },
{ "description": "Internet Connection Required. VPN Use Prohibited.", "order": 3 },
{ "description": "Rewards may be subject to 24 hour delay", "order": 4 },
{ "order": 5, "description": "You must “Allow” the app/game to track your activity to earn rewards" }
],
"instructions": "Play Coin Master,Reach each goal listed for reward,Complete Village 300 within 30 days to obtain full earnings",
"instructions_array": ["Play Coin Master", "Reach each goal listed for reward", "Complete Village 300 within 30 days to obtain full earnings"],
"campaign_completed": false,
"campaign_id": 30298,
"campaign_tracking_type": "CPE",
"is_multi_reward": true,
"hero_image": "https://cdn.adgem.com/campaigns/30298/campaign-offerwall-creatives/hero_images/6ufT89NtlMTDtNm",
"goals": [
{ "id": 80030, "campaign_id": 30298, "external_goal_id": 0, "name": "Install", "description": "Install", "order": 1,
"minimum_completion_time_interval": null, "minimum_completion_time": null, "maximum_completion_time_days": null,
"non_linear": false, "amount": 0, "formatted_amount": "0", "original_reward_amount": 0,
"is_complete": false, "completed_at": null, "is_attribution": true },
{ "id": 80031, "campaign_id": 30298, "external_goal_id": 106060, "name": "FTD", "description": "Make a first purchase of $4.99+", "order": 2,
"minimum_completion_time_interval": null, "minimum_completion_time": null, "maximum_completion_time_days": "30",
"non_linear": true, "amount": 690, "formatted_amount": "690", "original_reward_amount": 690,
"is_complete": false, "completed_at": null, "is_attribution": false },
{ "id": 80032, "campaign_id": 30298, "external_goal_id": 106061, "name": "village_3_complete", "description": "Complete Village 3", "order": 3,
"minimum_completion_time_interval": null, "minimum_completion_time": null, "maximum_completion_time_days": "30",
"non_linear": false, "amount": 78, "formatted_amount": "78", "original_reward_amount": 78,
"is_complete": false, "completed_at": null, "is_attribution": false },
// … 16 more progressive "village_N_complete" goals (village_7 … village_280) omitted for brevity …
{ "id": 80055, "campaign_id": 30298, "external_goal_id": 106085, "name": "village_300_complete", "description": "Complete Village 300", "order": 26,
"minimum_completion_time_interval": null, "minimum_completion_time": null, "maximum_completion_time_days": "30",
"non_linear": false, "amount": 4800, "formatted_amount": "4800", "original_reward_amount": 4800,
"is_complete": false, "completed_at": null, "is_attribution": false }
],
"amount": "33749",
"install_verified": null,
"expired_due_negative_install_check": false,
"player_support_enabled": true,
"player_support_message": null,
"wait_time": 72,
"wait_time_passed": false,
"support_window_ended": false,
"support_window_end_date": "08/28/2026",
"can_request_support": false,
"created_at": "07/14/2026",
"campaign_reward_amount": 33749,
"currency_name_plural": "Coins",
"transaction_id": "bPOzbX84QlAMLNTdqTPtXbDA",
"request_device_id": true,
"platform": "ios",
"gaid": null,
"idfa": null,
"existing_support_request": false
}