# Push templates and definitions from CI

You build your emails in your own repository, with your own renderer and your own
review process. SendOps is where they deploy to. This page is about the step that
connects the two.

The endpoint is `POST /v1/import`. It takes a **bundle** — a directory with
`sendops.json` at its root plus every file that manifest names — and applies it
through the same authoring services the dashboard and the API use. There is no
connection to configure, no repository to grant access to, and no webhook to wait
for. Your pipeline pushes; the API answers with what it did.

## The Action

For GitHub Actions, use the published Action rather than calling the endpoint by
hand:

```yaml
- uses: AltaCoda/sendops-push@v1
  with:
    dir: ./sendops
    api_key: ${{ secrets.SENDOPS_API_KEY }}
```

It reads the directory, posts it, and renders the result as a job summary: every
object, what happened to it, and any diagnostic positioned by line and column. It
fails the step when the bundle does not apply, when a template fails to deploy to
SES, or when the API refuses the key.

## Plan on the pull request, apply on merge

The recommended shape is one job, two modes. On a pull request it plans; on merge
it applies. A reviewer sees exactly what merging will do before it happens.

```yaml
name: SendOps

on:
  pull_request:
    paths: ["emails/**"]
  push:
    branches: [main]
    paths: ["emails/**"]

concurrency:
  group: sendops-${{ github.ref }}
  cancel-in-progress: false

jobs:
  sendops:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }

      - run: npm ci
      - run: npm run export:sendops   # your build; writes ./sendops

      - uses: AltaCoda/sendops-push@v1
        with:
          dir: ./sendops
          api_key: ${{ secrets.SENDOPS_API_KEY }}
          dry_run: ${{ github.event_name == 'pull_request' }}
```

`dry_run` is the only difference between the two modes. That is deliberate: a
plan that ran a different code path from the apply would not be a plan.

## Scopes

The key needs the `.manage` scope of each kind the bundle **contains**, and
nothing else. A key that deploys templates and cannot touch your audience is a
supported configuration, not a degraded one.

| Bundle section | Scope |
| --- | --- |
| `templates` | `api.templates.manage` |
| `segments` | `api.segments.manage` |
| `lists` | `api.lists.manage` |
| `topics` | `api.topics.manage` |
| `attributes` | `api.attributes.manage` |
| `activity-properties` | `api.activities.manage` |
| `workflows` | `api.workflows.manage` |
| `images` | `api.assets.manage` |

A missing scope is a `403` that names the one it needs, so you never have to
guess which of eight was short.

## Calling the endpoint directly

`POST /v1/import` takes either a zip of the bundle directory or a JSON document
of the same files:

```json
{
  "manifest": { "templates": { "welcome": { "path": "templates/welcome.html" } } },
  "files":    { "templates/welcome.html": "<!doctype html>…" },
  "images":   { "static/logo.png": "iVBORw0KGgo…" }
}
```

`manifest` is the parsed `sendops.json`. `files` carries text verbatim; `images`
carries binary content base64-encoded. The split is by encoding, not by kind.

```bash
curl -X POST "https://api.sendops.dev/v1/import?dry_run=true" \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/zip" \
  --data-binary @bundle.zip
```

Two query parameters decide what happens:

- **`dry_run`** defaults to **`true`**. A call that omits it returns the plan and
  writes nothing. The default is the safe one because this endpoint is reached by
  pipelines, and a misconfigured job should print a plan rather than rewrite an
  organization. Note that the GitHub Action defaults it to `false` instead: a
  workflow that added the step meant to deploy.
- **`prune`** defaults to **`false`**. With `prune=true`, objects your
  organization has and the bundle omits are archived (segments, lists, topics,
  workflows) or deleted (attributes, activity properties). It applies only to the
  sections the bundle declares, so a templates-only push never archives an
  audience, and an attribute a remaining segment references is skipped with
  `referenced_by` naming what blocked it.

## What comes back

The response is the plan, and on an apply the per-object results:

```json
{
  "dry_run": true,
  "plan": {
    "applicable": true,
    "prune": false,
    "totals": { "create": 2, "unchanged": 53 },
    "objects": [
      { "kind": "template", "key": "welcome", "path": "templates/welcome.html", "action": "create", "diagnostics": [] }
    ],
    "diagnostics": []
  },
  "applied": false,
  "objects": []
}
```

Four properties are worth designing around:

**Applicable or nothing.** If any object fails validation, `applicable` is
`false`, every diagnostic is positioned by line and column, and nothing is
written — not even the objects that passed. A bundle states one intended state,
and applying half of it produces a state nobody asked for.

**Idempotent.** Applying the same bundle twice yields an all-`unchanged` plan and
writes nothing the second time. No version snapshot, no SES deploy, no segment
re-evaluation. This is what makes the step safe to run on every commit.

**Ordered.** Objects are applied — and listed — in dependency order: attributes,
activity properties, lists, segments, topics, images, templates, workflows. Each
step's validation needs the previous step's rows, and images precede templates
because a template deploys with its image URLs embedded.

**Honest about partial results.** The services open their own transactions, so a
failure part-way leaves the earlier kinds written. `applied_until` names the last
kind that completed and `error` says what stopped it.

A workflow created by an import lands as a **draft**. An existing active workflow
updated by one stays active.

## Exporting

`GET /v1/export` is the other direction: the same bundle format, built from your
organization's current state. Ask for `Accept: application/zip` for the archive
or anything else for the JSON form. It is how you start pushing from a repository
that has nothing in it yet, and how you keep a diffable copy in git without
making git authoritative.

```bash
curl "https://api.sendops.dev/v1/export" \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Accept: application/zip" -o bundle.zip
```

An export takes any view scope and omits the sections your key cannot read,
naming them in `omitted` (and in the `X-SendOps-Omitted` header on the zip
response) so you can tell "no segments" from "you could not see the segments".


  - [Keeping a copy in git](https://help.sendops.dev/templates/keeping-a-copy-in-git) — the same feature from the product side: Settings → Export, Settings → Import, and what the copy is and is not.
  - [The Action's README](https://github.com/AltaCoda/sendops-push) — every input, the exporter pattern, and running the same script outside GitHub Actions.
  - [Authentication](/api-reference/authentication) — API keys and scopes.