Skip to content
Patronage Ads Sync Docs
Esc
navigateopen⌘Jpreview
On this page

Quickstart

Deploy the Ads Sync Reference Deployment to your own Cloudflare account and Postgres, and complete one scheduled Qualified Run in under an hour.

This quickstart takes you from a fresh Cloudflare account and a fresh Postgres database to one completed, scheduled Qualified Run. You deploy the Ads Sync Reference Deployment, one Sync Connection, one Supported Provider (Google Search Console), and one Cron trigger.

Everything here is single-tenant custody (ADR 0047). Your credentials live in your own Cloudflare Worker secrets, or in the local Wrangler dev vars file when you run locally. There is no broker, no registry, and no token exchange. The Reference Deployment never writes secret material to Postgres, R2, logs, or artifacts.

Read Google Search Console caveats before you create the OAuth client. The Testing-mode refresh token expires after seven days.

before you start

you need why
A Cloudflare account on Workers Paid Cloudflare Containers require Workers Paid.
Node.js 24, pnpm 10 or newer, Git The repository toolchain.
Docker running locally Wrangler builds the connector images from the shipped Dockerfiles at deploy time.
psql Applies the schema. Any Postgres client that reads SQL from stdin also works.
A reachable Postgres database and one role The role needs database-level CREATE. Airbyte owns the DDL and creates its own schemas. Do not pre-create schemas or use a schema-scoped writer.
A Google Search Console property you can read The provider data source.
A Google Cloud project Hosts the OAuth client for the Search Console API.

Set one variable for the rest of the tutorial:

export DATABASE_URL="postgres://<role>:<password>@<host>:<port>/<database>?sslmode=require"

The Reference Deployment reaches Postgres two ways: the Worker reads through Hyperdrive, and the destination Container writes to Postgres directly. Both need the same database.

1. get the code

git clone https://github.com/patronage/agentic-marketing-connectors.git
cd agentic-marketing-connectors
pnpm install
pnpm --filter @patronage/ads-sync build
pnpm --filter @patronage/ads-sync-deploy check

check runs type checking, the deployment tests, and the public-import boundary guard. The rest of the tutorial runs from the Reference Deployment directory:

cd packages/ads-sync/deploy

2. create the Cloudflare resources

pnpm exec wrangler login
pnpm exec wrangler r2 bucket create ads-sync-raw
pnpm exec wrangler hyperdrive create ads-sync-quickstart --connection-string="$DATABASE_URL" --caching-disabled

--caching-disabled matters. Hyperdrive caches query results for 60 seconds by default, and the Worker reads the committed watermark through Hyperdrive. With caching on, GET /runs reports watermark: null for up to a minute after a run succeeds, and a tick in that minute queues the same window again instead of answering no_new_final_data.

hyperdrive create prints an id. Paste it into wrangler.jsonc in place of the placeholder:

"hyperdrive": [
  {
    "binding": "HYPERDRIVE",
    "id": "<the id hyperdrive create printed>",
  },
],

Keep the bucket name ads-sync-raw, or change both the bucket and r2_buckets[0].bucket_name together. If your account already has a Worker named patronage-ads-sync-reference-deploy, change name in wrangler.jsonc too; the Container application names derive from it. Do not put secrets in wrangler.jsonc.

3. apply the schema

The package ships the control schema and the reporting views as SQL text. The Reference Deployment ships the schedule tables. Apply all three with psql:

node -e "import('@patronage/ads-sync').then(({ controlSchemaSql, reportingViewSql }) => console.log(\`\${controlSchemaSql}\n\${reportingViewSql}\`))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
node --experimental-strip-types -e "import('./src/schedule.ts').then(({ scheduleSchemaSql }) => console.log(scheduleSchemaSql))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q

NOTICE lines about columns and views that already exist are normal. The statements are idempotent. Confirm the tables:

psql "$DATABASE_URL" -c "\dt ads_sync.*"

