Push from CI

Search Documentation

Search across all developer documentation

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:

- 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.

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 sectionScope
templatesapi.templates.manage
segmentsapi.segments.manage
listsapi.lists.manage
topicsapi.topics.manage
attributesapi.attributes.manage
activity-propertiesapi.activities.manage
workflowsapi.workflows.manage
imagesapi.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:

{
  "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.

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:

{
  "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.

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”.

Where to go next

  • 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 — every input, the exporter pattern, and running the same script outside GitHub Actions.
  • Authentication — API keys and scopes.