0 / 0 done
ACTIONABLE AFFILIATE · AI ADS ENGINE

Go-Live Runbook

Every operator step between "all eight phases merged" and "the engine is spending, optimizing, and reporting on its own." Work top to bottom — each phase gates the next. Checkboxes save automatically in this browser.

← Back to the main user guide

AInfrastructure prerequisites

Do these first — the live publish, the digest emails, and prod webhooks all depend on them.

Verify the real brand domain (app side)
Add the annuity offer's domain as an org domain in the app so tracker links serve at https://{domain}/c/{code}. Phase 5's live publish is blocked until an ad's destination URL is a real tracker link on this domain. The currently-approved test creative points at the fake ads.test.local — it must be re-pointed.
Verify the same domain in Meta
Business Settings → Brand safety and suitability → Domains → Add → verify via DNS TXT or meta-tag. Meta increasingly requires a verified domain behind ad destinations, and it is mandatory for some financial verticals.
Add missing Vercel env vars
Local .env.local is complete; production is missing two. In the Vercel project → Settings → Environment Variables add:
  • ADS_WEBHOOK_SECRET — provider callbacks (kie/heygen) won't ingest in prod without it.
  • BLOB_READ_WRITE_TOKEN — creative asset uploads to Vercel Blob.
  • Optional now, needed for chat alerts later: ADS_TELEGRAM_BOT_TOKEN, ADS_SLACK_WEBHOOK_URL (see the deep dives below), then run pnpm ads:bootstrap once so they're seeded as encrypted provider credentials.
Names use underscores, never hyphens. Redeploy after adding.
AWS SES production access + config sets
The digest and alert emails ride the existing SES mailer. Still outstanding:
  1. Request production access (SES console → Account dashboard → Request production access; state transactional use, expected volume, bounce handling).
  2. Create configuration sets aa-transactional and aa-bulk.
  3. Point their event destinations (bounces/complaints via SNS) at /api/webhooks/ses so suppressions stay accurate.

BActivate reporting & alerts (5 minutes — do now)

Phase 8 shipped default-safe: digests skip with "no recipients" until you add one.

Add digest recipients in /ads/settings → Reporting & alerts
Recipients must be emails of current org members — the API rejects anything else, and membership is re-checked at send time (removing a member silently stops their digest). Confirm alerts are enabled and pick channels (email works immediately; Telegram/Slack need the deep-dive setup below).
Sanity-check the digest preview
Open /api/ads/digest/preview?date=YYYY-MM-DD (logged in). It renders the email in-browser without sending. Right now you'll see the empty-state digest — that's correct. The response header x-digest-skip tells you whether the real cron would have skipped that day.

CFirst live publish — the $5 test

The first-ever real Meta write. Everything is deliberately paranoid here: structures are created PAUSED, and you activate manually once.

Set guardrails in /ads/settings
dailySpendCap is currently NULL — set it (e.g. $10). Switch autonomyMode to assisted. Confirm publishEnabled is on.
Approve one creative with a real destination
In /ads/queue, approve a creative whose destination is a live tracker link on the verified domain (regenerate or re-point the existing test creative). This doubles as the still-owed full-cost generation run (kie/heygen paid pipeline end-to-end).
Let ads-publish run, then activate manually
The cron runs every 15 min (or curl it locally with the CRON_SECRET bearer). It creates campaign → ad set → ad PAUSED by design. Go to Ads Manager and flip the campaign/ad set to Active — a one-time manual step, by spec.
Verify idempotency
Run the publish cron a second time. Confirm no duplicate campaign/creative appeared — check ad_publish_jobs (unique idempotency key per creative × ad set) and Ads Manager.
Spend ~$5, click your own ad, confirm attribution
Click through from the live ad and confirm the click lands in the tracker (an ad_attribution_daily row for that ad/day). This closes the loop: Meta spend on one side, first-party truth on the other.
Financial vertical warning. Before publishing the annuity offer, set specialAdCategories in /ads/settings to include Financial products and services. Meta requires it for insurance/investment/annuity ads and will reject or flag ads without it. It restricts age/gender/zip targeting — expected. Have compliance/brand pages live on the verified domain; regulated offers get heavier ad review. Meta: special ad categories ↗