You get 16 tables, including sync_runs, sync_watermarks, and sync_schedule_ticks. The Airbyte schemas airbyte_google_search_console and airbyte_internal do not exist yet. The destination Container creates them on the first run. That is why the role needs database-level CREATE.

4. create the Google Search Console credential

In the Google Cloud console:

  1. Enable the Google Search Console API for your project.
  2. Configure the OAuth consent screen. Add the Google account that owns the Search Console property as a test user.
  3. Create an OAuth client of type Desktop app. Note the client id and client secret.

Get a refresh token. Open this URL in a browser, with your client id in place of the placeholder, and approve the webmasters.readonly scope:

https://accounts.google.com/o/oauth2/v2/auth?client_id=<client-id>&redirect_uri=http://localhost:8080&response_type=code&scope=https://www.googleapis.com/auth/webmasters.readonly&access_type=offline&prompt=consent

The browser redirects to http://localhost:8080/?code=<code>&scope=.... The page does not load because nothing listens there. Copy the code value from the address bar and exchange it:

curl -s https://oauth2.googleapis.com/token \
  -d client_id="<client-id>" \
  -d client_secret="<client-secret>" \
  -d code="<code>" \
  -d grant_type=authorization_code \
  -d redirect_uri=http://localhost:8080

The response contains refresh_token. Write the source config to a file outside the repository. Do not commit it:

mkdir -p ~/.config/ads-sync
cat > ~/.config/ads-sync/gsc-source-config.json <<'EOF'
{
  "authorization": {
    "auth_type": "Client",
    "client_id": "<client-id>",
    "client_secret": "<client-secret>",
    "refresh_token": "<refresh-token>"
  },
  "custom_reports_array": [
    { "dimensions": ["query", "page"], "name": "search_analytics_query_page" }
  ],
  "data_state": "final",
  "site_urls": ["https://example.org/"],
  "start_date": "2026-01-01"
}
EOF

