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.pngsendops.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.
| Key | Shape |
|---|---|
templates | Map of template name → { path, subject?, testdata?, default_topic?, default_consent_class? }. default_consent_class is "transactional" or omitted (marketing). |
images | Array of image paths. |
segments | Map of segment key → { path, name?, description? }. path ends in .sendql and holds the SendQL predicate. name defaults to the key. |
workflows | Map of workflow key → { path, name?, description? }. path ends in .flow. |
attributes | Path to one .json file holding every attribute. |
activity-properties | Path to one .json file holding every promoted activity property. |
lists | Path to one .json file holding every List. |
topics | Path 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 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:
{
"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.zipTwo query parameters decide what happens:
dry_rundefaults totrue. 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 tofalseinstead: a workflow that added the step meant to deploy.prunedefaults tofalse. Withprune=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 withreferenced_bynaming 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.zipAn 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.