Template message
Overview
Template message is the library of LINE Flex Messages built from ready-made templates, without writing JSON by hand (though you can, via the Custom JSON template). Templates built here get used as one of the content items in the Rich message menu, and are also picked up by trigger rules, workflows, and the form builder.
The key thing to understand up front: every template is stored as two parallel columns — a "form shape" column holding what the user filled in, so the form can re-hydrate on edit, and a "LINE shape" column holding the finished compiled Flex Message used for sending. Both are rewritten on every save, and each template type decides differently who does the compiling — the server or the browser (see Key details).
Who uses it — content teams who need to produce good-looking, repeatable messages fast without writing code (this module carries a real, enforced ModuleGate on the backend — see Access control).
User journey
1. Review the template list
Open the Template message menu. The list is a single table with columns Title / Type / Update date (labeled "(BKK Time)") / Action. The filter bar has 2 fields — search by title, and a template Type picker.
2. Create a new template
- Click Create template message. The system defaults the type to Card Builder.
- Enter a title — max 100 characters, and it must not duplicate another template in the same OA. Below it, a Data Entry Conditions box lists the supported characters and symbols, plus the restriction that it can't start with a symbol.
- Pick one of 6 template types — Register, Shopping, Notification (single card), Carousel, Custom JSON (write the Flex JSON yourself), or Card Builder (assemble visually from stacked blocks). Changing the type loads that type's seed content as a starting point.
- For a structured template (Register / Shopping / Notification / Carousel) — fill in the heading, description, image/icon, and buttons.
- Carousel starts with 2 cards and grows to 6 (the button reads
"Add card {n} / 6"with a hint that you can add up to 6). - Buttons offer 4 action types — send a message, open a link, place a call, or Postback.
- Send a message — the text field is an autocomplete suggesting keywords from active rules in the Auto-response message menu, because text a button sends into the chat really can trigger a reply rule.
- Open a link — enter a label (max 100 characters) and a URL that must start with http or https.
- Place a call — a single Phone Number field (max 10 digits, regex-checked). There is no separate label field.
- Postback — enter the postback data and the display text (both required).
- Every text field carries a Merge Tag button that opens the tag list and inserts
{{key}}at the cursor.
- Carousel starts with 2 cards and grows to 6 (the button reads
- For Card Builder — add blocks one at a time (hero image, video, text, button, separator), drag to reorder, set each block's action, then fill in the alt text shown in the chat list (max 400 characters). Save enables only when there's at least one block, every block is complete for its type, and at least one block actually renders content (separators alone don't count).
- A Card Builder hero image is uploaded through a dedicated endpoint (
POST /template-message/upload-image) to get a URL back first, so the save itself sends pure JSON.
- A Card Builder hero image is uploaded through a dedicated endpoint (
- For Custom JSON — paste your Flex JSON into a single field.
- Check the preview in the right column, rendered with a real Flex Message renderer, then save once the chosen type's requirements are met.
3. Consume it from another menu
- Rich message — choose the "Template Message" content type and reference a template built here. The system copies that template's LINE-shape content into the Rich message on save.
- Downstream menus call
GET /template-message/find-all-object, which returns three shapes depending on theformatparameter — object (the default),dropdown, anddropdownGroup.
Key details verified against the source
Confirmed from internal/modules/templatemessage/{controller,service,dto,formdata,flex}.go and internal/modules/planlimits/ in line-management-cms-api-go:
- 2 of the 6 template types have no server-side compiler at all — types 1–4 (Register / Shopping / Notification / Carousel) are compiled into a Flex Message on the server by per-type builders (
createType1Flex…createType4Flex). But type 5 (Custom JSON) and type 6 (Card Builder) let the browser compile and simply store the result as-is. The code comment says so plainly: Card Builder has "no server-side flex compiler — same trust model as CustomJSON." The consequence is that neither type gets any server-side Flex structure validation (unlike the Rich message menu, which actually sends the JSON to LINE to be validated). The server only checks that it parses as JSON. - A "place a call" button uses the phone number as both the button's label and the dial target — the server-side compiler builds the button's uri as
"tel:" + label, and the form binds the "Phone Number" field straight onto thelabelfield (there's no separate label field to fill in). So the button on the bubble displays the phone digits. You can't caption it something like "Call us." - The Postback button collapses two meanings the same way — the postback's
datavalue is read from thelabelfield, and the form labels that field "Label" while giving it the placeholder "Enter data" and the error "Please input data." So the postback data is also the text shown on the button. The "Display Text" field is sent astextand becomes the action'sdisplayText. Beyond that, the frontend only sends 4 fields per button (type,label,text,link) — there is no separatedatafield anywhere in the real data path. - A button with no label compiles to one that says "Click" — the compiler uses the fallback chain
label || text || "Click", so a button lacking both a label and text ships as a button reading "Click," not as an empty button or an error. - An unrecognized button type compiles to a
textaction — the compiler mapsuri/tel→uri,postback→postback,message→message, and everything else falls through to typetext, which is not a LINE action type. (The valueno_actionexists in the frontend code but is never offered in the picker, so it can't arise from normal use of the UI.) - The template quota is enforced on create only, and counted per channel — the platform default is 20 templates, and overrides cascade in the order platform default <
organization.plan_limits<line_oa.plan_limits(merged per key). When a LINE OA is in scope, usage is counted per channel, not per organization. A value of-1means unlimited. The count usesstatus != 'delete' AND deleted_date IS NULL, so deleted templates don't consume quota. At the cap you get a 400 stating the real numbers — "Plan limit reached: you are using 20 of 20 templates. Please upgrade your plan." — and editing an existing template is never quota-checked. - Editing a template cascades into the Rich messages that reference it, best-effort — after a successful save, the system runs raw SQL to find
rich_messagerows wherecontent::text LIKE '%<templateMessageId>%', then patches theflexvalue of every item whosetype = "templateMessage"and whosetemplateMessageIdmatches. The thing to know: errors in this step are swallowed (logged only), matching the original system's behavior — a failed cascade never fails the template edit and never tells the user. Also, the lookup does not filter byline_oa_id(onlydeleted_date IS NULL), so it scans across every OA in the database — though in practice it matches on the template's uuid, so it won't patch the wrong records. - The cascade runs on edit only, never on delete — deleting a template doesn't touch the Rich messages referencing it. Those keep sending the last copied content indefinitely, with no error.
- Whitespace-only text fields are specifically rejected — the comment states the reason directly: a blank value ends up inside a flex text node and makes the LINE push fail downstream ("may be empty"). The system therefore checks every text-bearing field in the flex structure, checking only the keys actually sent, since which keys exist varies by template type.
- System-level merge tags exist on the backend but this screen never shows them —
GET /template-message/merge-tagsreturns three groups:fixed(7 hardcoded entries — display_name, firstname, lastname, email, mobile_no, language, follow),custom(from the OA's active custom attributes, prefixedcustom.), andsystem(prefixedsystem., cached for 60 seconds). But thesystemgroup comes back only when called with?showSystem=true, and this screen's merge tag picker calls it without that parameter — so users only ever see 2 groups. - Merge tags are neither substituted nor verified here — a value typed as
{{key}}is stored verbatim, with no check that the key exists in the real tag list. Substitution happens in the downstream module at actual send time. - The list route has an opt-in cache mode whose key is byte-for-byte compatible with the old system — passing
cache=truetakes a path that hashes a key built by replicating JavaScript'sJSON.stringify(payload), key order included (page, orderBy, order, limit first, then the remaining parameters in URL order), and keys whose value is undefined are skipped just as JavaScript would. Every write purges this module's entire cache. - The
typefilter follows JavaScript's coercion rules — sendingtype=(empty) is treated as not sent at all, whiletype=0coerces to the number 0, which the repository layer then treats as "no filter" anyway. A value that won't coerce to a number is rejected with "type must be a number conforming to the specified constraints." - Card Builder images accept GIF, unlike the Rich message menu — the
upload-imageendpoint acceptsimage/jpeg,image/png, andimage/gifup to 10 MB, genuinely enforced server-side with the messages "Invalid file type. Only JPEG, PNG, and GIF are allowed" and "File size too large. Maximum size is 10MB" — whereas the Rich message menu accepts only JPEG and PNG.
Related menus in this journey
- Rich message — the main downstream consumer: it picks a template as one of its content items, with the content copied in at save time, and edits here cascade back into that copy (deletes don't).
- Auto-response message — the keywords of active rules surface as suggestions in the "send a message" button's text field here.
- Quick reply — not supported directly on a template (the
template_messagetable has no column for it). If you want quick reply chips alongside a template, attach them at the level of the Rich message wrapping it. - Templates are also picked up by trigger rules, workflows, and the form builder, through the same
find-all-objectendpoint.
Things to watch out for
- Custom JSON and Card Builder get no server-side Flex validation whatsoever — saving successfully does not mean LINE will accept it. Open the preview to be sure before using it live (unlike Flex content in the Rich message menu, which is genuinely sent to LINE for validation).
- The template quota gives no advance warning in the UI — you only find out at the cap when the save fails. The message states your usage against the real limit, and that limit may be set per channel rather than as one value for the whole organization.
- A call button shows the phone number as its caption — if you want a button that reads "Call us," use the "open a link" type and enter a
tel:URL yourself, or use the Card Builder template. - Editing a template may not reach every Rich message — the cascade is best-effort and can fail silently. After changing important content, open the Rich messages using that template and confirm visually.
- Deleting a template leaves Rich messages sending the old content — nothing warns you where a template is in use before you delete it (unlike the Quick reply menu, which both warns and blocks). You only hit the problem when you reopen that Rich message and try to save, which returns
APP_007. - A mistyped merge tag goes unflagged — neither the frontend nor the backend checks that a key exists. Insert tags with the Merge Tag button rather than typing them.
- A duplicate title is rejected per-field — the response is a 400 pointing at the
titlefield with "Title is already exist," so the UI shows the error right under the title input. - Deletion is a soft delete — the status flips to
deleteand a deletion timestamp is stamped; the row stays in the database. This module has no hard-delete route at all.
Access control (verified against the source)
- Frontend — wrapped in a
PermissionGuardchecking theVIEWaction on thetemplate-messagesubject. - Backend — all 8 routes in this module are wrapped in a real, enforced
ModuleGate(d, "template-message")(like the Rich message and Campaign modules, and unlike Quick reply, which has no ModuleGate at all). The code comment states the behavior precisely: a customer-type caller whose org has the "template-message" module closed by the platform admin gets a 403, while internal (onemoby) callers bypass it. GET /template-message/find-all-objectcarries no policy annotation at all, unlike every other route in the module (the comment records this: "@CheckPoliciesNOT present on find-all-object in TS" — preserved from the original system). It is still wrapped in theModuleGate, though, so the org-level module toggle still gates it.- The other routes' action-level policy annotations (
create/read/update/delete) are all declared but run through the same no-op middleware as most other modules — the real enforcement is the module-level ModuleGate. - Cross-tenant isolation is enforced on every route — reads, updates, and deletes all pin the caller's
line_oa_id. The comments note this closed holes that previously allowed editing (I1) and deleting (I2) another organization's templates. Requesting an id that isn't yours returns 404; a delete that finds no target returns a 400AUS_102. - There is no hard-delete route and no Super-Admin-only route in this module.
Source file reference
Frontend (cms-web-2026)
src/app/template-message/page.tsx,src/app/template-message/form/page.tsx— list and form entry points, withPermissionGuardsrc/components/template-message/list/template-message.container.tsx— table, title and type filterssrc/components/template-message/form/template-message-form.tsx— the fields for each template type, the 4 button action types and their field bindings, the Carousel's 6-card capsrc/components/template-message/form/template-message-form.container.tsx—FormDataassembly, the type-5/type-6 divergence, re-hydration on editsrc/components/template-message/form/flex-card-builder/—flex-card-builder.tsx,compile.ts(the browser-side compiler),validate.ts(the save gate),default-blocks.tssrc/components/template-message/form/merge-tag-picker.tsx,merge-tag-input.tsx— merge tag picking and insertionsrc/components/template-message/constants/template-message.constants.ts— the seed content for all 6 template typessrc/services/template-message.service.ts— every endpoint the frontend callsmessages/th/template-message.json,messages/en/template-message.json— all UI copy
Backend (line-management-cms-api-go)
internal/modules/templatemessage/controller.go— all 8 routes,ModuleGate("template-message"), theupload-imagerulesinternal/modules/templatemessage/service.go— the per-type divergence, the quota check, the cascade intorich_message, the merge tag endpointinternal/modules/templatemessage/flex.go— the server-side Flex compiler for types 1–4 and the button action mappinginternal/modules/templatemessage/formdata.go— bracket-notation multipart decoding and text-field validationinternal/modules/templatemessage/dto.go— inbound validation rules and cache-key constructioninternal/modules/planlimits/service.go,constant.go— the template quota, override precedence, and the default of 20
See the documentation team's earlier technical write-up at Template Message Management