DRead-only verification week

Spec §12 step 1: trust the numbers before letting anything act on them.

Compare /ads insights vs Ads Manager daily for ~a week
ads-sync runs every 4h. Spot-check spend, impressions, clicks, conversions per day. This also validates the hand-built test fixtures against reality for the first time.
Do the Phase 8 manual email passes
The digest goes out daily at 15:00 UTC once recipients exist. Verify: numbers match the dashboard for the same date; every CPA/ROAS is labelled with its source (first-party / meta / stripe); actions list rules and reasons; thumbnails load. Open the email in Gmail web, Gmail iOS, and Apple Mail — in both light and dark mode. Email rendering can't be tested any other way.
Confirm the duplicate-send guard once in prod
Trigger the digest cron twice for the same day; exactly one email should arrive and one ad_report_runs row exists (second run reports already_ran).

EOptimizer shadow rehearsal (needs 3–4 days of live insights)

Read every decision the optimizer would make before letting it make any.

Enable shadow optimization
Keep autonomyMode = shadow, set optimizeEnabled = true. The 14:00 UTC optimize run now writes an ad_actions row for every decision — none touch Meta.
Read every action daily; verify shadow phrasing
The daily digest mails them with "Would have killed…" phrasing and a shadow banner — if a shadow digest ever reads like a real one, that's a bug, report it. Tune targetCpa / killCpaMultiple until you agree with every intended decision.
Graduate to assisted, one kill at a time
Switch to assisted with maxKillsPerRun = 1. Raise caps only as confidence justifies. Full autonomous mode comes much later, if ever.

FResearch (blocked on Reddit approval — ticket 18208039)

Reddit gates commercial Data API use behind its Responsible Builder policy; our request is pending.

When approved: create the script app + creds
  1. Create a script-type app at old.reddit.com/prefs/apps ↗ (logged in as u/fbeach73).
  2. Put REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT into .env.local and Vercel.
  3. Run pnpm ads:bootstrap to seed the encrypted credential.
Enable + configure research
Set researchEnabled = true, then in the brand kit configure subreddits and keywords. The YouTube field takes video IDs, not channel names (v1 limitation). The 06:00 UTC cron then feeds /ads/research.
Judge extraction quality (Phase 7 §10)
Compare a sample of extracted insights against their source threads — the digest's "which research source produces winners" loop is only as good as extraction quality.
After all of the above has run cleanly for several weeks: Phase 9 (multi-tenant migration — OAuth, Meta App Review, per-tenant credentials) is the only build phase left. Don't start it earlier; proving the loop works comes first, by design.

GBusiness Manager: partner access & account naming

Adding your partner for the annuity campaign, done the right way — as a person in your Business Manager with scoped asset access.

Invite your partner as a person
  1. business.facebook.com → Settings ↗Users → People → Add / Invite people.
  2. Enter his email (ideally the one tied to his personal Meta account; any email works — he links his own login on accept).
  3. Access level: Employee access. Only grant Admin if he must manage billing/users — Admins can remove you from assets.
Assign the three assets in the same invite flow
  • Ad account "Actionable Affiliates" (act_1735117560747528) → enable Manage campaigns (Advertiser).
  • Page — the Page that will front the annuity brand → Create content / Manage as needed.
  • Dataset (Pixel) 619087824010534 → view or manage.
