---
title: Connector image HTTP contract
description: Exact HTTP surface of the Ads Sync connector images, the metadata verification consumers must do, and the build inputs that produce the images.
---

Every Ads Sync connector image runs one Python HTTP wrapper, `airbyte-source-server.py`, in front of a pinned Airbyte connector. A Qualified Run talks to that wrapper over HTTP; it never runs the connector command directly. This page is the wrapper contract for wrapper version `ads-sync-wrapper-v0.3`.

The build inputs live in the Ads Sync Reference Deployment at [`packages/ads-sync/deploy/images/`](https://github.com/patronage/agentic-marketing-connectors/tree/main/packages/ads-sync/deploy/images). Consumers build the images themselves; the project publishes Dockerfiles, not built images.

## images

| image | Dockerfile | base image (digest pinned in `FROM`) |
| --- | --- | --- |
| Google Ads source | `Dockerfile` | `airbyte/source-google-ads:6.1.0@sha256:dea39deedba0a095f60159d808dfb47fa778e304846396d2ab2f04c951b480ed` |
| Google Search Console source | `Dockerfile.gsc` | `airbyte/source-google-search-console:2.1.9@sha256:3ee78d227a25ec01a31b9f131b1b8d80afd6e3aaf0c0c2f1b09c7973190465b3` |
| Meta Ads source | `Dockerfile.meta` | `airbyte/source-facebook-marketing:5.2.11@sha256:4d6c916b29862ded4b5b94feea0b8ef75899f34c364e4884312e50414b6d447c` |
| Postgres destination | `Dockerfile.postgres` | `airbyte/destination-postgres:3.0.13@sha256:0b310bd46ba0e006757ea3dc1d3b8ef8e3bcf51c3a96f5460a836653b5ac4f4c` |

The pins equal `supportedImageVersions` in `@patronage/ads-sync`. Each Dockerfile also sets `AIRBYTE_CONNECTOR_IMAGE` to the same reference; `/metadata` reports it as `connectorImage`.

### build command

```bash
IMAGE_REGISTRY=registry.example.com/ads-sync bash build-images.sh --push
```

Run `build-images.sh` with `bash` from `packages/ads-sync/deploy/images/`; the public tree does not preserve the executable bit. Without `--push` it loads the images into the local Docker daemon. `IMAGE_TAG` defaults to `0.3.0` and `IMAGE_PLATFORM` to `linux/amd64`.

### exact-match build steps

| build argument | default | alternative | step |
| --- | --- | --- | --- |
| `GSC_AUTH_MODE` | `refresh_token` | `access_token` | `patch-gsc-manifest-auth.py` rewrites the pinned manifest OAuth authenticator to a `BearerAuthenticator` that reads `authorization.access_token` |
| `POSTGRES_SCHEMA_MODE` | `airbyte_owned` | `preprovisioned` | `patch-postgres-preprovisioned-schema.py` rewrites the connector jar constant `CREATE SCHEMA IF NOT EXISTS <schema>;` to `SELECT 1 /* preprovisioned <schema> */;` |
| always |  |  | `pin-postgres-java-cacerts.sh` copies the destination image CA bundle to `/app/java-cacerts`; `Dockerfile.postgres` then sets `DESTINATION_POSTGRES_OPTS` to that JKS trust store |

Each patch script requires the pinned upstream text to match exactly once. On any drift it exits non-zero and the image build fails. The defaults are the single-tenant mode: the operator owns the Google refresh token, and the destination writer role has database-level `CREATE` so Airbyte owns schema DDL. TLS is never lowered; keep `sslmode=verify-full` in the destination JDBC parameters.

## wrapper environment

| variable | set by | effect |
| --- | --- | --- |
| `AIRBYTE_CONNECTOR_COMMAND` | Dockerfile | connector entrypoint the wrapper runs |
| `AIRBYTE_CONNECTOR_IMAGE` | Dockerfile | value of `connectorImage` in `/metadata` |
| `AIRBYTE_CONNECTOR_SERVICE` | Dockerfile | value of `service` in `/metadata` |
| `AIRBYTE_IMAGE_BUILD_OPTIONS` | Dockerfile | value of `buildOptions` in `/metadata`, for example `postgres_schema_mode=airbyte_owned` |
| `AIRBYTE_READ_TIMEOUT_SECONDS` | Dockerfile or runtime | `/read` and `/read-artifact` wall-clock budget; default `1860`, Google Ads image sets `330` |
| `AIRBYTE_WRITE_TIMEOUT_SECONDS` | runtime | `/write` wall-clock budget; default `1260` |
| `AIRBYTE_JAVA_TRUST_STORE` | `Dockerfile.postgres` | JKS path that `/network-check` reports on |
| `AIRBYTE_NETWORK_CHECK_PORTS` | runtime | comma-separated port allowlist for `/network-check`; default `5432` |
| `AIRBYTE_WRAPPER_PORT` | runtime | listen port; default `8080` |

## endpoints

The wrapper listens on port `8080` on all interfaces (`0.0.0.0`). Every response is `application/json`.

### network posture

The wrapper has no authentication on any route. Under Cloudflare Containers the port is reachable only through the Durable Object binding, and that network isolation is the only access control. `/write` runs a destination write with a caller-supplied config, and `/network-check` opens a TCP connection to a caller-supplied host on an allowlisted port. Do not expose the container port on a host or a shared network. When you run an image outside Cloudflare Containers (for example under `docker run` for provider admission), bind the port to loopback only.

| method and path | request body | success | failure |
| --- | --- | --- | --- |
| `GET /metadata` (also `GET /` and `GET /health`) | none | `200` metadata object | none |
| `GET /spec` | none | `200` run result | `502` run result, `504` timeout |
| `POST /check` | connector config object | `200` run result | `400`, `502`, `504` |
| `POST /discover` | connector config object | `200` run result | `400`, `502`, `504` |
| `POST /read` | `{ config, catalog, state? }` | `200` run result without output | `400`, `502`, `504` |
| `POST /read-artifact` | `{ config, catalog, state? }` | `200` run result with `stdout` and `stderr` | `400`, `502`, `504` |
| `POST /write` | `{ config, catalog, messages, include_output? }` | `200` run result | `400`, `502`, `504` |
| `POST /network-check` | `{ host, port }` | `200` `{ connected: true, trustStore }` | `400`, `502` |
| any other path |  |  | `404 { "error": "Not found" }` |

Any `POST` body must be a JSON object; a missing, non-JSON, or non-object body returns `400 { "error": ... }`.

### `/metadata`

```json
{
  "buildOptions": "postgres_schema_mode=airbyte_owned",
  "connectorImage": "airbyte/destination-postgres:3.0.13@sha256:0b310bd46ba0e006757ea3dc1d3b8ef8e3bcf51c3a96f5460a836653b5ac4f4c",
  "service": "airbyte-postgres-destination",
  "status": "ok",
  "wrapperVersion": "ads-sync-wrapper-v0.3"
}
```

`buildOptions` is an empty string on images without build modes.

### run result

`/spec`, `/check`, `/discover`, `/read`, `/read-artifact`, and `/write` return the same shape:

```json
{
  "args": ["read", "--config", "<config>", "--catalog", "<config>"],
  "exitCode": 0,
  "stderrLength": 1234,
  "stdoutLength": 56789,
  "success": true
}
```

`args` redacts every temporary JSON path as `<config>`. The status is `200` when the connector exits `0` and `502` otherwise; the body is identical in both cases. `/read-artifact` always adds `stdout` and `stderr` as strings; `/write` adds them only when `include_output` is `true`. Consumers must treat `stdout` and `stderr` as untrusted text that can contain credentials and must apply artifact bounds and redaction before storing them. The Qualified Run seam does this for every artifact body it persists.

The wrapper writes each request object to a temporary file for the connector process. It enters the cleanup block before the first write, so a failed later write (for example, disk full while writing the catalog) still deletes the config file that holds the credential.

When the connector exceeds the wall-clock budget the wrapper returns `504 { "args": [...], "error": "Connector timed out" }`. The wrapper starts the connector under `/usr/bin/timeout` with a 30-second inner margin (60 seconds for `/write`) so the connector process ends before the HTTP budget does.

The connector always gets at least 30 seconds inside the budget. When `AIRBYTE_READ_TIMEOUT_SECONDS` or `AIRBYTE_WRITE_TIMEOUT_SECONDS` is smaller than the inner margin plus that floor (60 seconds for `/read`, 90 for `/write`), the wrapper does not start the connector. It returns `500 { "args": [...], "error": "Connector timeout budget is too small", "minimumTimeoutSeconds": <n>, "timeoutSeconds": <configured> }`. This is an image configuration error, not a run result.

### `/read-artifact`

`config` and `catalog` must be objects; `state` is optional and passed through as `--state`. The `catalog` is the configured Airbyte catalog for the Sync Connection. The wrapper writes each object to a temporary file inside the container, runs `read`, deletes the files, and returns the run result with `stdout` (Airbyte protocol messages, one per line) and `stderr`. Use `/read` when only the exit code matters.

### `/write`

`config` and `catalog` must be objects and `messages` must be a string of newline-delimited Airbyte protocol messages, which the wrapper passes to the connector on stdin. The destination writes only what the compiled destination input contains; state commit is the caller's decision after a successful write.

### `/network-check`

`host` must be a non-empty hostname string and `port` an integer in the `AIRBYTE_NETWORK_CHECK_PORTS` allowlist; other values return `400`. The wrapper opens one TCP connection with a 10-second timeout and does not send data or credentials.

```json
{
  "connected": true,
  "trustStore": {
    "destinationOptionsPresent": true,
    "keytoolReadable": true,
    "path": "/app/java-cacerts",
    "readable": true
  }
}
```

`trustStore` is `null` on images without `AIRBYTE_JAVA_TRUST_STORE`. A failed connection returns `502 { "connected": false, "errorType": "<Python exception class>" }`, for example `TimeoutError` (`timeout` on the older Python in the pinned destination image) or `ConnectionRefusedError`. Use it as an image-level readiness probe for the destination: it proves the container can reach the Postgres host and that the pinned JVM trust store is readable before a `/write` spends the connector budget.

## metadata verification

Before the first connector call of every Qualified Run, the consumer must:

1. `GET /metadata`.
2. Compare `connectorImage` with `supportedImageVersions[provider]` (or `supportedImageVersions.destination`) as an exact string, digest included.
3. Compare `wrapperVersion` with the exact string `ads-sync-wrapper-v0.3`.
4. Fail the run on any mismatch. Do not retry a mismatch; a responding wrapper with the wrong image or version is a deployment error, not a transient condition.

Prefix, tag-only, or semver-range comparisons are not the contract. The pins are what the tests in `packages/ads-sync/deploy/images/images.test.ts` and the Qualified Run seam assert.

## cold-start retry

A newly started Cloudflare Container can answer `/metadata` with a transient platform error, or refuse the connection, before the wrapper process listens. The Qualified Run seam performs a bounded, unavailable-only retry:

- retry only when the request did not reach a healthy wrapper: connection failure, or any `5xx` response (the body is not inspected on a `5xx`);
- never retry a `200` whose fields mismatch, and never retry a `4xx`;
- stop after the bounded attempt budget and fail the run.

The retry is seam behavior. A consumer that calls the wrapper directly must implement the same rule; the wrapper itself has no retry.

## versioning

`WRAPPER_VERSION` in `airbyte-source-server.py` changes whenever this contract changes. Consumers pin the exact string; the seam and the internal Paitronage app pin `ads-sync-wrapper-v0.3`. Adding an endpoint, changing a response field, or changing an error status is a version bump.
