Create or replace a template

Search Documentation

Search across all developer documentation

entities

Create or replace a template

PUT /v1/templates/{slug}
Auth required api.templates.manage

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.

200 The replaced template, or the unchanged one application/json
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
201 The created template application/json
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
401 Missing, malformed, or unknown API key application/problem+json
403 Key lacks the required scope or plan limit violated application/problem+json
409 The write was refused by a state rule. Three cases, told apart by the extension members on the Problem: a stale expected_content_hash carries the row's current content_hash; a delete blocked by a live dependent carries referenced_by. application/problem+json
422 A query parameter, path value or body field failed validation. The body is a 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
429 Per-org rate limit exceeded application/problem+json
500 Unexpected server-side failure. The code is internal_error. The request_id field can be quoted to SendOps support to investigate. application/problem+json