> ## Documentation Index
> Fetch the complete documentation index at: https://docs.interchange.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Delivery

> Ship log-level delivery data (impressions, clicks, conversions, MMP postbacks, VAST events, MB delivery) from Scope3 to a buyer-owned GCS bucket, S3 bucket, or Azure Blob container on a recurring cadence.

## Overview

Data Delivery is a standing subscription that pushes log-level data (LLD) from Scope3 into a destination you own — your cloud storage, your IAM, your data pipeline. Unlike `/api/v2/buyer/reporting/metrics` (which returns aggregates synchronously), Data Delivery runs on a schedule, writes objects, and never makes you pull.

Two objects make it work, both scoped to an advertiser:

| Object                       | What it is                                                                                                                                                                                                                                              |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Data Delivery Credential** | A named handle that tells Scope3 *where* to write and how to authenticate. Probed asynchronously; carries a `status` of `PENDING`, `VALIDATED`, or `FAILED`.                                                                                            |
| **Data Delivery Output**     | A standing subscription that says "ship `<data type>` to `<credential>` every `<cadence>` as `<format>` under `<pathPrefix>`". Multiple Outputs can share a credential. Campaign-scoped Outputs override the advertiser default for the same data type. |

Available data types: `MB_DELIVERY`, `IMPRESSIONS`, `CLICKS`, `VAST_EVENTS`, `CAPI_ATTRIBUTION`, `MMP_POSTBACKS`. Cadences: `HOURLY` (minute 0), `DAILY` (00:00 UTC), `WEEKLY` (00:00 UTC on `syncWeeklyDay`). Formats: `JSONL`, `PARQUET`, `CSV`.

Limits: up to 20 credentials and 20 Outputs per advertiser. One Output per `(dataDeliveryType, credentialName)` pair per scope — to fan a data type out to two destinations, list one Output per destination.

```
BASE = https://api.interchange.io/api/v2/buyer
AUTH = Authorization: Bearer scope3_<your_api_key>
```

## End-to-end shape

The high-level flow is the same regardless of destination:

<Steps>
  <Step title="Grant Scope3 access on your cloud">
    Add a bucket/container that Scope3 can write into and grant the Scope3 principal the required permissions. Details per destination below.
  </Step>

  <Step title="Register the credential">
    `PUT /advertisers/:advertiserId` with a `dataDelivery.credentials[]` entry. The credential lands as `status: PENDING`.
  </Step>

  <Step title="Wait for the Probe">
    Scope3 asynchronously writes a sentinel object under `_scope3-probe/` and deletes it. The credential flips to `VALIDATED` on success or `FAILED` (with `statusError`) on failure.
  </Step>

  <Step title="Attach Outputs">
    `PUT /advertisers/:advertiserId` with `dataDelivery.outputs[]` referencing the credential by `credentialName`. Optionally override per campaign via `PUT /campaigns/:campaignId` `dataDelivery.outputs[]`.
  </Step>

  <Step title="Receive data on the cadence">
    Each Output fires on its schedule and lands objects under `<destination>/<pathPrefix>/...`. Open the bucket/container to confirm.
  </Step>
</Steps>

<Note>
  `dataDelivery.credentials` and `dataDelivery.outputs` are **full-replace arrays**. The request body is the new desired state — anything not listed is archived (credentials) or cleared (Outputs). Read the current state with `GET /advertisers/:advertiserId` before writing. A credential cannot be archived while any live Output (advertiser- or campaign-scoped) still references it — remove or repoint the Output first.
</Note>

## Destination setup

