NetStacksNetStacks

Templates API

Create, render, and version config templates via /api/config/templates with automatic variable extraction powered by the MiniJinja engine.

Overview

The Templates API lets you manage configuration templates programmatically. Templates define reusable network configurations with Jinja2-style placeholders that are filled in at render time. Use the API to create, list, update, render, delete, and version templates.

All template endpoints live under the config router at /api/config/templates and require an authenticated token with operator permission. See API Authentication for token setup.

Controller feature

The Templates API is part of the NetStacks Controller. Templates are scoped to your organization (org_id) and are not visible across organizations.

Automatic variable extraction

When you create or update a template, NetStacks parses the template source and extracts every undeclared variable automatically. The response includes a variables array describing each one (name, type, and whether it is required) so you know exactly what values to provide at render time.

Endpoints

All paths are relative to your Controller base URL, for example https://netstacks.example.net.

MethodPathDescription
GET/api/config/templatesList templates (summaries, with total count).
POST/api/config/templatesCreate a template. Returns 201 with the full record.
GET/api/config/templates/:idGet a single template (full record with source).
PUT/api/config/templates/:idUpdate a template. Editing the source bumps the version.
DELETE/api/config/templates/:idDelete a template. Returns 204 No Content.
POST/api/config/templates/:id/renderRender a template with variables (preview).
GET/api/config/templates/:id/versionsList all stored versions of a template.
GET/api/config/templates/:id/versions/:versionGet a specific version of a template by version number.

The list endpoint accepts the query parameters platform (filter by platform name), limit, and offset.

How It Works

Templates are stored with metadata including a name, optional description, a target platform, a config format, a default operation, and an optional target path. The template body itself is stored in the source field.

The rendering engine

NetStacks renders templates with the MiniJinja engine. It supports the common Jinja2 constructs you need for config generation: variables ({{ var }}), conditionals ({% if %} / {% endif %}), loops ({% for %}), and filters ({{ var | upper }}).

Two NetStacks-specific behaviours make the engine forgiving of templates written with a Python mindset:

  • Python-style .get() rewriting. The engine preprocesses obj.get('key') and obj.get("key") into attribute access obj.key before parsing, and also registers a global get(obj, key) helper function that returns the value or undefined.
  • Smart type coercion. Variable values sent as strings are auto-coerced: strings that look like JSON arrays or objects are parsed, "true"/"false" become booleans, and numeric strings become numbers. This means you can pass "100" and use it as an integer in the template.
No template inheritance

The engine does not enable Jinja2 template inheritance ({% extends %} / {% block %}). Keep each template self-contained rather than relying on a base template.

Variable extraction

On create and on any update that changes source, NetStacks parses the template and records every undeclared variable. Each entry is an object with a name, an inferred type (string, list, or number), and required: true. Variables used in a {% for x in VAR %} loop are typed as list; variables placed bare after a JSON colon ("key": {{ VAR }}) are typed as number.

Version management

Each template tracks a current_version. Updating the source creates a new version record. You can list every version and fetch any prior version by number, and you can render a specific historical version by passing version in the render request.

Request & Response Fields

Create / update body

name (string, required)
Human-readable template name.
source (string, required on create)
The template body in Jinja2 syntax. This is the field that holds the template — not content.
platform (string, required on create)
A single platform name (not a list), for example cisco_iosxr. Used to filter and organize templates.
config_format (string, required on create)
One of json, xml, or cli. Returned back in the render response as format.
operation (string, optional)
One of replace, merge, or delete. Defaults to replace.
target_path (string, optional)
The structured config path the rendered output targets (used by gNMI/NETCONF deployments).
description (string, optional)
Free-form notes about the template.
No content / device_types / tags

The body does not accept content, device_types, or tags fields. Use source for the body and the single platform string for targeting.

Template response object

A created or fetched template serializes these fields:

  • id, org_id, name, description
  • platform, config_format, operation, target_path
  • source — the template body
  • current_version — the latest version number (not version)
  • variables — an array of objects {name, type, required}
  • created_by, created_at, updated_at

The list endpoint returns a wrapper: { "data": [ ...summaries ], "total": N }. Each summary carries the same fields except source.

Code Examples

Create a template