He accepts + enables 2FA; verify assets show
He accepts via the email link and logs in with his personal Facebook account. Business Manager forces two-factor auth — warn him ahead so he doesn't stall. Afterwards check People → his name → all three assets listed.
If he runs his own Business Manager (offer owner / agency side), the cleaner route is Partners → "Give a partner access to your assets" using his Business ID — same asset assignment, but tied to his business instead of a personal login. Meta: add people to BM ↗Meta: partner access ↗
Ad account name — don't sweat it. End users never see it. Ads show the Page name (and IG handle), the creative, and Page transparency info; the ad account name appears only in Ads Manager, Business Settings, and on invoices. It's freely renameable: Business Settings → Accounts → Ad accounts → select → rename (pencil). Instant, no review, no effect on delivery or the 5-account creation limit, and the act_… ID the engine keys on never changes. The asset that does deserve naming care is the Page — if "Affiliate Challenge" isn't the brand you want on a financial offer, create/rename a Page for that brand, assign it to the System User, and update metaPageId before publishing.

HDeep dive: Telegram alerts

Telegram carries alerts only (spend spikes, sync failures, token expiry…) — never the daily digest. That's deliberate: a digest on your phone at 7am is noise; a spend-spike ping is signal.

One-time setup (~10 minutes)

  1. Create the bot. In Telegram, message @BotFather ↗ → send /newbot → pick a display name (e.g. "AA Ads Alerts") and a unique username ending in bot. BotFather replies with the bot token — a string like 1234567890:AAF…. Treat it like a password.
  2. Open a chat with the bot. Bots can't message you first. Find your bot by its username and send it any message (e.g. "hi"). For a group: add the bot to the group and post a message there instead.
  3. Get the chat id. Visit (browser is fine):
    https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
    In the JSON, find "chat":{"id": …}. A DM id is a positive number; a group id is negative (often starting -100…). Shortcut for DMs: message @userinfobot ↗ and it replies with your id.
  4. Wire it into the app. Two halves, stored separately on purpose:
    • Token (secret): set ADS_TELEGRAM_BOT_TOKEN in .env.local and Vercel, then run pnpm ads:bootstrap — it lands encrypted in ad_provider_credentials.
    • Chat id (not secret): paste it into /ads/settings → Reporting & alerts → Telegram chat id.
  5. Enable the channel — tick "telegram" under alert channels in the same settings card.
  6. Test it. Force a condition (e.g. set ad_sync_state.consecutive_failures = 3 via a tsx one-off) and run the alerts cron with the bearer token — one Telegram message should arrive; running it again in the same hour sends nothing (dedupe).
Behavior notes. Only warning and critical alerts push to Telegram — info waits for the email digest (alert fatigue kills alerting). Messages use HTML parse mode with escaping and truncate at Telegram's 4,096-char limit with a dashboard link. If the token or chat id is missing the channel silently no-ops — nothing breaks. Undelivered alerts retry hourly until delivered or acknowledged.

IDeep dive: Slack alerts

Same job as Telegram — alerts only, for the team view. Uses a plain incoming webhook; no Slack app installation gymnastics beyond creating it once.

One-time setup (~10 minutes)

  1. Create a Slack app. Go to api.slack.com/apps ↗Create New App → From scratch → name it (e.g. "AA Ads Alerts") and pick your workspace.
  2. Activate Incoming Webhooks. In the app's sidebar: Incoming Webhooks → toggle OnAdd New Webhook to Workspace → choose the channel (e.g. #ads-alerts — make a dedicated channel so pings are unambiguous) → Allow.
  3. Copy the webhook URL. It looks like:
    https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
    This URL is the credential — anyone holding it can post to that channel. Treat it as a secret.
  4. Wire it into the app. Set ADS_SLACK_WEBHOOK_URL in .env.local and Vercel, run pnpm ads:bootstrap — stored encrypted in ad_provider_credentials. Then tick "slack" under alert channels in /ads/settings.
  5. Test it the same way as Telegram (force a sync-failure condition, run the alerts cron, expect exactly one Block Kit message with a dashboard button; a rerun in the same window sends nothing).
Security note (by design). The app refuses any webhook whose host isn't exactly hooks.slack.com over https, and routes the request through the SSRF-safe fetcher — a URL pointing anywhere else (internal IPs, lookalike domains) is rejected before any network call. If Slack ever rotates your webhook (they're revocable from the app config page), update the env var and re-run bootstrap.