Templates API
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Templates API
A custom template tells the parser how to recognize a message and what to pull out of it. Each template has two parts:
Match rules decide whether the template applies to a payload. Every rule must pass.
Mappings extract order fields from a payload the template applies to.
Custom templates use the same engine as the built-in retailer and monitor shapes. The preview and detect endpoints run that same code, so a preview result is what live intake will produce.
For a conceptual walkthrough and the dashboard builder, see the custom templates guide.
Endpoints
Method | Path | Purpose |
|---|---|---|
|
| List built-in shapes. |
|
| List your custom templates, newest first. |
|
| Create a custom template. |
|
| Update a custom template. Fields you leave out keep their values. See Update a template. |
|
| Delete a custom template. |
|
| Run mappings against a sample. |
|
| Evaluate match rules against a sample. |
|
| Parse a sample end to end, optionally with a draft template. |
List custom templates
GET /api/templates/custom
{
"items": [
{ "id": "tmpl_mq1a2b3c", "source": "webhook", "name": "Acme Monitor", "…": "…" }
]
}items holds every custom template in your workspace, of both kinds, newest first. That is the order they're tried in during intake (see When a template is used). The same list also appears in GET /api/state → templates.customTemplates.
The template object
{
"id": "tmpl_mq1a2b3c",
"userId": "usr_4f1c2a9b7e0d3c11",
"source": "webhook",
"name": "Acme Monitor",
"eventType": "order_placed",
"store": "walmart",
"sample": "{\"embeds\":[…]}",
"mappings": [
{ "target": "itemSummary", "selectorType": "field_name", "selector": "Product" }
],
"matchRules": [
{
"id": "rule_1",
"selectorType": "json_path",
"selector": "embeds[0].footer.text",
"op": "contains",
"value": "Acme Monitor",
"scope": "auto",
"caseSensitive": false
}
],
"createdAt": "2026-09-24T16:00:00.000Z"
}Field | Type | Description |
|---|---|---|
| string |
|
|
| Which channel the template applies to. Any value other than |
| string | Up to 120 characters. Defaults to |
| enum | The lifecycle event this template represents: |
| string | Optional fixed retailer: |
| string | The reference payload you built the template against. It is stored for your own reference and not used during matching. |
| array | Field extraction rules. See Mappings. |
| array | Detection rules, all of which must pass. Left out when empty. See Match rules. |
| timestamp | Creation time. Custom templates are tried newest |
When a template is used
For each payload, candidate shapes are tried in this order, and the first whose rules all pass wins:
Discord intake only: the template pinned on the bot (
bot.templateId). It is applied without checking its rules.Your custom templates for the payload's channel, newest first by
createdAt. The order is the same on every run and survives restarts, soGET /api/templates/customshows you exactly the order they'll be tried in.Built-in shapes.
An email source with a non-empty templateIds list narrows the candidates to those IDs, tried in that order.
Important: A template with no match rules is never selected automatically, for email or for Discord, even if it is listed in an email source's
templateIds. The only way to use a rule-less template is to pin it on a Discord intake bot.
After the matched template's mappings run, generic patterns fill any order fields that are still empty. See How parsing works.
Match rules
interface MatchRule {
id: string; // stable ID; generated if omitted
selectorType: "json_path" | "field_name" | "regex";
selector: string;
op: "equals" | "contains" | "regex" | "exists";
value?: string; // required for equals/contains/regex
scope?: "auto" | "header" | "body"; // default "auto"
caseSensitive?: boolean; // default false
}A rule works in two steps. First it resolves its selector to a string. Then it tests that string with op.
Selector types
| Resolves to |
|---|---|
| The value at a path in the webhook JSON, converted to a trimmed string. Only works on Discord payloads. On email it never resolves. |
| For Discord payloads, the value of the embed field with that name (case-insensitive). If there is no such field, and for email, the value of a |
| The first match in the text for the rule's scope. Capture group 1 is used if it exists, otherwise the whole match. |
JSONPath syntax is a minimal dot path. A leading $. is optional. Array indexes are written as key[n] inside a segment.
embeds[0].footer.text
$.embeds[0].fields[2].value
usernameFilters, wildcards, and bracket-quoted keys are not supported.
Label lines for field_name match the label, then optional whitespace, then one of :, #, or -, then the value. The value runs until the first |, ,, or line break. For example, Order #: 112-3458291 resolves Order to 112-3458291.
Regex patterns use JavaScript syntax. They are compiled with the s flag (so . matches newlines) and the i flag unless caseSensitive is true. Don't include slashes or flags in the string.
Scope
scope controls which text field_name and regex rules look in. It has no effect on json_path.
| Discord | |
|---|---|---|
| The decoded | Empty (never matches) |
| The decoded HTML (or text if there is no HTML), then the plain-text rendering | The flattened embed text |
|
| Same rule. In practice this is |
Operators
| Passes when |
|---|---|
| The resolved value equals |
| The resolved value contains |
|
|
| The selector resolved to a non-empty string. |
A rule whose selector doesn't resolve fails. Errors inside a rule count as a failed rule and never as a match.
Examples
[
{ "selectorType": "json_path", "selector": "embeds[0].footer.text", "op": "contains", "value": "Acme Monitor" },
{ "selectorType": "field_name", "selector": "Site", "op": "equals", "value": "Walmart" },
{ "selectorType": "regex", "selector": "From:\\s*([^\\n]+)", "op": "contains", "value": "@example.com", "scope": "header" },
{ "selectorType": "regex", "selector": "Subject:\\s*([^\\n]+)", "op": "regex", "value": "has (shipped|been shipped)" },
{ "selectorType": "field_name", "selector": "Tracking", "op": "exists" }
]Mappings
interface CustomTemplateMapping {
target: NormalizedOrderField;
customKey?: string; // used when target is "custom"
selectorType: "json_path" | "field_name" | "regex";
selector: string;
sampleValue?: string; // informational; stored as-is
}target is one of:
Target | Becomes | Notes |
|---|---|---|
|
| The key used to merge messages into one order. Map it whenever the payload has one. |
|
| |
|
| |
| same | |
| same | Display strings. |
| same | |
|
| Digits are pulled out of the value and rounded. Values of 0 or less are dropped. |
|
| |
|
| If not mapped, the carrier is detected from the tracking number. |
|
| Normalized to a store key, or detected from the text. Ignored when the template has a fixed |
|
| Must be an event-type value, or text that describes the event (for example "shipped"). Ignored when the template's |
|
| Any extra value. Without a |
Resolution works like match rules, with small differences:
json_pathreads from the webhook JSON only.field_namechecks the embed field with that name first, then aLabel: valueline in the plain text, then in the email headers.regexsearches the body, then the plain text, then the headers. It uses capture group 1 if present, otherwise the whole match.There is no
scopeon mappings.
Every extracted value is cleaned: HTML tags are stripped, common entities are decoded, Discord markdown (||, **, __, backticks) is removed, and whitespace is collapsed. When several mappings share a target, the first one that produces a value wins, so list the most specific first.
Create a template
POST /api/templates/custom
The body is the template object without id, userId, or createdAt.
curl -s -X POST https://shippified.net/api/templates/custom \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "webhook",
"name": "Acme Monitor",
"eventType": "order_placed",
"mappings": [
{ "target": "itemSummary", "selectorType": "field_name", "selector": "Product" },
{ "target": "orderNumber", "selectorType": "field_name", "selector": "Order" },
{ "target": "total", "selectorType": "field_name", "selector": "Price" },
{ "target": "custom", "customKey": "profile", "selectorType": "field_name", "selector": "Profile" }
],
"matchRules": [
{ "selectorType": "json_path", "selector": "embeds[0].footer.text", "op": "contains", "value": "Acme Monitor" }
]
}'Returns 201 with the stored template.
Validation:
Mappings and rules with an unknown
target,selectorType, orop, or with an emptyselector, are dropped without an error. Compare the returned template with what you sent.A
regexselector, or a rulevaluewithop: "regex", that doesn't compile returns400. For example:{"error": "Rule 1 selector is not a valid regex: …"}or{"error": "Mapping 2 (orderNumber) is not a valid regex: …"}.A new template doesn't change existing orders. Run
POST /api/orders/reparseto apply it to stored messages.
Update a template
PATCH /api/templates/custom/:id
PATCH is a partial update. Send only the fields you want to change: any field you leave out keeps its stored value.
curl -s -X PATCH https://shippified.net/api/templates/custom/tmpl_mq1a2b3c \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Monitor v3" }'How each field behaves:
Field | Left out | Sent |
|---|---|---|
| Keeps the stored value. | Replaces it. An empty |
| Keeps the stored value. | Replaces it. Invalid values become |
| Keeps the stored value. | Replaces it. Send |
| Keeps the stored list. | Replaces the whole list. Send every mapping you want to keep. |
| Keeps the stored list. | Replaces the whole list. Send |
| — | Ignored. A template's kind can't change after it's created, because its rules and mappings are written for one payload shape. Create a new template instead. |
| — | Ignored. The template keeps its place in the precedence order. |
Validation is the same as on create: arrays you send are sanitized the same way, and an invalid regex returns 400 without changing the stored template.
Returns 200 with the stored template, or 404 {"error": "Template not found"}.
Delete a template
DELETE /api/templates/custom/:id returns 200 {"ok": true} or 404. Orders already parsed with the template keep their data until they are re-parsed.
List built-in shapes
GET /api/templates
{
"webhookTemplates": [
{ "id": "hayha", "displayName": "Hayha", "platform": "hayha", "description": "Hayha checkout / monitor embed." }
],
"emailTemplates": [
{ "id": "…", "retailer": "target", "displayName": "…", "eventType": "order_shipped", "description": "…" }
]
}Use these ids in bot.templateId or in an email source's templateIds.
Preview extraction
POST /api/templates/custom/preview
Runs each mapping against a sample on its own and returns what it extracted.
Body field | Notes |
|---|---|
|
|
| Webhook: a JSON string, or plain text, which is treated as the body. Email: raw RFC 822, HTML, or plain text. It is normalized the same way as live mail. |
| A mapping array. It is validated the same way as on create. |
{
"extracted": {
"itemSummary": "Example Console Bundle",
"orderNumber": "2000123-45678",
"total": "$499.99",
"profile": "Main"
}
}Keys are the mapping target, or the customKey for custom mappings. A mapping that resolves to nothing returns "". When several mappings share a key, the first non-empty result is kept.
Preview match rules
POST /api/templates/custom/match-preview
Body field | Notes |
|---|---|
|
|
| As for preview. |
| A match-rule array. |
{
"allPassed": true,
"results": [
{
"rule": {
"id": "r1",
"selectorType": "json_path",
"selector": "embeds[0].footer.text",
"op": "contains",
"value": "Acme Monitor",
"scope": "auto",
"caseSensitive": false
},
"passed": true,
"resolved": "Acme Monitor v2"
}
]
}resolved is the value the selector found. Use it to tell a selector that found nothing apart from an operator that didn't match. An empty or missing rules returns {"allPassed": true, "results": []}. Remember that a template with no rules is never selected automatically.
Detect (full dry run)
POST /api/templates/custom/detect
Runs the full parser on a sample and answers two questions: which template would win, and what order would it produce? You can include an unsaved draft to test it in the position it would take once saved. Nothing is stored.
Body field | Notes |
|---|---|
|
|
| Webhook: a JSON string. Non-JSON text is wrapped as |
| Optional. A template body (same fields as create). Its |
Detect uses your saved custom templates plus the built-in shapes. It does not apply a bot's pinned template or an email source's templateIds list.
Example
Request:
curl -s -X POST https://shippified.net/api/templates/custom/detect \
-H "Authorization: Bearer $SHIPPIFIED_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"source": "webhook",
"sample": "{\"username\":\"Acme Monitor\",\"embeds\":[{\"title\":\"Successful Checkout\",\"footer\":{\"text\":\"Acme Monitor v2\"},\"thumbnail\":{\"url\":\"https://cdn.example.com/p/12345.png\"},\"fields\":[{\"name\":\"Site\",\"value\":\"Walmart\"},{\"name\":\"Product\",\"value\":\"Example Console Bundle\"},{\"name\":\"Price\",\"value\":\"$499.99\"},{\"name\":\"Order\",\"value\":\"||2000123-45678||\"},{\"name\":\"Profile\",\"value\":\"Main\"},{\"name\":\"Qty\",\"value\":\"2\"}]}]}",
"draft": {
"name": "Acme Monitor",
"eventType": "order_placed",
"mappings": [
{ "target": "itemSummary", "selectorType": "field_name", "selector": "Product" },
{ "target": "orderNumber", "selectorType": "json_path", "selector": "embeds[0].fields[3].value" },
{ "target": "total", "selectorType": "field_name", "selector": "Price" },
{ "target": "custom", "customKey": "profile", "selectorType": "field_name", "selector": "Profile" }
],
"matchRules": [
{ "id": "r1", "selectorType": "json_path", "selector": "embeds[0].footer.text", "op": "contains", "value": "Acme Monitor" }
]
}
}
EOFResponse 200:
{
"shape": { "id": "draft", "name": "Acme Monitor", "origin": "custom" },
"ignored": false,
"order": {
"store": "walmart",
"eventType": "order_placed",
"status": "ordered",
"orderNumber": "2000123-45678",
"itemSummary": "Example Console Bundle",
"total": "$499.99",
"quantity": 2,
"customerName": "Main",
"imageUrl": "https://cdn.example.com/p/12345.png",
"custom": { "profile": "Main" }
}
}In this result:
The draft's rule matched the footer, so
shape.idis"draft".The spoiler markup around the order number was stripped.
storewas detected from theSitefield andquantityfromQty. The draft doesn't map either one, so the generic fallbacks filled them.customerNamecame from the genericProfilelabel.imageUrlcame from the embed thumbnail.trackingNumberandcarrierare left out because they have no value.
Response fields
Field | Description |
|---|---|
|
|
| Email only: |
| A preview of the order: |
Invalid regexes in the draft return 400, the same as on create.
Recommended workflow
Capture a real payload. For Discord, take it from a bot's intake log (
GET /api/bots/:id/logs, or the workspace-wideGET /api/webhook-logs) or with the embed copier. For email, take it fromGET /api/inbox/:id, which returnsrawPayload.Build match rules with
match-previewuntil the right rules pass andresolvedshows the expected values.Build mappings with
preview.Run
detectwith the draft to confirm it wins over built-in shapes and your other templates.Save it with
POST /api/templates/custom.Run
POST /api/orders/reparseto apply it to stored messages.