Skip to content

210 × 297 mm

Reports

Monthly KPI report

A two-page A4 management report with KPI cards that show the change against last month, a 12-month revenue bar chart drawn with render_chart from a JSON array, the current month highlighted against a target line, revenue by channel, a top-products table and Markdown commentary, with page numbers.

A4 portraitLocale en-GBHTML + JinjaHeader & footer

Opens Dynamic Document API with this template preloaded in a free workspace.

Template language features

  • Loops
  • Conditionals
  • Macros
  • Filters
  • Currency formatting
  • Date formatting
  • Number formatting
  • Markdown
  • Chart
  • Page breaks
  • Repeating table headers
  • Header & footer
  • Page numbers

Filters such as format_currency and qrcode are described in the template language reference.

Variables

Derived from the sample data: 8 top-level fields, 38 paths in total. Send the same shape as data.

PathTypeExample
companyobject
company.namestring"Fernhollow Tea Company"
company.websitestring"fernhollow.example.com"
reportobject
report.titlestring"Monthly performance report"
report.period_labelstring"August 2026"
report.comparison_labelstring"July 2026"
report.prepared_bystring"Nadia Rahimi, Head of Finance"
report.issued_ondate"2026-09-08"
report.audiencestring"Board and leadership team"
report.currencystring"GBP"
report.localestring"en-GB"
kpisarray<object> · 6
kpis[].labelstring"Revenue"
kpis[].valuenumber184250
kpis[].previousnumber171900
kpis[].formatstring"currency"
kpis[].decimalsnumber0
kpis[].betterstring"higher"
monthly_revenuearray<object> · 12
monthly_revenue[].monthdate"2025-09-01"
monthly_revenue[].valuenumber138400
chartobject
chart.max_valuenumber250000
chart.stepnumber50000
chart.targetnumber180000
channelsarray<object> · 3
channels[].namestring"Online shop"
channels[].revenuenumber114235
channels[].colorstring"#b7791f"
top_productsarray<object> · 8
top_products[].namestring"Harbour Breakfast loose leaf, 250 g"
top_products[].skustring"FH-HBK-250"
top_products[].unitsnumber1840
top_products[].previous_unitsnumber1702
top_products[].unit_pricenumber8.9
top_products[].revenuenumber16376
commentarystring"### Highlights ⏎ - Revenue rose **7.2%** on Ju…"

Sample data

Fictional records used for previews. Replace them with your own data when you call the API.

