Templates
An InboxTemplate is a reusable outbound message body, on one channel, with a provider approval
state attached. It exists mainly because the official WhatsApp API refuses to deliver marketing
content that Meta has not reviewed — so the template is the thing that gets submitted, reviewed and
approved, and a campaign then references it.
Four channels carry templates: email, whatsapp, whatsapp_session, sms. Only whatsapp
requires provider approval; the other three send free-form or self-approved content.
The model
model InboxTemplate {
id String @id @default(cuid())
tenantId String? // null + isGlobal = a global template
isGlobal Boolean @default(false)
channel String // email | whatsapp | whatsapp_session | sms
name String
slug String
status String @default("draft")
// Email
subject String?
html String?
plainText String?
designJson Json?
// WhatsApp-shaped
body String?
header Json?
buttons Json?
cards Json?
bodyVariableValues Json?
headerVariableValues Json?
footerText String?
templateMeta Json?
providerSync Json?
sourceConnectionId String?
@@unique([tenantId, slug])
}
tenantId is nullable. A row with tenantId = null and isGlobal = true is a global template,
visible to every workspace. Every read is OR: [{ tenantId }, { isGlobal: true }], so a tenant sees
its own templates plus the global library, and @@unique([tenantId, slug]) means a global slug and a
tenant slug never collide.
providerSync is bookkeeping written by the provider's own status webhook — the live path that keeps
a template's status current. templateMeta.compliance.append_stop_opt_out_footer (default true)
decides whether the STOP opt-out footer is appended; a campaign created from the template inherits
that as its own appendStopFooter default, so template and campaign never disagree about whether an
inbound STOP is honoured.
Validation per channel, on create and update:
| Channel | Requires |
|---|---|
email |
subject, and one of html / plainText |
sms |
body |
whatsapp |
body or cards (unless templateMeta.template_format is authentication) |
whatsapp_session |
body, cards, or templateMeta |
Lifecycle
create ──▶ draft ──▶ POST /:id/submit ──▶ pending ──▶ (provider webhook) ──▶ approved | rejected
│
POST /:id/sync reads the stored status
Six statuses: draft, pending, approved, rejected, archived, hidden.
submitis WhatsApp-only. Submitting on any other channel is avalidation_failed— "Only WhatsApp API templates require provider submission." Submitting an already-approvedtemplate is a no-op, not an error. A blocked submit emitstemplate.submit_blocked.syncpulls the template's current stored status. The status itself is set by the provider's template-status webhook viaapplyProviderStatus, which resolves the template by name + channel (the stable handle across the migration), writes a human status message, and is idempotent — an unchanged status or an unmatched template is a no-op, never an error, because a webhook must not fail just because a template was deleted locally. Approval and rejection emittemplate.approvedandtemplate.rejected.hiddenis a global-only status, and only an admin can set or query it. Trying to hide a tenant template isvalidation_failed; trying it without admin isinsufficient_scope.- Delete refuses an
approvedorpendingtemplate: "Approved or pending templates must be archived instead of deleted." duplicatealways produces a tenant-owneddraftcopy named<name> (copy), with a fresh slug and no legacy identifiers — duplicating a global template gives you an editable local one.
Why approval matters
The whatsapp channel strategy sets requiresApprovedTemplate: true. A
campaign on that channel whose templateSnapshot.status is not approved is refused at send with a
422, before a single recipient is touched. Nothing else in the system enforces Meta's rule for you.
The campaign stores a snapshot of the template at create time, not a live reference — so a template later edited or re-submitted does not change what an in-flight campaign sends.
Routes
Tenant routes, scope inbox:
| Method | Path | Notes |
|---|---|---|
| GET | /v1/templates |
Filters channel, status, category, search; cursor-paged. status=hidden needs admin |
| GET | /v1/templates/meta |
Channels, statuses, categories, formats, header/button types, variable catalog, media limits, approvalRequired |
| POST | /v1/templates/verify-media-url |
Read-only URL check — see the gotcha below |
| POST | /v1/templates |
|
| GET | /v1/templates/:id |
|
| PATCH | /v1/templates/:id |
|
| DELETE | /v1/templates/:id |
Refused for approved / pending |
| POST | /v1/templates/:id/duplicate |
|
| POST | /v1/templates/:id/submit |
WhatsApp only; draft → pending |
| POST | /v1/templates/:id/sync |
GET /v1/templates/meta is worth calling before building a composer: it reports the media ceiling
(15 MB), the button types (quick_reply, url, phone_number, voice_call, copy_code, flow),
the header types, and capabilities.template_url_tracking_enabled, which is config-driven from
INBOX_TEMPLATE_URL_TRACKING_ENABLED so an operator can unlock a format without a deploy.
The global admin controller
/v1/admin/templates is a separate controller for managing the global library. It is class-level
@Operator() and @RequireScopes('admin') on every route — both, not either.
GET /v1/admin/templates list (global rows only)
POST /v1/admin/templates create, forced isGlobal: true
PATCH /v1/admin/templates/:id
DELETE /v1/admin/templates/:id
The two guards ask different questions and neither substitutes for the other:
ScopeGuardis satisfied byadmin, which implies everything on a tenant route.OperatorGuardis not satisfied byadminat all. It needsstats, and the caller's app must matchINBOX_OPERATOR_APP_SLUG.
So a credential holding admin alone gets a 403 from the operator guard, and a credential holding
stats alone gets a 403 from the scope guard. You need both. See
Scopes and guards.
Global management also requires a machine credential: canManageGlobals is
auth && !auth.user && auth.scopes.includes('admin'). An Accounts user session gets a fixed scope set
that never includes admin, so a human session can never author a global template.
Gotcha: verify-media-url needs an Idempotency-Key
POST /v1/templates/verify-media-url only fetches a URL and checks its content type. It creates
nothing. It is still a POST without @ReadOnlyPost(), and the default policy for a POST is
dataWrite — so the request is rejected without an Idempotency-Key header.
curl -X POST https://inbox.example.com/v1/templates/verify-media-url \
-H "x-api-key: sk_..." \
-H "x-axis-tenant: ws_..." \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"url":"https://cdn.example.com/header.jpg","type":"image"}'
The verifier is SSRF-guarded internally. type is image, video or document. See
Idempotency for the full list of POSTs that inherit this default.