🧩Templates API
Create custom parsing templates with match rules and field mappings, preview them against samples, and dry-run the full parser.
- Written for
- Developers teaching Shippified to parse new bots or emails
- Applies to
- All plans
- Deprecated
- + Deprecated
A custom template tells the parser how to recognise a message and what to pull out of it. It has two parts:
Match rules decide whether the template applies to a message. Every rule must pass.
Mappings extract order fields from a message the template applies to.
Custom templates run on the same engine as the built-in retailer and monitor shapes, and the preview and detect endpoints call that same code, so a preview is exactly what live intake will produce. For the concepts 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 template. |
|
| Update a template; fields you leave out keep their values. |
|
| Delete a template. |
|
| Run mappings against a sample. |
|
| Check match rules against a sample. |
|
| Run the whole parser on a sample, optionally with an unsaved draft. |
Build a template, start to finish
- Capture a real message
For Discord, copy a post from a bot's log (
GET /api/bots/:id/logs→rawPayload) or use the embed copier. For email, fetch one from the inbox withGET /api/inbox/:id→rawPayload, or export the.emlfrom your mail client. - Write match rules
Call
match-previewuntil the right rules pass and eachresolvedvalue is what you expect. - Write mappings
Call
previewuntil every field you care about comes back filled. - Dry-run the whole thing
Call
detectwith your draft. Checkshape.idis"draft"(your template wins over built-ins and your other templates) andorderlooks right. - Save it
POST /api/templates/customwith the same body. - Apply it to past orders
POST /api/orders/reparsere-runs stored messages through the new template. New messages use it straight away.
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 it applies to. Anything other than |
| string | Up to 120 characters. Defaults to |
| enum |
|
| string | Optional fixed retailer: |
| string | The message you built it against. Kept for reference; not used for matching. |
| array | Field extraction rules. See Mappings. |
| array | Rules that must all pass. Left out when empty. See Match rules. |
| timestamp | Templates are tried newest |
When a template is used
For each message, candidates are tried in order and the first whose rules all pass wins:
Discord only: the template pinned on the bot (
bot.templateId), applied without checking its rules.Your custom templates for the message's channel, newest first by
createdAt.GET /api/templates/customlists them in exactly this order.Built-in shapes.
An email source with a non-empty templateIds list narrows the candidates to those IDs, in that order.
A template with no match rules is never picked automatically, for email or Discord, even if it's listed in an email source's templateIds. The only way to use a rule-less template is to pin it on a bot.
After the winner's mappings run, generic patterns fill any fields still empty. See How a message becomes an order.
Match rules
interface MatchRule {
id: string; // generated if you omit it
selectorType: "json_path" | "field_name" | "regex";
selector: string;
op: "equals" | "contains" | "regex" | "exists";
value?: string; // needed for equals / contains / regex
scope?: "auto" | "header" | "body"; // default "auto"
caseSensitive?: boolean; // default false
}A rule first resolves its selector to a string, then tests that string with op.
Selector types
| Resolves to |
|---|---|
| The value at a path in the Discord JSON, as a trimmed string. Never resolves on email. |
| Discord: the embed field with that name (ignoring case). If there's none, and always for email, the value of a |
| The first match in the text for the rule's scope. Capture group 1 if there is one, otherwise the whole match. |
JSONPath is a simple dot path. A leading $. is optional; array indexes are written key[n]. Filters, wildcards and quoted keys aren't supported.
embeds[0].footer.text
$.embeds[0].fields[2].value
usernameLabel lines for field_name match the label, optional spaces, one of :, # or -, then the value, up to the first |, , or line break. Order #: 112-3458291 resolves Order to 112-3458291.
Regex uses JavaScript syntax with the s flag (. matches newlines) and the i flag unless caseSensitive is true. Don't include slashes or flags in the string.
Scope
scope sets which text field_name and regex rules search. It doesn't affect json_path.
| Discord | |
|---|---|---|
| The decoded | Nothing (never matches) |
| The decoded HTML (or text if there's no HTML), then the plain-text version | The flattened embed text |
|
| In practice, |
Operators
| Passes when |
|---|---|
| The resolved value equals |
| The resolved value contains |
|
|
| The selector resolved to a non-empty string. |
A rule whose selector finds nothing fails. An error inside a rule counts as a failure, never 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)", "scope": "header" },
{ "selectorType": "field_name", "selector": "Tracking", "op": "exists" }
]Mappings
interface CustomTemplateMapping {
target: NormalizedOrderField;
customKey?: string; // for target "custom"
selectorType: "json_path" | "field_name" | "regex";
selector: string;
sampleValue?: string; // stored as-is, for your reference
}
| Notes |
|---|---|
| The merge key. Map it whenever the message has one. |
| Product name. |
| Profile, buyer or customer name. |
| Links. |
| Display text. |
| As extracted. |
| Digits are pulled out and rounded; 0 or less is dropped. |
| If unmapped, generic patterns look for one. |
| If unmapped, detected from the tracking number. |
| Turned into a store key, or detected from the text. Ignored when the template has a fixed |
| An event-type value or text describing it (such as "shipped"). Ignored unless the template's |
| Any extra value, stored in the order's |
Mappings resolve like rules, with small differences: json_path reads the Discord JSON only; field_name tries the embed field, then a Label: value line in the plain text, then the email headers; regex searches the body, then the plain text, then the headers; there's no scope.
Every value is cleaned: HTML tags stripped, common entities decoded, Discord markdown (||, **, __, backticks) removed and whitespace 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 takes the template object without id, userId or createdAt, and returns 201 with the stored template.
- curl
- TypeScript SDK
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" }
]
}'const template = await shippified.templates.createCustom({
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" },
],
matchRules: [
{ id: "footer", selectorType: "json_path", selector: "embeds[0].footer.text", op: "contains", value: "Acme Monitor" },
],
});
await shippified.orders.reparse(); // apply it to stored messagesValidation:
Mappings and rules with an unknown
target,selectorTypeorop, or an emptyselector, are dropped without an error. Compare the response 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: …"},{"error": "Rule 2 value is not a valid regex: …"}or{"error": "Mapping 2 (orderNumber) is not a valid regex: …"}.A new template doesn't change existing orders until you re-parse.
Update a template
PATCH /api/templates/custom/:id is a partial update. Send only what you want to change.
- curl
- TypeScript SDK
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" }'await shippified.templates.updateCustom("tmpl_mq1a2b3c", { name: "Acme Monitor v3" });Field | Left out | Sent |
|---|---|---|
| Keeps the stored value. | Replaces it. An empty name becomes |
| Keeps the stored value. | Replaces it. |
| Keeps the stored value. | Replaces it; invalid values become |
| Keeps the stored value. | Replaces it. |
| Keeps the list. | Replaces the whole list. Send every mapping you want to keep. |
| Keeps the list. | Replaces the whole list. |
| — | Ignored. Create a new template to change channel. |
| — | Ignored. The template keeps its place in the order. |
Validation is the same as on create; an invalid regex returns 400 and changes nothing. Returns 200 with the template, or 404 {"error": "Template not found"}.
Delete a template
DELETE /api/templates/custom/:id returns 200 {"ok": true} or 404 {"error": "Template not found"}. Orders it already parsed keep their data until they're re-parsed.
List templates
GET /api/templates/custom returns { "items": [ … ] }: every custom template of both kinds, newest first (the order they're tried in). Through the SDK, templates.listCustom() returns the array directly; through MCP, the tool is list_custom_templates.
GET /api/templates returns the built-in shapes:
{
"webhookTemplates": [
{ "id": "hayha", "displayName": "Hayha", "platform": "hayha", "description": "Hayha checkout / monitor embed." }
],
"emailTemplates": [
{ "id": "target-order_shipped", "retailer": "target", "displayName": "…", "eventType": "order_shipped", "description": "…" }
]
}Use these ids in bot.templateId or an email source's templateIds.
Preview mappings
POST /api/templates/custom/preview runs each mapping on its own against a sample.
Body field | Notes |
|---|---|
|
|
| Webhook: a JSON string (plain text is treated as the body). Email: raw RFC 822, HTML or text, normalised like live mail. |
| A mapping array, validated 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. A mapping that finds nothing returns "". When several share a key, the first non-empty result is kept.
Preview match rules
POST /api/templates/custom/match-preview takes source, sample and rules:
{
"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 what 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 a template with no rules is never picked automatically.
Detect: a full dry run
POST /api/templates/custom/detect runs the whole parser on a sample and answers: which template wins, and what order would it produce? Nothing is stored.
Body field | Notes |
|---|---|
|
|
| Webhook: a JSON string (non-JSON text is wrapped as |
| Optional. A template body as on create. Its |
Detect uses your saved templates and the built-in shapes. It does not apply a bot's pinned template or an email source's templateIds.
- curl
- TypeScript SDK
- MCP
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" }
]
}
}
EOFconst result = await shippified.templates.detect({
source: "webhook",
sample: JSON.stringify(payload),
draft: {
source: "webhook",
name: "Acme Monitor",
eventType: "order_placed",
mappings: [{ target: "itemSummary", selectorType: "field_name", selector: "Product" }],
matchRules: [{ id: "r1", selectorType: "json_path", selector: "embeds[0].footer.text", op: "contains", value: "Acme Monitor" }],
},
});
console.log(result.shape, result.order);"Which Shippified template would parse this webhook? Don't import it: …" (tool detect_template)
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" }
}
}Here the draft's rule matched the footer, so shape.id is "draft"; the spoiler bars around the order number were stripped; store (from Site), quantity (from Qty) and customerName (from Profile) were filled by the generic patterns; and imageUrl came from the thumbnail.
Field | Description |
|---|---|
|
|
| Email only: |
|
|
An invalid regex in the draft returns 400, as on create.
Troubleshooting
detect shows a built-in shape winning instead of my draft
Your draft's rules didn't all pass. Run match-preview with the same rules and sample and look at each resolved value. Remember the draft is tried first, so a passing draft always wins.
My saved template never matches live messages
Check it has at least one match rule, that its source matches the channel, and (for email) that the email source's templateIds is empty or includes it. For a bot, you can also pin it with templateId.
Some of my mappings disappeared after saving
Mappings with an unknown target or selectorType, or an empty selector, are dropped silently. Also, PATCH replaces the whole mappings list: send every mapping you want to keep.
Old orders still show the old values
Templates apply to new messages. Run POST /api/orders/reparse to rebuild past orders.