0064: PlayerAPI Response Contract Validation Across AdGem Projects
STATUS
Accepted
CONTEXT
Multiple independent projects consume the PlayerAPI via HTTP clients that map JSON responses into local data structures. Each consumer maintains its own set of DTOs and response mappings, with no mechanism to guarantee alignment with the PlayerAPI's actual response schema.
Consumer Projects
| Project | Client | DTO Strategy | Endpoints Consumed |
|---|---|---|---|
| api | PlayerApi.php (Guzzle) |
Fully typed Spatie LaravelData DTOs with fromResponse() |
12 endpoints (CRUD for players, transactions, rewards, clicks, conversions, goals) |
| service-hub | PlayerApiService.php (Laravel HTTP) |
Fully typed Spatie LaravelData DTOs with fromResponse() |
7 endpoints (read-only: players, transactions, rewards, clicks, conversions) |
| targeted-api | PlayerTransactionsService.php (Laravel HTTP macro) |
Minimal typing — raw arrays, no per-field DTOs for transactions | 2 endpoints (player lookup by external ID, transactions with status filter) |
Problem Statement
A recent incident was caused by a field in a consumer project's response DTO that did not match the actual response returned by the PlayerAPI. The risk is amplified by the multi-consumer landscape:
- Silent data loss: Fields present in the PlayerAPI response but absent in a consumer's DTO are silently ignored.
- Runtime exceptions: Fields expected by a DTO but missing from the response cause hydration failures.
- Incorrect business logic: Type mismatches (e.g., a string where an integer is expected) can propagate incorrect values downstream without immediate errors.
- Late detection: Issues are only discovered in production, not during development or CI.
- Inconsistent representations: The same PlayerAPI endpoint may be represented differently across consumers. For example,
TransactionDatainapiincludesoffer_idwhileservice-hub's version does not. Thetargeted-apidoesn't use DTOs at all for transaction data — it works with raw arrays. - Multiplied blast radius: A single breaking change in the PlayerAPI can simultaneously affect three independent projects, each failing in different ways.
Current Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ Current Multi-Consumer Integration │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ api service-hub targeted-api │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ PlayerApi.php │ │ PlayerApi │ │ PlayerTx │ │
│ │ (Guzzle) │ │ Service.php │ │ Service.php │ │
│ │ 12 endpoints │ │ (HTTP Client) │ │ (HTTP macro) │ │
│ │ Full DTOs │ │ 7 endpoints │ │ 2 endpoints │ │
│ └──────┬────────┘ │ Full DTOs │ │ Raw arrays │ │
│ │ └──────┬────────┘ └──────┬────────┘ │
│ │ │ │ │
│ │ No contract │ No contract │ No contract │
│ │ validation │ validation │ validation │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PlayerAPI │ │
│ │ (Source of truth) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Since all projects are independent repositories, the solution must work without direct code-level coupling between them.
Considered Options
- Option 1: OpenAPI Specification generated by the PlayerAPI
- Option 2: Shared Composer package with contract interfaces
- Option 3: Consumer-Driven Contract Testing with Pact
DECISION
Adopt Option 1 (OpenAPI Specification) as the strategy. All contract mismatches are caught in CI — both on the provider side (PlayerAPI detects its own breaking changes) and on the consumer side (each consumer validates its DTOs against the spec) — before any code reaches production.
Option 1: OpenAPI Spec generated by the PlayerAPI
The PlayerAPI generates an OpenAPI specification from its controllers and response structures using a package such as Scramble (dedoc/scramble). This spec is published as a versioned artifact accessible to all consumer projects.
Each consumer project then uses this spec in its CI pipeline to validate that its local DTOs (or raw array access patterns) are structurally compatible with the PlayerAPI's documented responses.
Implementation outline:
Phase 1 — PlayerAPI (provider side):
- Install and configure Scramble to auto-generate the OpenAPI spec from existing controllers, form requests, and API resources.
- Include spec generation as a mandatory CI step on every deployment so the spec is always up to date with the running API.
- Publish the spec as an accessible artifact:
- Primary: Expose at a secure internal endpoint (e.g.,
/docs/api.json) accessible through VPC configuration, with endpoint authentication to restrict access to authorized consumer services only. - Secondary: Publish as a versioned artifact to shared storage (S3, GitHub Releases, or a dedicated spec repository) as a fallback and for offline CI access.
Phase 2 — Consumer projects (each independently):
Each consumer creates a contract validation test suite that: - Fetches the latest OpenAPI spec from the PlayerAPI. - Parses the response schema for each endpoint the consumer uses. - Compares the schema against the consumer's local DTO properties or array access patterns. - Fails CI if there is a structural mismatch (missing fields, type changes, nullability changes).
┌──────────────────────────────────────────────────────────────────────────┐
│ Proposed Validation Flow │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ PlayerAPI CI │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ oasdiff compares │ │ Scramble generates │ │
│ │ new spec vs current; │──▶│ openapi.json and │──┐ │
│ │ blocks breaking PRs │ │ publishes to S3/VPC │ │ │
│ └──────────────────────┘ └──────────────────────┘ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Shared Artifact │ │
│ │ (VPC endpoint + │ │
│ │ S3 fallback) │ │
│ └──────┬──────────────┘ │
│ ┌─────────────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ api CI │ │ service-hub│ │targeted-api│ │
│ │ validates │ │ CI validates│ │CI validates│ │
│ │ DTOs │ │ DTOs │ │ array keys │ │
│ └─────┬──────┘ └──────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ └─────── Composer hook regenerates ──┘ │
│ DTOs via Claude from spec │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Phase 1 — GitHub Actions (PlayerAPI spec publishing):
On every push to the PlayerAPI's main branch, generate and upload the OpenAPI spec as an artifact accessible to consumer CI pipelines:
# .github/workflows/publish-openapi-spec.yml (player-api repo)
name: Publish OpenAPI Spec
on:
push:
branches: [main]
jobs:
publish-spec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Generate OpenAPI spec
run: php artisan scramble:export --path=openapi.json
- name: Upload spec to S3
uses: jakejarvis/s3-sync-action@v0.5.1
with:
args: --acl private
env:
AWS_S3_BUCKET: ${{ secrets.OPENAPI_SPEC_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
SOURCE_DIR: '.'
DEST_DIR: 'player-api/latest'
# Uploads openapi.json to s3://<bucket>/player-api/latest/openapi.json
Phase 1 — GitHub Actions (PlayerAPI breaking change detection):
On every PR in the PlayerAPI repo, compare the proposed spec against the currently published one and fail the build when a breaking change is introduced without an explicit acknowledgement. This turns the PlayerAPI itself into the first gate that blocks breaking changes from being merged.
# .github/workflows/detect-breaking-changes.yml (player-api repo)
name: Detect OpenAPI Breaking Changes
on:
pull_request:
branches: [main]
jobs:
detect-breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Generate OpenAPI spec from PR branch
run: php artisan scramble:export --path=openapi.new.json
- name: Fetch currently published spec
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
run: |
aws s3 cp s3://${{ secrets.OPENAPI_SPEC_BUCKET }}/player-api/latest/openapi.json \
openapi.current.json
- name: Run oasdiff to detect breaking changes
uses: oasdiff/oasdiff-action/breaking@main
with:
base: openapi.current.json
revision: openapi.new.json
fail-on-diff: true
- name: Allow breaking change if explicitly acknowledged
# Opt-out for intentional breaking changes: add the "breaking-change" label
# to the PR and list the impacted endpoints in the PR description. The
# versioning process (see below) then kicks in for the migration window.
if: failure() && contains(github.event.pull_request.labels.*.name, 'breaking-change')
run: |
echo "Breaking change acknowledged via 'breaking-change' label; continuing."
exit 0
Detection rules (enforced by oasdiff):
- Removed endpoints, parameters, or response fields.
- Field type changes (e.g., string → int).
- Nullability tightening (e.g., a response field that was nullable becoming required).
- Renamed fields or paths.
Non-breaking changes (new optional fields, new endpoints, added examples) do not trip the workflow.
When a breaking change is intentional, the author adds the breaking-change label to the PR. The label acknowledges the impact and triggers the versioning / migration-window process described in the "Versioning and breaking changes" section below.
Phase 2 — GitHub Actions (Consumer contract validation):
Each consumer project runs a contract validation job on every PR. The job fetches the spec from S3 and runs a PHPUnit test suite that compares local DTOs against it:
# .github/workflows/contract-validation.yml (api, service-hub, targeted-api repos)
name: PlayerAPI Contract Validation
on:
pull_request:
branches: [main, develop]
jobs:
validate-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Fetch PlayerAPI OpenAPI spec
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
run: |
aws s3 cp s3://${{ secrets.OPENAPI_SPEC_BUCKET }}/player-api/latest/openapi.json \
tests/fixtures/player-api-spec.json
- name: Run contract validation tests
run: ./vendor/bin/sail test --testsuite=contract-validation
Consumer-specific considerations:
| Project | Validation Strategy |
|---|---|
| api | Compare all ResponseData and RequestData DTO properties against the spec. Strictest validation — covers field names, types, nullability, and nested objects (e.g., GoalData within TransactionData). |
| service-hub | Same DTO-based validation as api. Additionally, flag deprecated endpoints (conversions, clicks) if they are removed from the spec. |
| targeted-api | Validate that the array keys accessed via array_column() (e.g., campaignId, bundleId) exist in the spec's transaction response schema. Lower fidelity but still catches breaking field renames or removals. Migration path: Use the Claude-assisted Composer hook (see below) to generate the first versions of TransactionData / PlayerData, then switch the service layer from raw array access to the generated DTOs. This gets targeted-api to parity with the other consumers without the usual hand-coding cost. |
DTO auto-generation via Composer hook (Claude-assisted)
To make the contract alignment self-healing — and in particular to unblock targeted-api's migration away from raw arrays — each consumer installs a Composer hook that invokes Claude to generate or update DTOs directly from the OpenAPI spec. This removes the manual labor of keeping DTOs in sync and turns DTO maintenance into a deterministic, reviewable output.
How it works:
- A
post-install-cmd/post-update-cmdComposer hook runs a script that fetches the latest OpenAPI spec from the PlayerAPI. - The script invokes the Claude API (or Claude Code CLI) with the spec and a prompt that instructs it to generate Spatie LaravelData DTOs matching the response schemas consumed by the project.
- Generated DTOs are written to a known directory (e.g.,
app/Libraries/PlayerApi/ResponseData/Generated/). - Developers review and commit the diff. If the CI contract validation still passes, the changes are safe to merge.
Composer configuration (consumer project):
{
"scripts": {
"player-api:sync-dtos": [
"@php scripts/sync-player-api-dtos.php"
],
"post-install-cmd": [
"@player-api:sync-dtos"
],
"post-update-cmd": [
"@player-api:sync-dtos"
]
}
}
Sync script outline (scripts/sync-player-api-dtos.php):
<?php
// 1. Fetch the latest OpenAPI spec
$spec = file_get_contents(getenv('PLAYER_API_SPEC_URL'));
// 2. Build the Claude prompt describing the target DTO conventions
$prompt = <<<PROMPT
You are generating PHP DTOs for a Laravel consumer of the PlayerAPI.
Requirements:
- Use Spatie\\LaravelData\\Data as the base class.
- Namespace: App\\Libraries\\PlayerApi\\ResponseData\\Generated.
- Use PHP 8.2 constructor property promotion.
- Preserve nullability and types exactly as defined in the spec.
- Only generate DTOs for the endpoints this consumer uses: {{consumed_endpoints}}.
OpenAPI spec:
{$spec}
PROMPT;
// 3. Call Claude via the Anthropic SDK (or shell out to `claude` CLI)
$generatedDtos = callClaude($prompt);
// 4. Write files to disk
writeGeneratedFiles($generatedDtos, 'app/Libraries/PlayerApi/ResponseData/Generated/');
// 5. Run the contract validation test suite locally to confirm the output is valid
passthru('./vendor/bin/sail test --testsuite=contract-validation');
Why Claude-assisted generation over Jane or OpenAPI Generator:
- Convention-aware output: Claude can match the project's existing code style (Spatie LaravelData, specific namespaces, casting rules) without requiring configuration templates.
- Partial generation: Each consumer only needs DTOs for the endpoints it consumes (see tables A1–A3). Claude can scope generation to a subset of the spec; generic tools tend to generate everything.
- Low integration cost: No need to install a code generator as a dev dependency or learn its templating system. A thin script + the spec is enough.
- Handles edge cases: Nested DTOs (e.g.,
GoalDatawithinTransactionData) and consumer-specific naming (e.g., snake_case vs camelCase) are handled via prompt instructions rather than per-tool configuration.
Migration impact for targeted-api:
This mechanism is the recommended path for targeted-api to adopt typed DTOs. Running the hook produces the first generation of TransactionData, PlayerData, etc.; the team then switches the service layer from raw array access to the generated DTOs. Because the generation is reproducible and the contract validation catches drift, the migration becomes incremental and low-risk.
Risks and controls:
- Non-determinism of LLM output: Subsequent runs may produce stylistic variations even when the spec is unchanged. Mitigation: commit generated files and treat regeneration as an intentional action (not auto-commit); reviewers diff and approve.
- Hook blocking installs: The hook must fail gracefully if the spec is unreachable (e.g., developer is offline). It should skip generation rather than block
composer install. - Cost: Claude API calls have a cost per invocation. Mitigation: cache generated output by spec hash; only re-run Claude when the spec actually changes.
Why the other options were not chosen
- Option 2 (Shared Composer package): Introduces direct dependency coupling between four independent repositories. Requires publishing, versioning, and maintaining a separate package. Every PlayerAPI change forces a package release before any consumer can adapt. With three consumers at different maturity levels (fully typed DTOs vs. raw arrays), a shared package would either be too rigid or too loose to be useful. A variant where the DTOs live inside the PlayerAPI repo and are released as a package reduces some friction but still couples consumer deploy cycles to package releases.
- Option 3 (Pact): While Pact is designed for multi-consumer contract testing, the setup cost is significant — requires a broker (PactFlow or self-hosted), CI integration on all four repositories, and Pact PHP libraries on both sides. With three consumers, the operational overhead grows linearly. Better suited if the number of consumer-provider pairs scales significantly. Can be revisited in the future if additional services begin consuming the PlayerAPI.
Versioning and breaking changes
When the PlayerAPI needs to introduce a breaking change (removing a field, changing a type, restructuring a response), the following process applies:
oasdiffdetects the change on the PlayerAPI PR and blocks the merge by default. The author must either (a) rework the change to be non-breaking, or (b) apply thebreaking-changelabel to the PR and list impacted endpoints in the description.- The PlayerAPI publishes a new spec version once the labeled PR is merged. The spec is versioned alongside the API itself (e.g., if the API uses URL versioning like
/api/v1/vs/api/v2/, the spec tracks the same versions). - Consumer CI pipelines fail, alerting each team to the incompatibility.
- Migration window: The PlayerAPI maintains the previous version for a defined period (recommended: 2 weeks minimum) while consumers update their DTOs or array access patterns. Both spec versions are available simultaneously. Consumers can regenerate DTOs via the Composer hook to accelerate migration.
- Deprecation signals: Scramble supports marking endpoints and fields as deprecated. Consumer validation suites should log warnings for deprecated fields without failing CI, giving teams advance notice before removal.
For non-breaking changes (adding optional fields, adding new endpoints), the spec updates automatically and consumer CI should continue to pass — the validation logic treats additional fields in the response as acceptable.
This approach avoids the need for a centralized coordination step: each consumer discovers the breaking change independently through its own CI and migrates on its own timeline within the deprecation window.
CONSEQUENCES
Positive
- Breaking changes blocked at the source: The PlayerAPI's own CI detects and blocks breaking changes before they can be merged, preventing the problem instead of reacting to it.
- Early detection across all consumers: Contract mismatches are caught in CI for each consumer project independently before reaching production.
- Low coupling: The OpenAPI spec is a language-agnostic artifact; no shared code dependencies between repos. Each consumer validates at its own pace.
- Documentation as a byproduct: The generated OpenAPI spec serves as living API documentation for the PlayerAPI, benefiting all consumer teams.
- Self-healing DTOs: The Claude-assisted Composer hook regenerates DTOs from the spec, keeping consumer types aligned with the provider without manual effort.
- Gradual adoption: Each consumer can implement validation independently. The
targeted-apican start with minimal array-key checks and use the DTO generator to migrate to typed DTOs. - Visibility into divergence: The validation process will surface existing inconsistencies between consumers (e.g.,
offer_idpresent inapi'sTransactionDatabut absent inservice-hub's version), enabling teams to intentionally reconcile or document these differences.
Negative
- Maintenance of the spec: The PlayerAPI team must ensure the OpenAPI spec stays up to date. Scramble mitigates this by auto-generating from code, but custom endpoints may need manual annotations.
- CI dependency: All three consumer projects' CI pipelines depend on the availability of the PlayerAPI's OpenAPI spec artifact.
- Coordination overhead: Three consumer teams need to implement and maintain their own validation suites. However, the validation logic can follow a shared pattern documented in this ADR.
- Initial setup effort: Requires configuration of Scramble in the PlayerAPI, the breaking-change detection workflow, the DTO generation hook, and validation test suites in three consumer projects.
- LLM cost and non-determinism: The Claude-assisted DTO generator introduces a per-invocation API cost and may produce stylistic variations between runs. Mitigated through spec-hash caching and treating regeneration as an explicit action.
Risks
- Spec drift: If the PlayerAPI modifies responses without regenerating the spec, mismatches will go undetected across all consumers until the spec is refreshed. Mitigation: include spec generation as a mandatory step in the PlayerAPI's CI/CD pipeline, ideally blocking merges if the spec is stale.
- False positives: Overly strict validation could flag non-breaking changes (e.g., new optional fields). Mitigation: the validation logic should treat additional fields in the response as acceptable, only flagging missing or type-changed fields.
oasdiffalready distinguishes between breaking and non-breaking changes. - Partial adoption: If one consumer implements validation but others don't, the unprotected consumers remain vulnerable. Mitigation: track adoption as part of the rollout plan and prioritize the highest-traffic consumers (
api,service-hub) first. - Breaking-change label misuse: A
breaking-changelabel could be applied without following the migration process. Mitigation: require the PR description to list impacted endpoints and tag consumer owners as reviewers when the label is applied.
NOTES
References
- Scramble - Laravel API Documentation Generator
- OpenAPI Specification 3.1
- oasdiff - OpenAPI diff and breaking-change detector
- Pact Contract Testing
- Spatie Laravel Data
- Anthropic Claude API
- Composer scripts and hooks
- PR #134: docs(ADR): PlayerAPI response contract validation across AdGem projects
Original Author
qsoto
Approval date
Approved by
Appendix
A1. Consumer DTOs — API Project
Response DTOs
| DTO | Path | PlayerAPI Endpoint |
|---|---|---|
| PlayerData | app/Libraries/PlayerApi/ResponseData/PlayerData.php |
GET /api/v1/apps/{appId}/players/{adgemUid} |
| TransactionData | app/Libraries/PlayerApi/ResponseData/TransactionData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/transactions |
| RewardData | app/Libraries/PlayerApi/ResponseData/RewardData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/rewards |
| GoalData | app/Libraries/PlayerApi/ResponseData/GoalData.php |
POST /api/v1/transactions/{txId}/goals/{goalId} |
| ClickData | app/Libraries/PlayerApi/ResponseData/ClickData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/clicks |
| ConversionData | app/Libraries/PlayerApi/ResponseData/ConversionData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/conversions |
Request DTOs
| DTO | Path | PlayerAPI Endpoint |
|---|---|---|
| CreatePlayerData | app/Libraries/PlayerApi/RequestData/CreatePlayerData.php |
POST /api/v1/apps/{appId}/players |
| CreateTransactionData | app/Libraries/PlayerApi/RequestData/CreateTransactionData.php |
POST /api/v1/apps/{appId}/players/{adgemUid}/transactions |
| AddRewardData | app/Libraries/PlayerApi/RequestData/AddRewardData.php |
POST /api/v1/apps/{appId}/players/{adgemUid}/rewards |
| StoreClickData | app/Libraries/PlayerApi/RequestData/StoreClickData.php |
POST /api/v1/apps/{appId}/players/{adgemUid}/clicks |
| StoreConversionData | app/Libraries/PlayerApi/RequestData/StoreConversionData.php |
POST /api/v1/apps/{appId}/players/{adgemUid}/conversions |
A2. Consumer DTOs — Service-Hub Project
Response DTOs
| DTO | Path | PlayerAPI Endpoint |
|---|---|---|
| PlayerData | app/Libraries/PlayerApi/ResponseData/PlayerData.php |
GET /api/v1/apps/{appId}/players/{adgemUid} |
| TransactionData | app/Libraries/PlayerApi/ResponseData/TransactionData.php |
GET /api/v1/transactions/{transactionId}, GET /api/v1/apps/{appId}/players/{adgemUid}/transactions |
| GoalData | app/Libraries/PlayerApi/ResponseData/GoalData.php |
Nested within TransactionData |
| RewardData | app/Libraries/PlayerApi/ResponseData/RewardData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/rewards |
| ClickData | app/Libraries/PlayerApi/ResponseData/ClickData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/clicks (deprecated) |
| ConversionData | app/Libraries/PlayerApi/ResponseData/ConversionData.php |
GET /api/v1/apps/{appId}/players/{adgemUid}/conversions (deprecated) |
A3. Consumer Fields — Targeted-API Project
Raw Array Access (no DTOs)
| Accessed Field | Context | PlayerAPI Endpoint |
|---|---|---|
adgemUid |
Extracted from player lookup response | GET /api/v1/apps/{appId}/players/by-external/{externalPlayerId} |
campaignId |
Extracted via array_column() from transactions |
GET /api/v1/apps/{appId}/players/{adgemUid}/transactions |
bundleId |
Extracted via array_column() from transactions |
GET /api/v1/apps/{appId}/players/{adgemUid}/transactions |
A4. Known Divergences Between Consumers
| Field | api | service-hub | targeted-api | Notes |
|---|---|---|---|---|
TransactionData.offer_id |
Present | Absent | N/A (raw array) | Potential inconsistency — verify if intentional |
GoalData.isAttribution |
Absent | Present | N/A | service-hub has this field; api does not |
TransactionData.status |
Present (nullable) | Present (nullable) | Accessed as array key | Consistent across typed consumers |
| Transaction response | Full DTO | Full DTO | Raw array | targeted-api has no type safety on this response |