Offer Sync: Enroll an App
When to use this
Use this when an AdGem app needs offers in the Offer API and isn't getting them, or needs to move between sync paths:
- First-time enablement: an app on neither sync list, for example before it's turned on for Prism. It has no rows in the Offer API, so it needs a first full sync.
- Move from batch to realtime: an app on the batch schedule moving to EventSync.
Background on the two paths and the lists is in Offer Sync Today. Read that first if the terms below are new.
This is a manual procedure today. Making it automatic is tracked in the EventSync project (auto first sync when an app becomes active, and eligibility from dashboard data).
Prism serves only what the Offer API holds. Turning Prism on for an app before its first sync has landed serves an empty offerwall. That was IR-308 (2026-09-03): 39 of 77 Prism-enabled apps had no offers at all. Enable Prism last, after step 4 passes.
Which path?
As of 2026-09-29, add new apps to the batch schedule. The realtime differ is at 80-88% of its 25-minute lock at 103 apps. Moves to realtime happen in planned waves, gated on differ capacity and on the nightly consume latency monitor. Check with the EventSync project lead before adding apps to realtime.
Prerequisites
-
AWS SSO access, region
us-east-2, to:- the AdGem dashboard account, where the
adgem-dashboard-productionElastic Beanstalk environment runs. Commands below use the profile nameadgem-legacy. - the Offer API account (
010438502987), with theoffer-api-green-productionenvironments. Profile nameadgem-offer-api.
Substitute your own profile names.
- the AdGem dashboard account, where the
-
ssm:SendCommandon those instances. -
Edit access to the
adgemproject in DevCycle. -
jq.
Steps
1. Pre-flight
-
The app is
activeand not soft-deleted. The sync skips anything that isn't active. -
Open both flags in DevCycle, read live, right now:
offer-api-v2-sync-schedule: the batch bucketsonce20,once50,twicereal-time-offer-sync-apps:app_ids
For a first-time enablement, the app is in neither. For a move, it's in exactly one batch bucket.
Other people edit the batch schedule, so any copy you saved earlier is already stale.
2. Backfill
Queue a sync for the apps now, instead of waiting for a schedule. This runs UpdateOfferApiForAppJob for each app, the same job the batch uses.
APPS=1234,5678 # comma-separated, no spaces
IID=$(aws elasticbeanstalk describe-environment-resources \
--environment-name adgem-dashboard-production \
--profile adgem-legacy --region us-east-2 \
--query 'EnvironmentResources.Instances[0].Id' --output text)
jq -n --arg c "cd /var/app/current && sudo -u webapp /usr/bin/php artisan offer-api:sync-v2 --apps=$APPS 2>&1 | tail -20" \
'{commands:[$c]}' > /tmp/offer-sync-backfill.json
aws ssm send-command --instance-ids "$IID" \
--document-name AWS-RunShellScript --comment "offer sync backfill" \
--parameters file:///tmp/offer-sync-backfill.json \
--profile adgem-legacy --region us-east-2 \
--query Command.CommandId --output text
- SSM reports
Successwith no output. That's expected: the command only queues jobs, and the dashboard doesn't ship info logs. - Jobs are staggered 20 seconds apart, so the last app starts about
number of apps x 20 safter dispatch. - Keep batches under about 110 apps per run. Batch 3 of the Prism ramp (111 apps, about 16,500 campaign memberships, 2026-09-24) averaged about 450 writes a minute. A first sync is almost all creates (
POST /v2/offers), which the Offer API limits to 1,000 a minute per caller IP, so that run used under half the headroom. Size larger batches against 1,000, not the 2,000 that applies only to updates. - Write the parameters to a file as shown. SSM mangles single quotes inside inline
--parameters.
3. Verify the inventory
Wait until the last job should have finished (step 2's estimate plus 5 minutes), then compare what the dashboard expects with what the Offer API holds. eventsync:diff does exactly that for any app id. Without --reconcile it only reads.
The instance id from step 2 may be gone by now: a dashboard deploy replaces the instances. Look it up again:
APPS=1234,5678 # the same list as step 2
IDS=$(echo "$APPS" | tr , ' ')
IID=$(aws elasticbeanstalk describe-environment-resources \
--environment-name adgem-dashboard-production \
--profile adgem-legacy --region us-east-2 \
--query 'EnvironmentResources.Instances[0].Id' --output text)
jq -n --arg c "cd /var/app/current && sudo -u webapp /usr/bin/php artisan eventsync:diff $IDS 2>&1 | grep -E \"diff for app|Expected|Actual|Summary\"" \
'{commands:[$c]}' > /tmp/offer-sync-verify.json
CMD=$(aws ssm send-command --instance-ids "$IID" \
--document-name AWS-RunShellScript --comment "offer sync verify (read-only)" \
--parameters file:///tmp/offer-sync-verify.json \
--profile adgem-legacy --region us-east-2 \
--query Command.CommandId --output text)
# a minute later
aws ssm get-command-invocation --command-id "$CMD" --instance-id "$IID" \
--profile adgem-legacy --region us-east-2 \
--query '[Status,StandardOutputContent,StandardErrorContent]' --output text
Each app prints something like:
EventSync diff for app 1234
Expected (dashboard): 212 campaigns
Actual (offer-api): 212 offers
Summary: 212 in_sync, 0 drift (0 supersede + 0 patch), 0 missing, 0 extra actionable, 0 extra already-disabled
Pass: 0 missing and 0 extra actionable for every app. A few drift rows are normal if campaigns were edited during the sync.
If an app is short (missing greater than zero), run step 2 again for just those apps. Large first syncs, roughly 400 offers or more, can be cut off by the job's 60-second timeout. On 2026-09-24, two apps came out 98 and 61 offers short. The job retries, but each retry starts the whole app again. A second run completes them, because the offers already written are skipped.
If an app expects zero campaigns, the sync can't give it offers: every campaign membership is non-servable (paused, zero payout, or filtered out). Fix that on the dashboard side first.
Notes:
- Don't use the Metabase mirror of the Offer API for this check. It has frozen for days at a time; check
max(updated_at)onoffer_api.offersif you do use it. - A manual
eventsync:diffalso emits the drift metrics for those apps, so a short blip on the EventSync drift graphs is expected. - SSM output is capped at 24,000 characters, which is why the command filters to the summary lines. Split long app lists across several runs.
4. Edit the flag
Only after step 3 passes. In DevCycle, adgem project, edit the Variation Prod value only. Leave the empty and testing variations alone.
Append the ids to the live value. Never paste a saved copy of the whole document: that deletes whatever other people added since you read it.
-
First-time enablement to batch: append to
once20oronce50inoffer-api-v2-sync-schedule. Balance the two buckets; each bucket dispatches its apps 20 seconds apart within its hour, so keep each under about 180 apps. -
Move from batch to realtime: two edits, in this order:
- append to
app_idsinreal-time-offer-sync-apps; - remove the same ids from their batch bucket.
Adding first means the app is briefly on both paths, which is harmless. Removing first would leave it on neither.
- append to
Then read both flags again and check:
- every enrolled id appears exactly once across the two flags;
- no id is in both a batch bucket and
app_ids; - the counts moved by exactly the number of apps you enrolled.
Nothing enforces these rules in code today, so this check is the only safeguard.
5. Watch the first scheduled runs
- Batch: the app's bucket runs at :20 (
once20) or :50 (once50). The first scheduled run after a backfill should be mostly no-ops. Watch forUpdateOfferApiForAppJobfailures and for 429s from the Offer API. - Realtime: the differ runs at :05 and :35. It should report the moved apps clean.
adgem_dashboard.eventsync.cohort_sizeshould step up by the number of apps moved. For a wave of more than a few apps, check the "EventSync: nightly consume latency p99 (migration gate)" monitor after the next 00:00 UTC burst before planning another wave.
6. Prism, if needed
Only now. Hand the verified list (the apps that passed step 3) to whoever is editing uses-targeted-api. Two exclusions:
- Apps integrated through the Offer API itself (integration type
offer_api) don't render an AdGem offerwall, so Prism doesn't apply. - If an app shows
0 missingbut expects very few campaigns, confirm with the account owner that a near-empty wall is expected.
Rollback
- Batch: remove the ids from their bucket. The offers already created stay in the Offer API and stop updating. If the app should serve nothing, disable it on the dashboard, and the disable path turns its offers off.
- Realtime move: put the ids back in their batch bucket first, then remove them from
app_ids.
History
Built from the long-tail Prism ramp: batch 2 (2026-09-23, 19 apps to realtime plus 7 moved from batch) and batch 3 (2026-09-24, 111 first-time enablements to batch). Both passed with this procedure; the two partial syncs in batch 3 are the reason for the "if an app is short" step.