site_urls is the exact property URL as Search Console shows it (https://example.org/ for a URL-prefix property, sc-domain:example.org for a domain property). start_date bounds the connector; the schedule narrows every run to one window from the committed watermark to the Final-Data Horizon.

5. deploy

Dry-run first, then deploy:

pnpm --filter @patronage/ads-sync-deploy build
pnpm --filter @patronage/ads-sync-deploy run deploy

The word run is required for deploy. Without it pnpm executes its own built-in pnpm deploy command and stops with ERR_PNPM_INVALID_DEPLOY_TARGET.

build is a Wrangler dry run. It lists the bindings and the four Container classes and exits. run deploy builds the four connector images from images/ with Docker, pushes them to your Cloudflare account’s container registry, and publishes the Worker with its Cron trigger (0 6 * * *, UTC). The first deploy takes several minutes because of the image builds; with warm Docker layers it takes about one minute. Wrangler prints the Worker URL at the end:

export WORKER_URL="https://patronage-ads-sync-reference-deploy.<your-subdomain>.workers.dev"

6. set the Worker secrets

The Reference Deployment reads exactly three secrets for this connection. Every name is in the managed set the custody adapter enforces; any other name fails closed.

Write the destination config to a second file outside the repository:

cat > ~/.config/ads-sync/postgres-destination-config.json <<'EOF'
{
  "host": "<postgres-host>",
  "port": 5432,
  "database": "<database>",
  "username": "<role>",
  "password": "<password>",
  "schema": "airbyte_google_search_console",
  "raw_data_schema": "airbyte_internal",
  "ssl": true,
  "ssl_mode": { "mode": "require" },
  "jdbc_url_params": "sslmode=verify-full",
  "tunnel_method": { "tunnel_method": "NO_TUNNEL" },
  "unconstrained_number": true,
  "drop_cascade": false
}
EOF

ssl_mode: require is the Airbyte server-auth TLS mode. jdbc_url_params: sslmode=verify-full makes the JDBC driver verify the server certificate against the CA bundle the shipped destination image pins. Keep both.

Now set the secrets:

export ADS_SYNC_RUNNER_TOKEN="$(openssl rand -hex 32)"
printf '%s' "$ADS_SYNC_RUNNER_TOKEN" | pnpm exec wrangler secret put ADS_SYNC_RUNNER_TOKEN
pnpm exec wrangler secret put POSTGRES_DESTINATION_CONFIG_JSON < ~/.config/ads-sync/postgres-destination-config.json
pnpm exec wrangler secret put GOOGLE_SEARCH_CONSOLE_SOURCE_CONFIG_JSON < ~/.config/ads-sync/gsc-source-config.json

Keep ADS_SYNC_RUNNER_TOKEN in your shell; /runs requires it as a bearer token, and Worker secrets cannot be read back. Each secret put publishes a new Worker version with the secret attached. Until the runner token is set, POST /runs answers 503; until the other two are set, the tick fails when the seam reads them.

ads-sync.config.ts already declares this Sync Connection: google_search_console_default, destination schema airbyte_google_search_console, stream search_analytics_query_page, secret name GOOGLE_SEARCH_CONSOLE_SOURCE_CONFIG_JSON. It stores secret names only. You do not need to edit it for this tutorial.

7. run the first Qualified Run

curl -s "$WORKER_URL/health"
curl -s "$WORKER_URL/"

/health answers {"ok":true,"service":"ads-sync-reference-deployment"}. / lists the configured connection, the Supported Providers, and the routes GET /, GET /health, GET /runs, GET /runs/:runId, and POST /runs. To follow a run live, keep pnpm exec wrangler tail --format json open in a second terminal.

Trigger one tick now instead of waiting for 06 UTC:

curl -s -X POST -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"

The answer is 202 with result: "queued", a runId, the read mode final, the Final-Data Horizon, and the window. The Worker hands the run to the RunDispatcher Durable Object (execution: "durable_object_alarm"), whose alarm executes it, so a first run with two cold Containers completes without the request staying open. orphanedRunIds lists runs the tick failed closed because they lost their executor; on a fresh deployment it is empty:

{
  "execution": "durable_object_alarm",
  "horizon": "2026-08-15T00:00:00.000Z",
  "orphanedRunIds": [],
  "readMode": "final",
  "result": "queued",
  "runId": "<uuid>",
  "window": {
    "id": "<uuid>",
    "windowEnd": "2026-08-15T00:00:00.000Z",
    "windowStart": "2026-07-16T00:00:00.000Z"
  }
}

The horizon is UTC midnight three days before today for Google Search Console. Before the first commit the window starts one provider step (30 days) before the horizon. Poll until the run is succeeded:

curl -s -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"

A second POST /runs while this run is queued or running answers 200 with result: "already_active" and the same runId; it never queues a duplicate. The first run starts two Containers cold. The seam retries an unavailable Container up to five times, two seconds apart; that retry is seam behavior, not something you configure. A run that fails with container did not respond to /metadata after 5 attempt(s) names a deployment problem, not a credential problem. Expect a few minutes for the first run; the walkthrough run took 37 seconds from tick to succeeded for a 30-day window.

When the run is succeeded, watermark equals windowEnd, and the rows are in Postgres:

psql "$DATABASE_URL" -c "select count(*) from airbyte_google_search_console.search_analytics_query_page"
psql "$DATABASE_URL" -c "select r.status, r.trigger_type, t.read_mode, t.window_start, t.window_end from ads_sync.sync_runs r left join ads_sync.sync_schedule_ticks t on t.run_id = r.id and t.result <> 'already_active' order by r.started_at desc limit 5"
psql "$DATABASE_URL" -c "select connection_id, watermark_end, run_id from ads_sync.sync_watermarks"

The Reference Deployment writes the window to ads_sync.sync_schedule_ticks (the tick ledger) and the committed watermark to ads_sync.sync_watermarks; the first query shows the succeeded run with its window, and the second shows watermark_end equal to windowEnd. The join excludes already_active ticks: the second POST /runs above wrote one of those with the in-flight run_id and no window, and GET /runs applies the same predicate, so each run appears once with the window of the tick that planned it.

The reporting view ads_sync_reporting.gsc_query_page_daily was created as an empty stub in step 3, because the Airbyte table did not exist yet. Now that it does, apply the reporting views once more; the view then reads the table:

node -e "import('@patronage/ads-sync').then(({ reportingViewSql }) => console.log(reportingViewSql))" \
  | psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q
psql "$DATABASE_URL" -c "select count(*) from ads_sync_reporting.gsc_query_page_daily"

Trigger a second tick:

curl -s -X POST -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"

The answer is 200 with result: "no_new_final_data", the runId of that no-op run, and the committed watermark. The horizon has not moved past the watermark, so no Containers start. (If you created the Hyperdrive config without --caching-disabled, wait one minute after the first success before this tick, or it queues the same window again.) That is a first-class run result. The Cron trigger records the same outcome on days when the provider has no new final data. Every tick, with or without a run, is a row in ads_sync.sync_schedule_ticks (result is queued, no_new_final_data, or already_active).

You now have a scheduled Qualified Run. Each day at 06 UTC the Worker reads from the committed watermark to the new Final-Data Horizon and commits the watermark only after the destination write succeeds. To catch up a stale watermark the tick caps each window at 120 days (four windows of 30 days) and continues on the next tick.

optional: run the HTTP surface locally

You can run the same Worker on your machine. Put the three secret names in the local Wrangler dev vars file beside wrangler.jsonc (never commit that file), and point the local Hyperdrive binding at your database:

WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="$DATABASE_URL" \
  pnpm exec wrangler dev --config wrangler.jsonc --ip 127.0.0.1 --port 8796 --enable-containers=false

--enable-containers=false skips the local image builds, so /health, /, and GET /runs work and POST /runs records a failed run instead of syncing. Local Cron does not fire on its own; curl "http://127.0.0.1:8796/cdn-cgi/handler/scheduled" runs one tick.

cost envelope

The Reference Deployment README carries the cost envelope that ADR 0011 requires: the Workers Paid floor, standard-1 Container active minutes per run, R2 artifact storage and operations, and the Postgres tier. Steady-state daily ticks are cheap; the initial catch-up windows are the peak. Read it before you point the deployment at a large property.

tear down

pnpm exec wrangler delete
pnpm exec wrangler containers list
pnpm exec wrangler containers delete <application-id>   # once per Container application of this Worker
pnpm exec wrangler containers images list
pnpm exec wrangler containers images delete <image>:<tag>   # once per image of this Worker
pnpm exec wrangler r2 object delete ads-sync-raw/<key> --remote   # once per artifact object
pnpm exec wrangler r2 bucket delete ads-sync-raw
pnpm exec wrangler hyperdrive delete <hyperdrive-id>
psql "$DATABASE_URL" -c "drop schema if exists airbyte_google_search_console, airbyte_internal, ads_sync_reporting, ads_sync cascade"

wrangler delete removes the Worker and its Durable Objects but not the four Container applications or the registry images; delete those by id. r2 bucket delete refuses a bucket that still holds objects, and r2 object delete without --remote acts on the local store only. The Cloudflare dashboard can empty the bucket in one action.

Revoke the OAuth grant in your Google account and delete the two config files under ~/.config/ads-sync/. Delete the local files the quickstart created next to wrangler.jsonc: .dev.vars holds the client secret, the refresh token, and the database password, and .wrangler/ holds local state. Both are gitignored, so wrangler delete leaves them in place:

rm -f .dev.vars
rm -rf .wrangler

Last verified: 2026-08-18, independent walkthrough, 35 minutes to the first succeeded run (with a Cron workaround that #1554 has since removed).

next

  • Google Search Console caveats: the seven-day Testing-mode refresh token and the cold-start retry.
  • Verify a bounded run: canary evidence and the Historical Comparison Gate.
  • Ejection Path: the same deployment as the landing zone for a Loop tenant.
  • To sync Google Ads or Meta Ads instead, replace the one connection in ads-sync.config.ts with that provider’s catalog and secret names, and set that provider’s source-config secret. The routes and the schedule are the same.

Was this page helpful?