create-template.shbash
curl -X POST https://netstacks.example.net/api/config/templates \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "VLAN Configuration",
    "description": "Standard VLAN interface config for Cisco IOS",
    "platform": "cisco_ios",
    "config_format": "cli",
    "operation": "merge",
    "source": "interface Vlan{{ vlan_id }}\n description {{ description }}\n ip address {{ ip_address }} {{ subnet_mask }}\n no shutdown"
  }'

# Response (201 Created):
# {
#   "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
#   "org_id": "0a1b2c3d-...",
#   "name": "VLAN Configuration",
#   "description": "Standard VLAN interface config for Cisco IOS",
#   "platform": "cisco_ios",
#   "config_format": "cli",
#   "operation": "merge",
#   "target_path": null,
#   "source": "interface Vlan{{ vlan_id }}\n ...",
#   "current_version": 1,
#   "variables": [
#     {"name": "vlan_id", "type": "string", "required": true},
#     {"name": "description", "type": "string", "required": true},
#     {"name": "ip_address", "type": "string", "required": true},
#     {"name": "subnet_mask", "type": "string", "required": true}
#   ],
#   "created_at": "2026-03-10T15:00:00Z",
#   "updated_at": "2026-03-10T15:00:00Z"
# }
create-template.pypython
import requests

base_url = "https://netstacks.example.net/api/config"
headers = {"Authorization": "Bearer eyJhbGciOiJIUzI1NiIs..."}

# Create a VLAN configuration template
template_source = """interface Vlan{{ vlan_id }}
 description {{ description }}
 ip address {{ ip_address }} {{ subnet_mask }}
 no shutdown"""

resp = requests.post(f"{base_url}/templates", headers=headers, json={
    "name": "VLAN Configuration",
    "description": "Standard VLAN interface config for Cisco IOS",
    "platform": "cisco_ios",
    "config_format": "cli",
    "operation": "merge",
    "source": template_source,
})
template = resp.json()
print(f"Created template: {template['id']}")
print(f"Version: {template['current_version']}")
for v in template["variables"]:
    print(f"  {v['name']} ({v['type']}, required={v['required']})")

Render a template

POST a variables object (and optionally a version to render a historical version). The response returns the rendered text plus the template's format.

render-template.shbash
curl -X POST https://netstacks.example.net/api/config/templates/b2c3d4e5-f6a7-8901-bcde-f23456789012/render \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{
    "variables": {
      "vlan_id": "100",
      "description": "Server-Farm-DC1",
      "ip_address": "10.100.0.1",
      "subnet_mask": "255.255.255.0"
    }
  }'

# Response (200 OK):
# {
#   "rendered": "interface Vlan100\n description Server-Farm-DC1\n ip address 10.100.0.1 255.255.255.0\n no shutdown",
#   "format": "cli"
# }
render-template.pypython
# Render the current version
rendered = requests.post(
    f"{base_url}/templates/{template['id']}/render",
    headers=headers,
    json={
        "variables": {
            "vlan_id": "100",
            "description": "Server-Farm-DC1",
            "ip_address": "10.100.0.1",
            "subnet_mask": "255.255.255.0",
        }
    },
)
result = rendered.json()
print(f"format: {result['format']}")
print(result["rendered"])

# Render a specific historical version
v2 = requests.post(
    f"{base_url}/templates/{template['id']}/render",
    headers=headers,
    json={"version": 2, "variables": {"vlan_id": "100", "description": "x",
          "ip_address": "10.0.0.1", "subnet_mask": "255.255.255.0"}},
).json()
print(v2["rendered"])

A JSON-format template with a loop

Because the engine coerces string values, a list passed as a JSON string is parsed into an iterable, and a bare numeric placeholder is typed as a number during extraction.

bgp-openconfig.j2jinja2
{
  "openconfig-network-instance:network-instance": [
    {
      "name": "default",
      "protocols": {
        "protocol": [
          {
            "identifier": "BGP",
            "name": "bgp",
            "bgp": {
              "global": { "config": { "as": {{ local_asn }} } },
              "neighbors": {
                "neighbor": [
{% for n in neighbors %}                  {
                    "neighbor-address": "{{ n.ip }}",
                    "config": { "peer-as": {{ n.remote_asn }} }
                  }{% if not loop.last %},{% endif %}
{% endfor %}                ]
              }
            }
          }
        ]
      }
    }
  ]
}
render-vars.jsonjson
# Variable values. "local_asn" is coerced from string to number,
# and "neighbors" is coerced from a JSON string into a real list.
{
  "variables": {
    "local_asn": "65001",
    "neighbors": "[{\"ip\": \"10.0.0.2\", \"remote_asn\": 65002}, {\"ip\": \"10.0.0.3\", \"remote_asn\": 65003}]"
  }
}