<Tabs>
  <Tab title="Google Cloud Storage">
    ### 1. Grant access on your bucket

    Scope3 authenticates to GCS with a single service account. Grant it `roles/storage.objectCreator` on the bucket you want data shipped into. No keys or secrets are exchanged.

    **Scope3 service account:**

    ```
    report-delivery@swift-catfish-337215.iam.gserviceaccount.com
    ```

    Grant via `gcloud`:

    ```bash theme={null}
    gcloud storage buckets add-iam-policy-binding gs://my-scope3-data \
      --member="serviceAccount:report-delivery@swift-catfish-337215.iam.gserviceaccount.com" \
      --role="roles/storage.objectCreator"
    ```

    ### 2. Register the credential

    ```bash theme={null}
    curl -X PUT "$BASE/advertisers/12345" \
      -H "Authorization: Bearer scope3_<your_api_key>" \
      -H "Content-Type: application/json" \
      -d '{
        "dataDelivery": {
          "credentials": [
            {
              "name": "primary-gcs",
              "config": {
                "type": "GCS",
                "bucket": "my-scope3-data"
              }
            }
          ]
        }
      }'
    ```

    `name` must be unique among live credentials on this advertiser. Outputs will reference it by this name.
  </Tab>

  <Tab title="Amazon S3">
    ### 1. Grant access via bucket policy

    Scope3 authenticates to S3 with a shared IAM user. Add a statement to your bucket policy granting that principal `s3:PutObject` and `s3:DeleteObject` on objects under the path prefix you'll use. No per-buyer access keys are needed.

    **Scope3 IAM principal:**

    ```
    arn:aws:iam::948454267882:user/scope3-data-sync-service
    ```

    Example bucket policy statement (replace `my-scope3-data`):

    ```json theme={null}
    {
      "Sid": "AllowScope3DataDelivery",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::948454267882:user/scope3-data-sync-service"
      },
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-scope3-data/*"
    }
    ```

    `s3:DeleteObject` is recommended so the Probe can clean up its `_scope3-probe/` sentinel objects; with `s3:PutObject` only, sentinels accumulate but the credential still validates.

    ### 2. Register the credential

    ```bash theme={null}
    curl -X PUT "$BASE/advertisers/12345" \
      -H "Authorization: Bearer scope3_<your_api_key>" \
      -H "Content-Type: application/json" \
      -d '{
        "dataDelivery": {
          "credentials": [
            {
              "name": "primary-s3",
              "config": {
                "type": "S3",
                "bucket": "my-scope3-data",
                "region": "us-east-1"
              }
            }
          ]
        }
      }'
    ```

    `region` accepts any commercial AWS region (e.g., `us-east-1`, `eu-west-1`). GovCloud and ISO partitions are not currently supported — contact Scope3 if you need them.
  </Tab>

  <Tab title="Azure Blob Storage">
    ### 1. Mint a SAS for the container

    Scope3 authenticates to Azure with a **Shared Access Signature (SAS)**. Either flavor works — pick the one your security tooling produces.

    In both flavors, the required permissions are **Create + Write + Delete** (`sp` includes `c`, `w`, `d`). The Probe and delivery worker perform `PUT Blob`, block uploads, and `DELETE Blob` — they do not use Append or List.

    #### Option A — Service SAS scoped to the container (recommended)

    Narrower grant; the SAS is bound to one container, no implicit account-level access.

    ```bash theme={null}
    az storage container generate-sas \
      --account-name datareports \
      --name scope3-data \
      --permissions cwd \
      --expiry 2027-01-01T00:00:00Z \
      --https-only \
      --output tsv
    ```

    The resulting SAS has `sr=c&sp=cwd` — `sr=c` binds it to the container and `sp=cwd` authorizes blob operations inside it. **No `srt` field.**

    <Warning>
      A **blob-scoped Service SAS** (`sr=b`) does **not** work. The Probe writes to a randomized `_scope3-probe/<uuid>.txt` path; a blob-scoped SAS is bound to one fixed blob name and the URLs will not match. Always scope Service SAS to the container (`sr=c`).
    </Warning>

    #### Option B — Account SAS

    If your org policy requires Account SAS instead, the `srt` field **must include `o` (Object)** — the Probe and the delivery worker perform object-level operations (`PUT Blob`, `DELETE Blob`). `srt=co` is the common shape; `srt=o` alone also works.

    ```bash theme={null}
    az storage account generate-sas \
      --account-name datareports \
      --services b \
      --resource-types co \
      --permissions cwd \
      --expiry 2027-01-01T00:00:00Z \
      --https-only \
      --output tsv
    ```

    The resulting SAS has `ss=b&srt=co&sp=cwd`. `srt=s` (Service only) or `srt=c` (Container only) **will not work** — Container-only authorizes operations *on* the container resource, not on the blobs inside it.

    <Warning>
      The SAS token is a secret. Submit only over HTTPS and never paste it into shared docs or tickets. Scope3 stores it encrypted, redacts it from all `GET` responses, and surfaces only the parsed `expiresAt` back.
    </Warning>

    ### 2. Register the credential

    ```bash theme={null}
    curl -X PUT "$BASE/advertisers/12345" \
      -H "Authorization: Bearer scope3_<your_api_key>" \
      -H "Content-Type: application/json" \
      -d '{
        "dataDelivery": {
          "credentials": [
            {
              "name": "primary-azure",
              "config": {
                "type": "AZURE_BLOB",
                "storageAccountName": "datareports",
                "containerName": "scope3-data",
                "auth": {
                  "mode": "SAS_TOKEN",
                  "sasToken": "sv=2022-11-02&se=2027-01-01T00%3A00%3A00Z&sr=c&sp=cwd&sig=..."
                }
              }
            }
          ]
        }
      }'
    ```

    Leading `?` on the SAS is tolerated and stripped. The `se=` claim is parsed and surfaced as `credential.expiresAt` — see [Rotation](#rotation) below.
  </Tab>
</Tabs>

## Probe lifecycle

When a credential is created or its config changes, Scope3 kicks off an async **Probe** workflow keyed on the credential. The Probe writes a sentinel object to the destination, deletes it, and updates the credential row:

| `status`    | Meaning                                                                               |
| ----------- | ------------------------------------------------------------------------------------- |
| `PENDING`   | Just created or rotated; Probe hasn't completed yet. Typically clears within seconds. |
| `VALIDATED` | Probe wrote and deleted a sentinel object. `validatedAt` reflects the run.            |
| `FAILED`    | Probe could not write. `statusError` carries a human-readable reason.                 |

Check status with:

```bash theme={null}
curl "$BASE/advertisers/12345" \
  -H "Authorization: Bearer scope3_<your_api_key>"
# inspect dataDelivery.credentials[].status / statusError
```

Re-run the Probe without re-submitting the credential:

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/data-delivery-credentials/primary-gcs/validate" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

<Note>
  Outputs can reference a credential in any status. The shipping workflow re-checks at run time, so a credential that flips to `FAILED` will block its Outputs until you fix it; one that flips back to `VALIDATED` resumes automatically.
</Note>

## Attaching Outputs

Outputs live alongside credentials in the same `dataDelivery` block. The example below ships hourly impressions and daily clicks to the same GCS credential, with each data type writing under a distinct path prefix.

```bash theme={null}
curl -X PUT "$BASE/advertisers/12345" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "dataDelivery": {
      "outputs": [
        {
          "dataDeliveryType": "IMPRESSIONS",
          "cadence": "HOURLY",
          "credentialName": "primary-gcs",
          "deliveryConfig": {
            "type": "GCS",
            "pathPrefix": "scope3/impressions/",
            "format": "PARQUET"
          }
        },
        {
          "dataDeliveryType": "CLICKS",
          "cadence": "DAILY",
          "credentialName": "primary-gcs",
          "deliveryConfig": {
            "type": "GCS",
            "pathPrefix": "scope3/clicks/",
            "format": "JSONL"
          }
        }
      ]
    }
  }'
```

### Rules to know

* **`credentialName` must point at a live credential on the same advertiser.** The `deliveryConfig.type` must match the credential's `destinationType` (you can't ship to S3 through a GCS credential).
* **One Output per `(dataDeliveryType, credentialName)`.** To send the same data type to two destinations, list two Outputs with different credentials.
* **`syncWeeklyDay` is required when `cadence: "WEEKLY"`** (0 = Sunday, 6 = Saturday). Ignored for HOURLY/DAILY.
* **`pathPrefix` is used verbatim** — leading slashes are not stripped and the prefix is not templated. End it with `/` if you want a directory-like layout.
* **`enabled: false`** pauses the schedule without removing the Output. In-flight runs complete; no new runs fire until you flip it back.

