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

# Store a report in S3

> Run a Five9 report, write its rows out as NDJSON, and land the file in your own S3 bucket where Snowpipe picks it up — with the exact IAM policy to attach

This is the warehouse loop, end to end: **run a report, turn its rows into a file, put the file in your bucket.** Four steps, one flow, and the file's contents never travel through it.

The last step is the whole integration. GetDialed has no Snowflake connector and needs none — Snowpipe already watches an S3 prefix and ingests what lands there. Put the object in the right prefix and you are done.

```
run_report ──► get_report_result ──► create_file ──► aws__s3__put ──► Snowpipe
              (as a record set)      (as ndjson)     (your bucket)
```

## Step 1 — Create the IAM user

The credential is **yours**, and the bucket is yours. Create an IAM user in your own AWS account, scoped to the one bucket you want GetDialed to write into, and hand over its access-key pair. Nothing else in your account is reachable with it.

### The IAM policy

Attach this **inline** to that user, substituting your bucket name. Inline rather than a shared managed policy, so the grant cannot be widened later by a change somebody makes for a different reason.

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GetDialedBucketList",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket"
      ]
    },
    {
      "Sid": "GetDialedObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:AbortMultipartUpload"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket/*"
      ]
    }
  ]
}
```

Two statements, because S3 has two kinds of permission and they take different resource ARNs. The first names the **bucket**; the second names the **objects inside it**, which is what the trailing `/*` is. A policy that puts all five actions on one ARN grants nothing useful, whichever ARN it picks.

<Note>
  **`s3:ListBucket` is on the list even though nothing lists your bucket, and it is not decorative.**

  Without it, S3 answers `403 AccessDenied` for an object that simply is not there — instead of `404`. That is deliberate on S3's part: it stops a caller who is not allowed to see inside a bucket from probing it for what it contains, one key at a time.

  The cost is that a credential lacking `s3:ListBucket` makes "your object is missing" and "your credential is wrong" the same answer, and nothing on our side can tell them apart to tell you. Granting it on the bucket ARN is what lets a failed fetch or delete be diagnosed at all.

  It grants no ability to read, write or remove anything — those are the object actions in the second statement.
</Note>

`s3:AbortMultipartUpload` is there because a large file is uploaded in parts. If an upload fails part way through, the parts have to be cleaned up — and an upload that cannot abort leaves them behind in your bucket, billed to you, invisible in a normal object listing.

**Nothing wider is needed.** No `s3:*`, no second bucket, no account-wide `"Resource": "*"`, and no `iam` or `sts` statement of any kind. `sts:GetCallerIdentity` — the call that identifies which AWS account the key belongs to — requires no permissions at all and cannot be denied by policy, so granting it would only imply that it needed granting.

## Step 2 — Create the credential

Supply the key pair and the bucket's region. `aws_default_bucket` is optional but worth setting: with it, a step that omits `bucket` uses it, so the bucket name lives in one place rather than in every step.

```bash theme={null}
curl -X POST "$BASE_URL/credentials" \
  -H "X-API-Key: $GETDIALED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Warehouse Staging Bucket",
    "platform_id": "aws",
    "auth_method": "aws_access_key",
    "aws_region": "us-east-1",
    "aws_default_bucket": "your-bucket",
    "credentials": {
      "access_key_id": "REPLACE_ME",
      "secret_access_key": "REPLACE_ME"
    }
  }'
```

The create verifies the keys and files the credential under the AWS **Account** they belong to — you never look up or type the account number. Keep the returned `id`; it is the `connection_id` the S3 step selects.

Check the grant before you build a flow on it:

```bash theme={null}
curl -X POST "$BASE_URL/credentials/cred_e5f6g7h8/test" \
  -H "X-API-Key: $GETDIALED_API_KEY"
```

Because a default bucket is configured, this proves **both halves** — that the key pair is valid and that it can actually reach that bucket. Without a default bucket it can only prove the first, and says so in as many words. See [credentials](/concepts/credentials#aws-access-key) for the full method.

## Step 3 — Build the flow

Four tasks. The first two are the Five9 report you already run; the last two are new.

```json theme={null}
{
  "name": "Nightly activity to the warehouse",
  "steps": [
    {
      "step_id": "step_run",
      "tasks": [
        {
          "task_id": "task_run",
          "task_name": "Start the report",
          "platform_id": "five9",
          "service_id": "five9__configuration_service",
          "action_id": "five9__configuration_service__run_report",
          "connection_id": "cred_a1b2c3d4",
          "parameters": {
            "folder_name": "Call Center Reports",
            "report_name": "Daily Activity",
            "criteria": {
              "time": { "start": "2026-08-26", "end": "2026-08-27" }
            }
          }
        }
      ]
    },
    {
      "step_id": "step_report",
      "tasks": [
        {
          "task_id": "task_result",
          "task_name": "Collect the rows as a record set",
          "platform_id": "five9",
          "service_id": "five9__configuration_service",
          "action_id": "five9__configuration_service__get_report_result",
          "connection_id": "cred_a1b2c3d4",
          "parameters": {
            "report_id": "{{ step_run.output.report_id }}",
            "output_mode": "record_set"
          }
        }
      ]
    },
    {
      "step_id": "step_write",
      "tasks": [
        {
          "task_id": "task_write",
          "task_name": "Write it out as NDJSON",
          "platform_id": "getdialed",
          "service_id": "getdialed__files",
          "action_id": "getdialed__files__create_file",
          "parameters": {
            "record_set_id": "{{ step_report.output.record_set_id }}",
            "format": "ndjson",
            "compress": "gzip",
            "filename": "daily-activity.ndjson",
            "tags": { "source": "five9", "load": "nightly" }
          }
        }
      ]
    },
    {
      "step_id": "step_upload",
      "tasks": [
        {
          "task_id": "task_upload",
          "task_name": "Land it where Snowpipe is watching",
          "platform_id": "aws",
          "service_id": "aws__s3",
          "action_id": "aws__s3__put",
          "connection_id": "cred_e5f6g7h8",
          "parameters": {
            "file_id": "{{ step_write.output.file_id }}",
            "key": "snowpipe/five9/daily-activity/{{ system.execution_id }}.ndjson.gz"
          }
        }
      ]
    }
  ]
}
```

### What each of the two new steps is doing

**`create_file` writes the set out and hands on a reference.** `output_mode: "record_set"` on the report step already staged the rows as a sealed [record set](/concepts/record-transforms) and reported its `record_set_id`; `create_file` reads that set as it runs and reports `file_id`, `size_bytes`, `sha256` and `row_count`. A report with three million rows costs the flow exactly what a report with three costs it — see [files](/concepts/files#how-a-file-travels).

`format: "ndjson"` is the shape to pick here. One JSON record per line is what Snowpipe reads a line at a time, so a partially-written or very large file is never a problem for the loader.

`compress: "gzip"` stores the file compressed, and its name gains `.gz`. That suffix is how Snowflake detects the compression on load — there is nothing to configure at the Snowflake end, and nothing to decompress in between.

**`aws__s3__put` streams the file into your bucket.** It takes the `file_id` from the previous step, not its contents. The `key` is the full path within the bucket and you write it out yourself, deliberately: Snowpipe watches a **prefix**, so the path is part of the integration rather than a detail for the platform to invent.

The key above ends in `{{ system.execution_id }}`, which is unique to the run — see [expressions](/concepts/expressions#context-roots) for the other identifiers available. To partition the prefix by date instead, pass the load date in with the trigger and format it in the key:

```text theme={null}
snowpipe/five9/daily-activity/{{ input.load_date | formatDate: '%Y/%m/%d' }}/activity.ndjson.gz
```

<Warning>
  **An object already at that key is replaced.** A key that does not vary per run quietly overwrites yesterday's load with today's, and Snowpipe may or may not have ingested it first. Put something that changes — the execution id, a date, a batch id — into the path.
</Warning>

## Step 4 — Point Snowpipe at the prefix

Nothing more is needed from GetDialed. On the Snowflake side, create a stage over the same bucket and prefix and a pipe that auto-ingests from it, with a JSON file format for the NDJSON you wrote.

Leave the file format's compression setting on its automatic default, which is what reads the `.gz` suffix `create_file` gave the object — that is the whole reason the suffix is worth having. Consult Snowflake's own documentation for the storage integration, stage, file format and pipe: that half lives entirely in your Snowflake account, and its options change on Snowflake's schedule rather than ours.

The object landing in the bucket **is** the integration. There is no connector to install and no credential of yours that GetDialed holds for Snowflake.

## Confirming a load

Two things are worth recording per run, and both come back from the steps you already have.

`row_count` from `create_file` reconciles against the report's own `row_count` — if they disagree, the file is not what the report said it was. And `sha256` is computed over the bytes as stored, so anything that downloads the object can compute the same value and prove it received the file unchanged.

To check a file before uploading it — skipping an empty load, for instance — put `getdialed__files__get_file_metadata` between the two steps and branch on `size_bytes`.

<Note>
  **Cleaning up.** The stored file is kept until you delete it. If the copy in your bucket is the one that matters, either add `getdialed__files__delete_file` as a final step, or set `expires_in_days` on `create_file` to `1`, `7`, `30` or `90` and let it lapse on its own. Deleting the stored file does not touch the object you uploaded.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Files" icon="file-lines" href="/concepts/files">
    Formats, compression, tags, retention, and all six file steps.
  </Card>

  <Card title="Credentials" icon="key" href="/concepts/credentials#aws-access-key">
    The AWS credential method in full, including S3-compatible storage.
  </Card>

  <Card title="Schedule a flow" icon="clock" href="/guides/schedule-a-flow">
    Make it nightly.
  </Card>

  <Card title="Record transforms" icon="wand-magic-sparkles" href="/concepts/record-transforms">
    Filter or reshape the report's rows before writing the file.
  </Card>
</CardGroup>
