Templates API

AdminUpdated Sep 24, 2026

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

GET

/api/templates

List built-in shapes.

GET

/api/templates/custom

List your custom templates, newest first.

POST

/api/templates/custom

Create a custom template.

PATCH

/api/templates/custom/:id

Update a custom template. Fields you leave out keep their values. See Update a template.

DELETE

/api/templates/custom/:id

Delete a custom template.

POST

/api/templates/custom/preview

Run mappings against a sample.

POST

/api/templates/custom/match-preview

Evaluate match rules against a sample.

POST

/api/templates/custom/detect

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

id

string

tmpl_…. Orders it parses record this as templateId, and their parser is custom:<id>.

source

"webhook" | "email"

Which channel the template applies to. Any value other than "email" is stored as "webhook". Set on create and fixed after that.

name

string

Up to 120 characters. Defaults to "Custom Template".

eventType

enum

The lifecycle event this template represents: order_placed, order_shipped, order_update, order_delivered, order_canceled, or unknown. With unknown, the event type is read from an eventType mapping or detected from the message text. Invalid values become unknown.

store

string

Optional fixed retailer: target, walmart, amazon, or bestbuy. When set, it overrides any extracted store. unknown and invalid values are dropped.

sample

string

The reference payload you built the template against. It is stored for your own reference and not used during matching.

mappings

array

Field extraction rules. See Mappings.

matchRules

array

Detection rules, all of which must pass. Left out when empty. See Match rules.

createdAt

timestamp

Creation time. Custom templates are tried newest createdAt first, and editing a template doesn't change it.

When a template is used

For each payload, candidate shapes are tried in this order, and the first whose rules all pass wins:

  1. Discord intake only: the template pinned on the bot (bot.templateId). It is applied without checking its rules.

  2. Your custom templates for the payload's channel, newest first by createdAt. The order is the same on every run and survives restarts, so GET /api/templates/custom shows you exactly the order they'll be tried in.

  3. 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

selectorType

Resolves to

json_path

The value at a path in the webhook JSON, converted to a trimmed string. Only works on Discord payloads. On email it never resolves.

field_name

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 Label: value line in the text for the rule's scope.

regex

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
username

Filters, 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.

scope

Email

Discord

header

The decoded From, To, Subject, and Date lines

Empty (never matches)

body

The decoded HTML (or text if there is no HTML), then the plain-text rendering

The flattened embed text

auto (default)

header if the selector starts with from, subject, to, cc, bcc, reply-to, return-path, or message-id, otherwise body

Same rule. In practice this is body.

Operators

op

Passes when

equals

The resolved value equals value. Case-insensitive unless caseSensitive.

contains

The resolved value contains value. Case-insensitive unless caseSensitive.

regex

value, compiled as a regex with the same flags as above, matches the resolved value.

exists

The selector resolved to a non-empty string. value is ignored.

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

orderNumber

orderNumber

The key used to merge messages into one order. Map it whenever the payload has one.

itemSummary

itemSummary

customerName

customerName

productUrl, imageUrl

same

total, price

same

Display strings. costCents is parsed from price, falling back to total.

eventTime, sku, size

same

quantity

quantity

Digits are pulled out of the value and rounded. Values of 0 or less are dropped.

trackingNumber

trackingNumber

carrier

carrier

If not mapped, the carrier is detected from the tracking number.

store

store

Normalized to a store key, or detected from the text. Ignored when the template has a fixed store.

eventType

eventType

Must be an event-type value, or text that describes the event (for example "shipped"). Ignored when the template's eventType is not unknown.

custom

custom[customKey]

Any extra value. Without a customKey, the selector is used as the key.

Resolution works like match rules, with small differences:

  • json_path reads from the webhook JSON only.

  • field_name checks the embed field with that name first, then a Label: value line in the plain text, then in the email headers.

  • regex searches the body, then the plain text, then the headers. It uses capture group 1 if present, otherwise the whole match.

  • There is no scope on 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, or op, or with an empty selector, are dropped without an error. Compare the returned template with what you sent.

  • A regex selector, or a rule value with op: "regex", that doesn't compile returns 400. 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/reparse to 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

name, sample

Keeps the stored value.

Replaces it. An empty name falls back to the stored name, then "Custom Template".

eventType

Keeps the stored value.

Replaces it. Invalid values become unknown.

store

Keeps the stored value.

Replaces it. Send null (or "unknown") to clear it.

mappings

Keeps the stored list.

Replaces the whole list. Send every mapping you want to keep.

matchRules

Keeps the stored list.

Replaces the whole list. Send [] to remove every rule.

source

—

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.

id, userId, createdAt

—

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

source

"webhook" or "email".

sample

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.

mappings

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

source

"webhook" or "email".

sample

As for preview.

rules

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

source

"webhook" or "email". Default "webhook".

sample

Webhook: a JSON string. Non-JSON text is wrapped as { "content": "<text>" }. Email: raw RFC 822, HTML, or text.

draft

Optional. A template body (same fields as create). Its source is forced to the request's source. If draft.id matches one of your templates, the draft is merged onto it the same way PATCH would merge it and replaces it for this run; otherwise it runs with id "draft". The draft is tried before all your saved templates.

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" }
    ]
  }
}
EOF

Response 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.id is "draft".

  • The spoiler markup around the order number was stripped.

  • store was detected from the Site field and quantity from Qty. The draft doesn't map either one, so the generic fallbacks filled them.

  • customerName came from the generic Profile label.

  • imageUrl came from the embed thumbnail.

  • trackingNumber and carrier are left out because they have no value.

Response fields

Field

Description

shape

{ id, name, origin } of the winning shape. origin is "custom" or "builtin". null if nothing matched and only generic extraction ran.

ignored

Email only: true if live intake would drop this message as non-order mail. Always false for webhooks.

order

A preview of the order: store, eventType, status, orderNumber, itemSummary, total, quantity, trackingNumber, carrier, customerName, imageUrl, custom. Empty fields are left out.

Invalid regexes in the draft return 400, the same as on create.

Recommended workflow

  1. Capture a real payload. For Discord, take it from a bot's intake log (GET /api/bots/:id/logs, or the workspace-wide GET /api/webhook-logs) or with the embed copier. For email, take it from GET /api/inbox/:id, which returns rawPayload.

  2. Build match rules with match-preview until the right rules pass and resolved shows the expected values.

  3. Build mappings with preview.

  4. Run detect with the draft to confirm it wins over built-in shapes and your other templates.

  5. Save it with POST /api/templates/custom.

  6. Run POST /api/orders/reparse to apply it to stored messages.

Was this page helpful?