Authenticating...
Skip to main content

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 offers contract. 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)​

  1. 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 a completedAt timestamp per goal. Prism already requests status => ['completed','in-progress'] and currently discards it in SuppressOffers. Join keys: transaction offerId ⟷ offer id; goal offerGoalId ⟷ offer goal id (both nullable on legacy records; fall back to campaignId/bundleId).
  2. In-progress/completed offers are transaction-driven, not targeting-driven. Offers a player has engaged with may no longer appear in the targeted offers response (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.
  3. 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 the offers Offer type without polluting it with ~15 always-null fields.
  4. Most of that sample is api-local, not Offer/Player API — assembled from api's own Campaign/creative/Offerwall models, PaymentTermsService, support-requests.* config, a deprecated Player API clicks endpoint (platform/gaid/idfa), and api presentation formatting. Prism cannot (and should not) reproduce it wholesale.
  5. Disabled/deleted offers remain completable; archived offers do not. In api, the click/start path gates on campaign_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 production offers:archive and offers:prune run 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.
  6. 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 a status (active/disabled/deleted) in the same formatOfferForResponse shape 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 with archived_at, pre-hard-prune) were returned as status: deleted. PUB-493 has since added a nullable archived_at to both this endpoint and the batch variant (see Decision 4). (An older internal-key GET /v1/offer_lookup/{snowflake_id} also exists; the public endpoint is preferable because targeted-api already authenticates to /v1/offers the same way and gets a list-consistent shape.)

Considered Options​

Response shape:

  • Option A — Opt-in statuses argument on offers (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 offers return type to a status-keyed object. Rejected: breaking — existing queries select fields directly on the list and would fail validation.
  • Option C — New sibling playerOffers query returning a distinct PlayerOffer type (Selected). offers is 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 — SuppressOffers is 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 public GET /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:

CandidateWhy not
engagedOffers / EngagedOfferMost precise — "engaged" is exactly the returned set — but mild jargon for a public API surface.
myOffers / MyOfferMatches 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 / PlayerOfferProgressForegrounds 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. TransformOfferLinks rewrites integration=offer-api to integration=targeted-api on offer links, and it runs only inside the offers pipeline. Because playerOffers resolves entirely through the batch lookup and never through that pipeline, PlayerOffer.links would otherwise ship with the offer-api integration parameter and misattribute the click traffic. The resolver applies the same rewrite.
  • Player-id interpolation. InterpolatePlayerIdDirective substitutes {playerid} from $context->player_id, and returns the raw placeholder when it is unset. Offers.php sets it explicitly; since PlayerOffer also exposes links, 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 offers integrations are untouched; the feature is opt-in via a new field.
  • Honest modeling: a distinct PlayerOffer type and a transaction-driven resolver reflect the real data flow rather than overloading offers.
  • Dogfood parity: the shape mirrors the existing offerwall My Offers payload, so api adoption is largely deletion of a hardcode.
  • Cleaner completion signal than the current offerwall (Player API status vs. 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-Id targeted-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_at exposure). 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 playerOffers request, not just for offers that aged out — the lookup is unconditional (Decision 3). Mitigated by batching, and by caching offer payloads keyed on adgem_app_id + offer id + language. The per-player @cache(maxAge: 60) used by the browse offers query 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. playerOffers is transaction-driven, and Player API reports any transaction older than the horizon as EXPIRED — 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 in America/New_York rather than UTC.

    1. player-api PlayerTrxService::transactionsCreatedAfter() — the index-level lower bound, deliberately TTL + 1 day to absorb timezone drift between the cutoff and the persisted keys, and skipped entirely when expired is explicitly requested.
    2. player-api TransactionData::calculateStatus() — the source of truth: anything created before now - TTL returns EXPIRED regardless of goal state.
    3. api SupportRequestService — filters the transaction list it receives from Player API on createdAt > now - TTL, so legacy offerwall "My Offers" is bounded identically. playerOffers is 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_DAYS appears exactly once in each tree, at the config line above, and nothing sets it anywhere: not in the Elastic Beanstalk properties (player-api-production 54, adgem-api-production 99), not in .ebextensions, and not in any predeploy hook. player-api's hook adds only APP_KEY, DEVCYCLE_SDK_KEY, REDIS_HOST and APPLICATION_VERSION beyond the EB dump; api's additionally pulls 23 named SSM parameters plus DD_VERSION, and its Datadog hook appends DD_API_KEY and DD_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:prune forceDelete()s soft-deleted rows once deleted_at is older than retention_days (90 by default), so an offer a player started becomes physically unavailable 90 days after it is soft-deleted, and playerOffers drops 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_days makes 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 offerId cannot be resurfaced via id-keyed lookup and will appear only if still in the targeted set; goal-level completion is imprecise without offerGoalId.

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. The playerOffers contract caps limit at 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 reaches lookupByIds. No player is known to reach the ceiling today: measured against analytics.click_events on 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_at is now returned on the lookup, so archived offers are distinguishable from soft-deleted. Treat a non-null archived_at, and any requested id absent from the response, as unavailable.
  • Privacy: device identifiers (gaid/idfa) present in api's internal payload are deliberately excluded from the external PlayerOffer.

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 internal GET /v1/offer_lookup/{offer:snowflake_id}; archived_at migration 2026_02_09_000000_add_archived_at_to_offers_table, offers:archive/offers:prune commands, 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 Offers clicks payload), TargetedApi::getOffers (dogfood client).
  • PR #174: docs(adr): Prism (Targeted API) — expose in-progress & completed player offers
  • 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 playerOffers query + PlayerOffer type.
  • PUB-495 — Prism: Offer API lookup client + archived/404 exclusion.
  • PUB-494 — Prism: transaction-driven playerOffers resolver. 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 — api offerwall: adopt playerOffers for "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​

IncludeSource
id, campaign_id, name, store_id, tracking_type, is_multi_reward, creatives, platform_availabilityOffer API (/v1/offers, /v1/offers/{id}, or batch)
status/is_completed, created_at, transaction_idPlayer API transaction
per-goal is_completed, completed_at, maximum_completion_time_daysPlayer API goal + Offer API goal metadata
raw goal amount/payout_usdOffer API / Player API (consumer formats)
ExcludeReason
support-request block (wait_time, support_window_*, can_request_support, player_support_*)api-local config/creative columns
campaign_reward_amount, request_device_idapi payment-terms + DevCycle
app_nameapi App model
gaid, idfa, platformdeprecated Player API clicks endpoint + device PII
goal minimum_completion_time*not exposed by Offer API
formatted_amount, formattingpresentation-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 parallel PlayerOfferGoal — avoids two goal types drifting; cost is two always-false/null fields on browse goals.
  • status enum over a campaign_completed boolean — more expressive; api maps COMPLETED → its campaign_completed.
  • Earned-so-far is derivable from goals[].amount where is_completed; no separate presentation field.
  • availability is optional (surfaces winding-down offers); ARCHIVED offers are excluded regardless.
  • attribution_window_days was added to Offer by PUB-601 after this sketch was written. Including it makes time remaining derivable from started_at + goal maximum_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. RouteServiceProvider registers exactly three limiters today (offers, links, api), and offers is Limit::perMinute(2000)->by($request->user()?->id ?: $request->ip()). Sharing the offers name would make browse and My Offers split a single 2000/min ceiling per publisher, which matters because playerOffers fans 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 register RateLimiter::for('playerOffers', ...) alongside the others; Lighthouse resolves @throttle(name:) at request time, so an unregistered limiter fails at runtime rather than at deploy.
  • stats is 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 stateIn playerOffers?
activeYes
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
}