Create or replace a template
Creates the template named by {slug}, or replaces it if it exists.
This is the endpoint to push rendered email from a build: it is
idempotent, hash-guarded, and it deploys to SES before it answers.
It is a full replacement, not a patch. An omitted field is the new
value, not "leave it alone" — send the whole body every time. (This
differs from PUT /v1/segments/{id}, which merges; a template is
rendered whole by whatever produced it.)
Idempotent. If the stored content, subject, default_topic and
default_consent_class already match what you are sending, the
response is 200 with unchanged: true and NOTHING is written: no
version snapshot, no SES call. A pipeline that runs on every commit can
push every template every time.
format is required and handlebars is the only accepted value;
anything else is 422 unsupported_format with the accepted values in
accepted_formats. The field exists so that accepting other source
formats later is an added value rather than a breaking change. Render
your source (React Email, MJML, anything) to SES-subset Handlebars
before pushing it.
Concurrency. Send expected_content_hash (the content_hash from
a previous read) to make the write conditional. If the stored hash has
moved on, the write is refused with 409 conflict carrying the row's
current content_hash, so a retry is one re-read away. Omit it and the
last write wins. The hash covers the BODY only — a subject-only edit
does not move it, so two callers changing only the subject cannot
detect each other. That matches the MCP templates_author tool
exactly, deliberately: one concurrency rule, not two.
Deploy. A successful write pushes the template to the org's SES
account within a bounded budget and reports what happened in
deploy_state: deployed (live and sendable on return), failed
(the transform pipeline blocked it — the content is still saved, and
deploy_error says why), or pending (the budget ran out, SES errored,
or the org has no AWS connection yet; a background job will finish it).
Replacing an already-deployed template reports pending until the new
body lands, even though the template is still sendable with its
previous content — those are two different questions and this field
answers the one about what you just pushed.
Naming. {slug} is the template's name, and it must be something
SES can hold: letters, numbers, _ and -. A name with a dot in it is
refused (422) rather than deployed, because SES rejects it on every
attempt. A name in the body is accepted only when it equals the slug;
this route does not rename, since the name is what every broadcast and
every workflow send step resolves through. A slug differing from a
stored template only in case updates that template and keeps its stored
spelling.
requires[] reports what this template still needs: whether its
default_topic names a topic that exists, and whether every
{{asset}} path resolves. It is a different question from
deploy_state, which is only about this row reaching SES.
Path parameters
slug string required Template name (e.g. welcome).
Request body
Content type: application/json
name string optional Optional, and must equal the {slug} in the path when given. This route does not rename.
subject string optional The subject line, itself a Handlebars template.
body string required The template source, in SES-subset Handlebars.
format string enum required Required. handlebars is the only accepted value; anything else is 422 unsupported_format.
One of: handlebars
default_topic string | null optional The topic name a send of this template inherits. Omit or send null to clear it.
default_consent_class string enum | null optional Omit or send null for marketing.
One of: ``, transactional, null
expected_content_hash string optional The content_hash you last read. When set, a write whose stored hash has moved on is refused with 409 carrying the current hash.
Responses
Errors follow the RFC 7807 problem format — see the error reference.
slug string required The template's name, used in the path (e.g. welcome).
name string required Same as slug — there is no separate display-name column.
subject string | null required The email's subject line, itself a Handlebars template. Null when the template carries none.
channel string | null required Always null. Templates carry no channel binding in the data model; the field is kept so existing readers keep working.
content_hash string | null required The optimistic-concurrency token. Send it back as expected_content_hash on a write to make that write conditional. It covers the BODY only, so a subject-only change does not move it.
deploy_state string enum required Whether this content is live in the org's SES account. pending means saved but not yet in SES — including on the response to a write of an already-deployed template, where SES is still serving the previous body. Poll GET /v1/templates/{slug} to see it settle.
One of: pending, deployed, failed
ses_template_name string | null required The name the template holds in SES ({namespace}--{name}); null before it has ever deployed.
deploy_error string | null required Why the last deploy failed. Null when nothing has.
deployed_at string<date-time> | null required When SES last accepted this template. Null while a deploy is outstanding, including on a write whose new content has not landed.
default_topic string | null required The topic NAME a send of this template opts recipients out of when the step names none. Resolved late, at send time, so it may name a topic that does not exist yet — requires[] on a write says whether it does.
default_consent_class string enum required "" — marketing, the default. transactional — sanctioned lifecycle mail, which is topic-exempt.
One of: ``, transactional
variables array<string> required The merge fields the body references, as stored on the row.
last_modified_at string<date-time> required requires array<object> optional What this template still needs — its default_topic, and every {{asset}} path it references — with the state of each. Present on writes only; absent on the reads, which resolve nothing.
Each entry in requires:
kind string enum required The namespace key is looked up in. Branch on this; never parse detail.
One of: template, segment, list, topic, identity, channel, attribute, activity_property, asset
key string required The identifier as the definition names it — a template name, a segment id or key, a from-address. Empty for a requirement about the ABSENCE of a reference: a send with no topic at all names no topic.
status string enum required ok — resolves and is usable now.
missing — nothing in this organization answers to that key. Correct
the reference, or create the thing.
not_ready — it exists, in a state that cannot be used yet. Usually
a wait, and retrying the identical request is the move. Treating this
as missing sends you hunting for a typo that is not there.
One of: ok, missing, not_ready
detail string required The state, in the words that say which state — deploy_state=pending, member_count=null (evaluation queued). For display and logs; do not parse it.
fix string required The call that clears this requirement, as an HTTP route where there is one and as a plain instruction where there is not (a template deploy is waited for, not called). Empty on an ok entry.
created boolean required True when this call registered a new template, false when it replaced one.
unchanged boolean required True when the stored template already matched what was sent. NOTHING was written: no version snapshot and no SES call.
validation object required The saved row's own analysis. Content that fails SES validation never reaches the database, so valid is true on every response that exists — the useful half is variables, which is what the body actually references.
Fields of validation:
valid boolean required variables array<string> required slug string required The template's name, used in the path (e.g. welcome).
name string required Same as slug — there is no separate display-name column.
subject string | null required The email's subject line, itself a Handlebars template. Null when the template carries none.
channel string | null required Always null. Templates carry no channel binding in the data model; the field is kept so existing readers keep working.
content_hash string | null required The optimistic-concurrency token. Send it back as expected_content_hash on a write to make that write conditional. It covers the BODY only, so a subject-only change does not move it.
deploy_state string enum required Whether this content is live in the org's SES account. pending means saved but not yet in SES — including on the response to a write of an already-deployed template, where SES is still serving the previous body. Poll GET /v1/templates/{slug} to see it settle.
One of: pending, deployed, failed
ses_template_name string | null required The name the template holds in SES ({namespace}--{name}); null before it has ever deployed.
deploy_error string | null required Why the last deploy failed. Null when nothing has.
deployed_at string<date-time> | null required When SES last accepted this template. Null while a deploy is outstanding, including on a write whose new content has not landed.
default_topic string | null required The topic NAME a send of this template opts recipients out of when the step names none. Resolved late, at send time, so it may name a topic that does not exist yet — requires[] on a write says whether it does.
default_consent_class string enum required "" — marketing, the default. transactional — sanctioned lifecycle mail, which is topic-exempt.
One of: ``, transactional
variables array<string> required The merge fields the body references, as stored on the row.
last_modified_at string<date-time> required requires array<object> optional What this template still needs — its default_topic, and every {{asset}} path it references — with the state of each. Present on writes only; absent on the reads, which resolve nothing.
Each entry in requires:
kind string enum required The namespace key is looked up in. Branch on this; never parse detail.
One of: template, segment, list, topic, identity, channel, attribute, activity_property, asset
key string required The identifier as the definition names it — a template name, a segment id or key, a from-address. Empty for a requirement about the ABSENCE of a reference: a send with no topic at all names no topic.
status string enum required ok — resolves and is usable now.
missing — nothing in this organization answers to that key. Correct
the reference, or create the thing.
not_ready — it exists, in a state that cannot be used yet. Usually
a wait, and retrying the identical request is the move. Treating this
as missing sends you hunting for a typo that is not there.
One of: ok, missing, not_ready
detail string required The state, in the words that say which state — deploy_state=pending, member_count=null (evaluation queued). For display and logs; do not parse it.
fix string required The call that clears this requirement, as an HTTP route where there is one and as a plain instruction where there is not (a template deploy is waited for, not called). Empty on an ok entry.
created boolean required True when this call registered a new template, false when it replaced one.
unchanged boolean required True when the stored template already matched what was sent. NOTHING was written: no version snapshot and no SES call.
validation object required The saved row's own analysis. Content that fails SES validation never reaches the database, so valid is true on every response that exists — the useful half is variables, which is what the body actually references.
Fields of validation:
valid boolean required variables array<string> required expected_content_hash carries the row's current content_hash; a delete blocked by a live dependent carries referenced_by. application/problem+json validation_failed Problem. When the refusal is about a
reference — a segment key, template slug, topic or attribute name the
organization does not have — it additionally carries validation_code,
field, line/column, missing, candidates and next_step, so a
client can correct the call without a second round of guessing. See
ValidationProblem.
application/problem+json code is internal_error. The
request_id field can be quoted to SendOps support to investigate.
application/problem+json