List, fetch, and version

list-versions.shbash
# List templates, optionally filtered by platform
curl "https://netstacks.example.net/api/config/templates?platform=cisco_ios&limit=50" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

# Response:
# {
#   "data": [
#     {
#       "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
#       "name": "VLAN Configuration",
#       "platform": "cisco_ios",
#       "config_format": "cli",
#       "operation": "merge",
#       "current_version": 3,
#       "variables": [ ... ]
#     }
#   ],
#   "total": 1
# }

# List every stored version of a template
curl "https://netstacks.example.net/api/config/templates/b2c3d4e5-f6a7-8901-bcde-f23456789012/versions" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

# Fetch one specific version
curl "https://netstacks.example.net/api/config/templates/b2c3d4e5-f6a7-8901-bcde-f23456789012/versions/2" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
list-templates.pypython
resp = requests.get(f"{base_url}/templates", headers=headers,
                    params={"platform": "cisco_ios", "limit": 50})
body = resp.json()
print(f"{body['total']} templates")
for t in body["data"]:
    print(f"{t['name']} (v{t['current_version']}) - {t['platform']}")

Update a template (creates a new version)

update-template.shbash
curl -X PUT https://netstacks.example.net/api/config/templates/b2c3d4e5-f6a7-8901-bcde-f23456789012 \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{
    "source": "interface Vlan{{ vlan_id }}\n description {{ description }}\n ip address {{ ip_address }} {{ subnet_mask }}\n mtu {{ mtu }}\n no shutdown"
  }'

# Editing source re-extracts variables and increments current_version.

Questions & Answers

What is the base path for the Templates API?
Templates live under the config router: /api/config/templates. The older /api/templates path does not exist.
How do I create a template via the API?
Send a POST to /api/config/templates with name, source (the template body), platform, and config_format. Optional fields are operation, target_path, and description.
Why is my request rejected when I send a "content" field?
The template body field is source, not content. Likewise there are no device_types or tags fields — use the single platform string instead.
How do I render a template with variables?
Send a POST to /api/config/templates/:id/render with a variables object. The response is { "rendered": "...", "format": "<config_format>" }. Add an optional version to render a past version.
How does template versioning work?
Each time you change a template's source, a new version is stored and current_version increments. List versions at /versions and fetch a specific one at /versions/:version.
What does the variables array look like?
It is an array of objects, not plain strings — for example {"name": "vlan_id", "type": "string", "required": true}. The type is inferred as string, list (used in a for loop), or number (a bare value after a JSON colon).
What Jinja2 features are supported?
The MiniJinja engine supports variables ({{ var }}), conditionals ({% if %}), loops ({% for %}), and filters ({{ var | upper }}). Template inheritance ({% extends %}) is not enabled. A custom get(obj, key) helper and obj.get('key') rewriting are provided for Python-style templates.
Do I have to send variable values with the right type?
No. The engine coerces string values automatically: "100" becomes a number, "true" becomes a boolean, and a JSON-array or JSON-object string is parsed into the real structure before rendering.

Troubleshooting

404 on /api/templates

The correct base path is /api/config/templates. A bare /api/templates URL returns 404 because templates are served by the config router.

Missing required field on create

Creating a template requires name, source, platform, and config_format. If you sent content instead of source, the body will be rejected as missing the template source. See Error Codes.

Render error: undefined variable or bad syntax

The render handler returns a 400 Bad Request with a Render error: message when the template fails to render — for example an unclosed {{ }} block, mismatched {% if %} / {% endif %} tags, or a variable that the engine could not resolve. Check the template's variables array and make sure every name appears in your variables object.

Template version not found

Rendering or fetching a non-existent version returns 404 Template version not found. Versions start at 1 and increment on each source change — use the /versions endpoint to see which versions exist.

A list variable renders as a single value

If you pass a list as a plain string it must be valid JSON for coercion to parse it (for example "[1, 2, 3]"). Malformed JSON stays a string, so a {% for %} loop over it will not iterate as expected.