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.

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:

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.