### Campaign-scoped overrides

For a single campaign that needs a different cadence, format, or destination than its advertiser default, set `dataDelivery.outputs` on the campaign:

```bash theme={null}
curl -X PUT "$BASE/campaigns/99999" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "dataDelivery": {
      "outputs": [
        {
          "dataDeliveryType": "IMPRESSIONS",
          "cadence": "HOURLY",
          "credentialName": "primary-gcs",
          "deliveryConfig": {
            "type": "GCS",
            "pathPrefix": "scope3/campaign-99999/impressions/",
            "format": "PARQUET"
          }
        }
      ]
    }
  }'
```

The override replaces the advertiser-scoped Output for the matching `dataDeliveryType` on this campaign only. Resolved Outputs in `GET` responses are tagged with `source: "advertiser"` or `source: "campaign"` so it's clear which level produced each entry.

## Rotation

### GCS and S3

No rotating secret to manage — Scope3's identity is fixed, so as long as the IAM grant stays in place the credential keeps working. Rotating means changing the bucket: submit a new credential with the new `bucket` and the existing credentials inline-array will update in place.

### Azure Blob

SAS tokens carry an expiry. The expiry from the `se=` claim is parsed and exposed on the credential response as `expiresAt` — watch it and rotate ahead of time.

To rotate, submit a fresh credential **with the same `name`**:

