Summary: Behaviour notes, API-level payload spec, and edge cases for product decision-making.
Try it yourself: open the "Do" tab above for a side-by-side walkthrough of a recurring notification and a one-off notification, showing why their underlying dispatch mechanics are built completely differently.
A notification is a single record — a "notification template" — that covers both the setup (who it goes to, on what trigger) and, for one-off and recurring types, the actual message content. There are 17 notification types (document, category, product, consumer, customer, e-invoice, payment reminder, ageing, statement-live, statement-monthly, message, promotion, promotion reminder, plus three defined but currently switched off: coupon, points, phone-number). Every type shares one record shape and one create/update payload — the type field determines which of the other fields actually matter, similar in spirit to how a coupon's type field determines what "who it applies to" means.
Each type is also restricted to a specific set of channels — for example, document notifications can go over app, email, WhatsApp, or LINE, while consumer/customer/e-invoice notifications are email-only, and "Message" is WhatsApp/LINE-only. This channel restriction is enforced server-side for the types that use grouped recipients (promotion/promotion reminder) and otherwise governed by configuration the frontend reads to build the form.
This is the real payload shape accepted by the create and update
endpoints (both take the same shape). Fields are grouped by purpose; which
ones are actually required depends on the notification's type and
channel (noted per field).
Identity & routing
| Field | Type | Required | Constraint |
|---|---|---|---|
name |
string | No | Internal display name. |
type |
string | Yes | One of the 17 type values above. Effectively selects which other fields are read. |
channel |
string | Yes | app, email, whatsapp, or line. For promotion/promotion_reminder, validated against that type's allowed-channel list server-side; other types' channel options are constrained by config, not a hard server-side enum check in the create request itself — worth tightening if a type's channel gets exposed to a less-trusted caller. |
status |
string | No (system-managed for WhatsApp) | draft, pending, or active. For WhatsApp templates, the server forces this to pending on save/edit until Meta approval flips it to active — see below. |
Triggers (triggers, shape depends on type)
| Type | Trigger payload |
|---|---|
document |
document_type, document_statuses (list), document_credit_approval_statuses, document_price_approval_statuses, document_purchase_limit_approval_statuses, notify_on_document_create (bool), notify_on_document_update (bool). |
promotion |
promotion_event — required, one of 5 enum values (approval_required, approver_approved, approver_rejected, status_changed, user_note_added). If the event is status_changed, new_status is also required (one of the promotion status enum values). |
promotion_reminder |
reminder_type — required, exactly one value (pending_approval is the only one defined today). |
| All other types | triggers accepted as a free-form nullable object — no shape enforced by the server for these. |
Recipient targeting
| Field | Type | Required | Constraint |
|---|---|---|---|
recipient.company_id, .tier_id, .chain_id, .channel_id, .classification_id, .region_id, .role_id |
arrays of IDs | No | Sent as one nested object with up to 7 independent ID-list filters, each optional. Not "excluded" toggles like coupon's eligibility flags — these are always "only these," an AND-of-populated-lists match. |
send_to_primary_contact, send_to_other_contacts |
boolean | No | Contact scope — who among a matched company's contacts qualifies. |
send_to_email, send_to_email2 |
boolean | No | Which of a qualifying contact's addresses to use (email channel). |
send_to_einvoice_buyer_email |
boolean | No | Adds the e-invoice buyer's email as an extra recipient address, independent of the contact-based scope above. |
send_to_rep, send_to_rep_manager, send_to_super_rep, send_to_fulfiller, send_to_referrer, send_to_transporter, send_to_customer |
boolean | No | Document-type-specific recipient roles in the order lifecycle — layered on top of, not instead of, the contact-scope rules above. |
customer_condition |
string | No | Legacy exclusion toggle interacting with role filtering — see the known-limitations note below before building anything new on top of it. |
recipient_groups |
array | Required (present) for promotion/promotion_reminder; otherwise nullable |
Only valid values: approver, submitter, each distinct. This is a completely separate targeting model from the fields above — promotion notifications never use company/role/contact-scope targeting. |
cc_list, send_to_custom_emails |
array of email strings | Required-if-empty-together for promotion/promotion_reminder on the email channel (see content rule below) |
Straightforward CC/BCC address lists. |
Content
| Field | Type | Required | Constraint |
|---|---|---|---|
title |
string | Required for promotion/promotion_reminder on the email channel |
Email subject line. |
content |
string | Required for promotion/promotion_reminder; free-form nullable otherwise |
HTML-sanitized server-side for the email channel — except for the document type, whose email content stays as submitted (it's escaped at render time instead). WhatsApp/LINE content is never sanitized here, because WhatsApp template text has to exact-match what Meta approved. |
attachments |
array of media IDs | No |
Scheduling (only meaningful for scheduled/recurring and one-off types)
| Field | Type | Required | Constraint |
|---|---|---|---|
trigger_type |
string | Required for scheduled/recurring and one-off types | send_now or schedule. |
schedule_type |
string | Required if trigger_type=schedule and type is scheduled/recurring |
daily, weekly, or monthly. |
schedule_day_of_week |
— | Required if schedule_type=weekly |
|
schedule_day_of_month |
— | Required if schedule_type=monthly |
|
schedule_time |
— | Required if trigger_type=schedule for scheduled/recurring or one-off types |
|
scheduled_date |
date | Required if trigger_type=schedule for one-off types (product, category, message) |
Combined server-side with schedule_time into a single scheduled_at timestamp — this field is computed, not stored as submitted. |
conditions |
object | Only read for 4 types (below) |
Conditions — the only 4 types that have them
payment_reminder, ageing, statement_live, statement_monthly each
accept exactly the same three condition fields: due_date_type,
due_date_days, minimum_outstanding. No other type reads this object at
all, even if submitted.
Document, e-invoice, payment reminder, ageing, statement, and message notifications all resolve "who actually gets this" through the same single resolution path — role filter (include-only) → contact scope (primary / other) → email-address scope (Email 1 / Email 2). This was a deliberate consolidation specifically so recipient behavior could be reasoned about in one place instead of six near-duplicate implementations. Any future notification type should route through this same resolver rather than reinventing recipient logic — that's both cheaper to build and avoids reintroducing bugs already fixed here once.
A known migration gotcha, worth protecting against in future work:
role filtering used to be entangled with the customer_condition
exclusion setting. That coupling was removed so role filtering now always
behaves as "only contacts with this role," independent of
customer_condition. Any notification template saved before that fix,
with customer_condition set to "except selected" and specific roles
chosen, silently changed meaning when the fix shipped — those roles now
mean "only these roles," not "every role except these." No backfill/data
migration shipped alongside the fix (there were reportedly no such
templates in production at the time) — this is an argument for validating
that assumption before touching this area again, and for writing a
migration this time if any legacy templates are ever found in this shape.
WhatsApp is not a plain send-and-forget channel for the affected types
(payment_reminder, ageing, statement_live, statement_monthly,
message, and document): every template's exact content must be
pre-approved by Meta before it can be used.
status to pending automatically and kicks off a side-effect that
registers/updates the template with the messaging provider.active can
the template actually send.active yet — this is
enforced at the service layer, not just hidden in the UI.message specifically, and by extension
product/category) instead pre-create individual dispatch records ahead
of the actual send when a schedule is set, so a background job can pick
up exactly those rows at the right time. Saving a one-off template with
no schedule set creates no dispatch rows at all — sending only happens
when someone explicitly triggers it.type after creation is handled as a full
re-hydration into the new type's record shape (each type has its own
underlying casts/validation) rather than a partial field update — this
is a heavier operation than a normal field edit and is worth keeping in
mind if type-switching is ever exposed more prominently in the UI.Promotion and promotion-reminder notifications don't use company/role/
contact-scope targeting at all — they target one of two fixed groups,
"approver" or "submitter" (or both, depending on the event), resolved from
the promotion's own approval-workflow state rather than from the
recipient-resolution path used by every other type. There are 5 defined
trigger events for promotion and exactly 1 for promotion_reminder
(a recurring "still pending your approval" digest). Any future promotion
event should be added to the fixed event enum server-side, not inferred
from freeform trigger data, since new_status is only validated at all
when the event is specifically status_changed.
coupon, points, phone_number) are fully
modeled in the payload/config layer but marked inactive — don't assume
they're usable without first flipping them on and validating the
recipient/content paths, which haven't been exercised in production.customer_condition / role-filter interaction described
above is a real latent correctness risk for any pre-existing template
that was never re-saved after the decoupling fix — worth a one-time data
audit before this area sees further investment.