data.json
{
  "company": {
    "name": "Fernhollow Tea Company",
    "website": "fernhollow.example.com"
  },
  "report": {
    "title": "Monthly performance report",
    "period_label": "August 2026",
    "comparison_label": "July 2026",
    "prepared_by": "Nadia Rahimi, Head of Finance",
    "issued_on": "2026-09-08",
    "audience": "Board and leadership team",
    "currency": "GBP",
    "locale": "en-GB"
  },
  "kpis": [
    { "label": "Revenue", "value": 184250, "previous": 171900, "format": "currency", "decimals": 0, "better": "higher" },
    { "label": "Orders", "value": 4812, "previous": 4655, "format": "number", "decimals": 0, "better": "higher" },
    { "label": "Average order value", "value": 38.29, "previous": 36.93, "format": "currency", "decimals": 2, "better": "higher" },
    { "label": "Gross margin", "value": 0.584, "previous": 0.571, "format": "percent", "decimals": 1, "better": "higher" },
    { "label": "Returning customers", "value": 0.412, "previous": 0.398, "format": "percent", "decimals": 1, "better": "higher" },
    { "label": "Refund rate", "value": 0.021, "previous": 0.026, "format": "percent", "decimals": 1, "better": "lower" }
  ],
  "monthly_revenue": [
    { "month": "2025-09-01", "value": 138400 },
    { "month": "2025-10-01", "value": 146200 },
    { "month": "2025-11-01", "value": 172800 },
    { "month": "2025-12-01", "value": 205300 },
    { "month": "2026-01-01", "value": 128900 },
    { "month": "2026-02-01", "value": 131500 },
    { "month": "2026-03-01", "value": 149700 },
    { "month": "2026-04-01", "value": 152300 },
    { "month": "2026-05-01", "value": 158800 },
    { "month": "2026-06-01", "value": 163400 },
    { "month": "2026-07-01", "value": 171900 },
    { "month": "2026-08-01", "value": 184250 }
  ],
  "chart": {
    "max_value": 250000,
    "step": 50000,
    "target": 180000
  },
  "channels": [
    { "name": "Online shop", "revenue": 114235, "color": "#b7791f" },
    { "name": "Wholesale", "revenue": 44220, "color": "#1f2a37" },
    { "name": "Subscriptions", "revenue": 25795, "color": "#8fa3b8" }
  ],
  "top_products": [
    { "name": "Harbour Breakfast loose leaf, 250 g", "sku": "FH-HBK-250", "units": 1840, "previous_units": 1702, "unit_price": 8.9, "revenue": 16376.0 },
    { "name": "Smoked Lapsang, 100 g tin", "sku": "FH-LAP-100", "units": 1212, "previous_units": 1251, "unit_price": 11.5, "revenue": 13938.0 },
    { "name": "Moorland Green sencha, 100 g", "sku": "FH-MGS-100", "units": 1106, "previous_units": 980, "unit_price": 9.75, "revenue": 10783.5 },
    { "name": "Monthly tea subscription box", "sku": "FH-SUB-BOX", "units": 438, "previous_units": 421, "unit_price": 24.0, "revenue": 10512.0 },
    { "name": "Cast-iron teapot, 800 ml", "sku": "FH-POT-800", "units": 231, "previous_units": 198, "unit_price": 42.0, "revenue": 9702.0 },
    { "name": "Evening chamomile and honeybush, 20 bags", "sku": "FH-EVE-020", "units": 1524, "previous_units": 1560, "unit_price": 5.6, "revenue": 8534.4 },
    { "name": "Earl Grey blue flower, 250 g", "sku": "FH-EGB-250", "units": 802, "previous_units": 815, "unit_price": 10.4, "revenue": 8340.8 },
    { "name": "Gift sampler, six teas", "sku": "FH-GFT-006", "units": 396, "previous_units": 344, "unit_price": 19.5, "revenue": 7722.0 }
  ],
  "commentary": "### Highlights\n\n- Revenue rose **7.2%** on July to £184,250, the strongest month since December, helped by the late-summer sampler campaign.\n- Gross margin improved to 58.4% after the new packaging contract started on 1 August.\n- The refund rate fell to 2.1% following the courier change for deliveries to Scotland.\n\n### Watch items\n\n- Two wholesale café orders moved into September; we expect **£6,000 to £8,000** to land next month.\n- Cast-iron teapot stock covers about three weeks of sales at the current rate.\n\n### Next month\n\n- Launch the autumn blends on 15 September.\n- Review subscription pricing ahead of the October renewal cycle."
}

Source

The body markup with Jinja, the stylesheet placed in <head> and the header and footer settings.