```bash theme={null}
curl -X PUT "$BASE/advertisers/12345" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "dataDelivery": {
      "credentials": [
        {
          "name": "primary-azure",
          "config": {
            "type": "AZURE_BLOB",
            "storageAccountName": "datareports",
            "containerName": "scope3-data",
            "auth": { "mode": "SAS_TOKEN", "sasToken": "<new SAS>" }
          }
        }
      ]
    }
  }'
```

Updating in place preserves the `credentialId`, so any Output that references this credential by name keeps shipping without interruption. A fresh Probe runs against the new SAS; an in-flight Probe for the old SAS is cancelled.

## Troubleshooting

### Credential stuck on `PENDING`

The Probe workflow typically completes within seconds. If it's been more than a minute:

1. Re-fetch the advertiser — the row may have already flipped.
2. Call the revalidate endpoint to force a fresh Probe.
3. If it stays `PENDING`, contact Scope3 support — the Probe worker may be backed up.

### `status: FAILED` — common `statusError` patterns

| Destination | `statusError` includes...                                             | Fix                                                                                                                                                                                                                |
| ----------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GCS         | `permission`, `403`, `does not have storage.objects.create`           | Grant `roles/storage.objectCreator` to `report-delivery@swift-catfish-337215.iam.gserviceaccount.com` on the bucket.                                                                                               |
| GCS         | `bucket ... does not exist`                                           | Check the bucket name in `config.bucket` matches an existing bucket in the project that hosts the SA.                                                                                                              |
| S3          | `AccessDenied`, `403`                                                 | Confirm the bucket policy grants `s3:PutObject` to `arn:aws:iam::948454267882:user/scope3-data-sync-service` on `Resource: arn:aws:s3:::<bucket>/*`.                                                               |
| S3          | `PermanentRedirect`, `region` mismatch                                | `config.region` must match the bucket's actual region.                                                                                                                                                             |
| S3          | `NoSuchBucket`                                                        | Bucket name typo, or the bucket isn't in a commercial AWS partition.                                                                                                                                               |
| Azure       | `Azure SAS token expired at ...`                                      | The `se=` claim is in the past. Mint a new SAS and update the credential.                                                                                                                                          |
| Azure       | `AuthenticationFailed`                                                | SAS signature is wrong (truncated paste, wrong `sv=` version, signed against a different key). Regenerate.                                                                                                         |
| Azure       | `AuthorizationPermissionMismatch`                                     | SAS is valid but missing required permissions. Confirm `sp` includes `c`, `w`, `d` (`sp=cwd`). For Account SAS, also confirm `ss=b`.                                                                               |
| Azure       | `AuthorizationResourceTypeMismatch`                                   | **Account SAS only** — `srt` is missing `o` (Object). The Probe and delivery worker perform blob-level operations, so `srt` must include `o` (e.g., `srt=co`). `srt=s` or `srt=c` alone will not work. Regenerate. |
| Azure       | `AuthorizationFailure` on `PUT /<container>/_scope3-probe/<uuid>.txt` | Service SAS was scoped to a single blob (`sr=b`) instead of the container (`sr=c`). Regenerate with `az storage container generate-sas` so the SAS covers any blob name under the container.                       |
| Azure       | `ContainerNotFound`                                                   | `containerName` doesn't exist on the storage account, or the SAS was issued at a different scope.                                                                                                                  |

### Output created but no objects landing

1. Verify the referenced credential is `VALIDATED`.
2. Check `enabled` on the Output — if `false`, the schedule is paused.
3. For HOURLY cadence, allow a full hour boundary to pass before the first run. DAILY fires at 00:00 UTC, WEEKLY at 00:00 UTC on `syncWeeklyDay`.
4. Confirm there is delivery data for the period — a campaign with zero impressions on a given day produces no objects, not an empty file.

### Conflicting Outputs

If `PUT` returns a validation error about duplicate `(dataDeliveryType, credentialName)` pairs, it's because the inline array contains two Outputs with the same data type pointing at the same credential. Use distinct credentials, or merge into a single entry.

## Related

* [Reporting Overview](/v2/guides/reporting-overview) — pull-style aggregate metrics (the synchronous counterpart to Data Delivery).
* [Log Events](/v2/guides/log-events) — push events *into* Scope3 (the inbound counterpart to outbound Data Delivery).
