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.

The bundle format

A bundle is sendops.json plus every file it names. Every path in the manifest is relative to the bundle root, must not be absolute, and must not contain ... Nothing requires a particular directory layout, but an export uses this one, and a directory you push from is easiest to read if it does too:

sendops.json
templates/welcome.html
audience/high-value.sendql
audience/attributes.json
audience/activity-properties.json
audience/lists.json
audience/topics.json
audience/workflows/onboarding.flow
static/logo.png

sendops.json

{
  "templates": {
    "welcome": {
      "path": "templates/welcome.html",
      "subject": "Welcome to {{productName}}",
      "default_topic": "product-updates"
    }
  },
  "images": ["static/logo.png"],
  "segments": {
    "high-value": { "path": "audience/high-value.sendql", "name": "High value", "description": "…" }
  },
  "workflows": {
    "onboarding": { "path": "audience/workflows/onboarding.flow", "name": "Onboarding" }
  },
  "attributes": "audience/attributes.json",
  "activity-properties": "audience/activity-properties.json",
  "lists": "audience/lists.json",
  "topics": "audience/topics.json"
}

Every key is optional, and a bundle only speaks to the sections it declares.

KeyShape
templatesMap of template name → { path, subject?, testdata?, default_topic?, default_consent_class? }. default_consent_class is "transactional" or omitted (marketing).
imagesArray of image paths.
segmentsMap of segment key → { path, name?, description? }. path ends in .sendql and holds the SendQL predicate. name defaults to the key.
workflowsMap of workflow key → { path, name?, description? }. path ends in .flow.
attributesPath to one .json file holding every attribute.
activity-propertiesPath to one .json file holding every promoted activity property.
listsPath to one .json file holding every List.
topicsPath to one .json file holding every topic.

Segments and workflows are declared one entry per file. The four schema kinds, attributes, activity-properties, lists and topics, are each a single file the manifest points at. Each of those files is a JSON object keyed by name, not an array, and may be at most 64 KiB.

attributes

A map of attribute name → definition:

{
  "handle":    { "type": "string",   "description": "Public username" },
  "joined_at": { "type": "datetime" },
  "seats":     { "type": "number" },
  "is_admin":  { "type": "bool" },
  "plan":      { "type": "enum", "enum_values": ["Free", "Basic", "Pro"] }
}

type is required and is one of string, number, bool, datetime or enum. An enum needs a non-empty enum_values. description is optional.

activity-properties

A map of activity name → property name → definition. A definition has the same fields as an attribute definition:

{
  "signed_up": {
    "method": { "type": "enum", "enum_values": ["email", "google"] }
  },
  "approved": {
    "waited_days": { "type": "number", "description": "Days from sign-up to approval" }
  }
}

lists

A map of List key → { name?, description? }. name defaults to the key, so {} is a complete definition:

{
  "beta-users": { "name": "Beta users", "description": "Early access cohort" },
  "vips": {}
}

The file holds definitions only. List membership is never part of a bundle.

topics

A map of topic name → { display_name?, description?, default_subscription_status }. default_subscription_status is required and is OPT_IN or OPT_OUT:

{
  "product-updates": {
    "display_name": "Product Updates",
    "default_subscription_status": "OPT_OUT"
  }
}

Topics and the contact list

Topics live on your organization’s SES contact list. If the organization has none yet, the plan shows a contact_list object with action create ahead of the topics. Applying it adopts the contact list your AWS account already has (SES allows one per account) or, if there is none, creates sendops-contacts. The result says which happened. A dry run does not touch SES. The only case a push refuses its topics is an organization with no connected AWS account.

An inbound key is accepted and reported with a diagnostic, but not imported. A receiving domain needs DNS records and a webhook secret, and neither belongs in a repository.

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.

Whole or not at all against the plan, too. A bundle that would take the organization past a plan limit (segments, templates or image bytes) is refused before anything is written: the dry run comes back with applicable: false and a plan_limit naming the cap, and an apply is a 402 plan_limit_exceeded. The Action prints Import refused: plan limit — … and fails the step. See Plans & limits.

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: Workspace → Export / 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.