{#- Monthly KPI report. Changes against the previous month are calculated here: percentage change for
    amounts and counts, percentage-point change for rates. Whether a change is good depends on each
    KPI's "better" direction. The bar chart is drawn by render_chart from monthly_revenue (the
    renderer turns it into SVG, so no JavaScript is needed), on the scale chart.max_value sets. -#}
{%- set locale = report.locale | default("en-GB") -%}
{%- set currency = report.currency -%}
{%- set month_total = channels | sum_by("revenue") -%}
{#- The chart's alternative text lists the values it draws, so a screen reader gets the same figures. -#}
{%- set chart_ns = namespace(parts=[]) -%}
{%- for month in monthly_revenue -%}
  {%- set chart_ns.parts = chart_ns.parts + [(month.month | format_date("%b %Y", locale)) ~ " " ~ (month.value | format_currency(currency, locale, 0))] -%}
{%- endfor -%}
{%- set chart_alt = "Bar chart of monthly revenue: " ~ (chart_ns.parts | join(", ")) ~ (("; monthly target " ~ (chart.target | format_currency(currency, locale, 0))) if chart.target else "") ~ "." -%}


{%- macro kpi_value(kpi, value) -%}
  {%- if kpi.format == "currency" -%}{{ value | format_currency(currency, locale, kpi.decimals) }}
  {%- elif kpi.format == "percent" -%}{{ value | format_percent(locale, kpi.decimals) }}
  {%- else -%}{{ value | format_number(locale, kpi.decimals) }}{%- endif -%}
{%- endmacro -%}

{%- macro change_badge(current, previous, format, better) -%}
  {%- set delta = current - previous -%}
  {%- set improved = (delta > 0 and better == "higher") or (delta < 0 and better == "lower") -%}
  <span class="change {{ 'flat' if delta == 0 else ('good' if improved else 'bad') }}">
    {%- if delta > 0 %}▲{% elif delta < 0 %}▼{% else %}■{% endif %}
    {% if format == "percent" -%}
      {{ (delta * 100) | abs | format_number(locale, 1) }} pp
    {%- else -%}
      {{ (delta / previous) | abs | format_percent(locale, 1) }}
    {%- endif -%}
  </span>
{%- endmacro -%}

<section class="title-block">
  <div>
    <p class="eyebrow">{{ company.name }} · {{ report.title }}</p>
    <h1>{{ report.period_label }}</h1>
    <p class="subtitle">Compared with {{ report.comparison_label }} · prepared by {{ report.prepared_by }} · issued {{ report.issued_on | format_date("long", locale) }}</p>
  </div>
  <div class="audience">{{ report.audience }}</div>
</section>

<section class="kpis">
  {% for kpi in kpis %}
  <div class="kpi">
    <p class="kpi-label">{{ kpi.label }}</p>
    <p class="kpi-value">{{ kpi_value(kpi, kpi.value) }}</p>
    <p class="kpi-foot">
      {{ change_badge(kpi.value, kpi.previous, kpi.format, kpi.better) }}
      <span class="kpi-prev">vs {{ kpi_value(kpi, kpi.previous) }}</span>
    </p>
  </div>
  {% endfor %}
</section>

<section class="panel chart-panel">
  <div class="panel-head">
    <h2>Revenue, last {{ monthly_revenue | length }} months</h2>
    <p class="legend">
      <span class="key key-past"></span>Previous months
      <span class="key key-current"></span>{{ report.period_label }}
      {% if chart.target %}<span class="key key-target"></span>Monthly target{% endif %}
    </p>
  </div>
  {{ render_chart("bar", monthly_revenue, label="month", value="value",
       labels=monthly_revenue | map(attribute="month") | map("format_date", "%b", locale) | list,
       names=["Revenue"], colors=["#ecd9b3", "#b7791f"], highlight_last=true, legend=false,
       format="money", currency=currency, locale=locale, compact=true, min=0, max=chart.max_value,
       reference=chart.target, reference_label="Monthly target",
       font_family="Inter", font_size=10, text_color="#667085",
       width="168mm", height="66mm", alt=chart_alt, class="chart") }}
</section>

<section class="panel channels">
  <div class="panel-head">
    <h2>Revenue by channel</h2>
    <p class="panel-total">{{ month_total | format_currency(currency, locale, 0) }}</p>
  </div>
  <div class="stack">
    {% for channel in channels %}<span style="width: {{ (channel.revenue / month_total * 100) | round(3) }}%; background: {{ channel.color }}"></span>{% endfor %}
  </div>
  <div class="channel-list">
    {% for channel in channels %}
    <div class="channel">
      <span class="swatch" style="background: {{ channel.color }}"></span>
      <span class="channel-name">{{ channel.name }}</span>
      <span class="channel-amount">{{ channel.revenue | format_currency(currency, locale, 0) }}</span>
      <span class="channel-share">{{ (channel.revenue / month_total) | format_percent(locale, 1) }}</span>
    </div>
    {% endfor %}
  </div>
</section>

{{ page_break() }}

<section class="products">
  <h2 class="section-title">Top products by revenue</h2>
  <table>
    <thead>
      <tr>
        <th class="rank" scope="col">#</th>
        <th scope="col">Product</th>
        <th class="num" scope="col">Units</th>
        <th class="num" scope="col">vs {{ report.comparison_label }}</th>
        <th class="num" scope="col">Revenue</th>
        <th class="share-col" scope="col">Share of revenue</th>
      </tr>
    </thead>
    <tbody>
      {% for product in top_products %}
      {%- set share = product.revenue / month_total -%}
      <tr>
        <td class="rank">{{ loop.index }}</td>
        <td>
          <span class="product-name">{{ product.name }}</span>
          <span class="sku">{{ product.sku }}</span>
        </td>
        <td class="num">{{ product.units | format_number(locale, 0) }}</td>
        <td class="num">{{ change_badge(product.units, product.previous_units, "number", "higher") }}</td>
        <td class="num">{{ product.revenue | format_currency(currency, locale) }}</td>
        <td class="share-col">
          <span class="share-bar"><span style="width: {{ (share / 0.1 * 100) | round(1) if share < 0.1 else 100 }}%"></span></span>
          <span class="share-value">{{ share | format_percent(locale, 1) }}</span>
        </td>
      </tr>
      {% endfor %}
    </tbody>
    <tfoot>
      <tr>
        <td class="blank"></td>
        <th scope="row">Top {{ top_products | length }} products</th>
        <td class="num">{{ top_products | sum_by("units") | format_number(locale, 0) }}</td>
        <td class="blank"></td>
        <td class="num">{{ top_products | sum_by("revenue") | format_currency(currency, locale) }}</td>
        <td class="share-col"><span class="share-value">{{ (top_products | sum_by("revenue") / month_total) | format_percent(locale, 1) }}</span></td>
      </tr>
    </tfoot>
  </table>
  <p class="table-note">Share bars are scaled so that 10% of monthly revenue fills the bar.</p>
</section>

<section class="commentary">
  <h2 class="section-title">Commentary</h2>
  <div class="commentary-body">{{ commentary | markdown }}</div>
</section>

Render it with the API

After adding the template to your workspace, render it by ID and pass your data. Arrays are shortened here.

curl -X POST https://api-eu.dynamicdocumentapi.com/v1/renders \
  -H "Authorization: Bearer $DYNAMIC_DOCUMENT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "input": {
    "type": "template",
    "template_id": "tpl_01J9ZK3M7Q8V5W2X4Y6Z8A0B1C"
  },
  "data": {
    "company": {
      "name": "Fernhollow Tea Company",
      "website": "fernhollow.example.com"
    },
    "report": {
      "title": "Monthly performance report",
      "period_label": "August 2026",
      "comparison_label": "July 2026",
      "prepared_by": "Nadia Rahimi, Head of Finance",
      "issued_on": "2026-09-08",
      "audience": "Board and leadership team",
      "currency": "GBP",
      "locale": "en-GB"
    },
    "kpis": [
      {
        "label": "Revenue",
        "value": 184250,
        "previous": 171900,
        "format": "currency",
        "decimals": 0,
        "better": "higher"
      },
      {
        "label": "Orders",
        "value": 4812,
        "previous": 4655,
        "format": "number",
        "decimals": 0,
        "better": "higher"
      }
    ],
    "monthly_revenue": [
      {
        "month": "2025-09-01",
        "value": 138400
      },
      {
        "month": "2025-10-01",
        "value": 146200
      }
    ],
    "chart": {
      "max_value": 250000,
      "step": 50000,
      "target": 180000
    },
    "channels": [
      {
        "name": "Online shop",
        "revenue": 114235,
        "color": "#b7791f"
      },
      {
        "name": "Wholesale",
        "revenue": 44220,
        "color": "#1f2a37"
      }
    ],
    "top_products": [
      {
        "name": "Harbour Breakfast loose leaf, 250 g",
        "sku": "FH-HBK-250",
        "units": 1840,
        "previous_units": 1702,
        "unit_price": 8.9,
        "revenue": 16376
      },
      {
        "name": "Smoked Lapsang, 100 g tin",
        "sku": "FH-LAP-100",
        "units": 1212,
        "previous_units": 1251,
        "unit_price": 11.5,
        "revenue": 13938
      }
    ],
    "commentary": "### Highlights\n\n- Revenue rose **7.2%** on July to £184,250, the strongest month since December, helped by the late-summer sampler campaign.\n- Gross margin improved to 58.4% after the new packaging contract started on 1 August.\n- The refund rate fell to 2.1% following the courier change for deliveries to Scotland.\n\n### Watch items\n\n- Two wholesale café orders moved into September; we expect **£6,000 to £8,000** to land next month.\n- Cast-iron teapot stock covers about three weeks of sales at the current rate.\n\n### Next month\n\n- Launch the autumn blends on 15 September.\n- Review subscription pricing ahead of the October renewal cycle."
  },
  "output": {
    "format": "pdf",
    "filename": "report-monthly-kpi.pdf"
  }
}'