Skip to content

Jinja2Image

Metrics card with a chart, 1200×675

A 16:9 card for Slack, LinkedIn or X from thirty days of orders: totals, the change on the month before, the busiest day found with sortBy and first, and a Chart.js bar chart styled for a dark background, all computed in the template.

Free plan, no card. You get your own copy in the editor, published and ready for the API.

Rendered result

Rendered result: Metrics card with a chart, 1200×675
PNG · 52 KB ↓ download

What this example shows

  • Thirty rows of daily orders go in; the template sums them to 2,524 orders and £214,791.00 and divides for the average order, £85.10.
  • The change on August is computed and rounded in the template, ▲ 13.1%, and turns red with a downward arrow when the month was weaker.
  • days | sortBy('orders', 'desc') | first finds the busiest day, Sun 27 Sep with 126 orders.
  • The chart is Chart.js with its options passed through, tick and grid colours for the dark card, and range(1, (days | length) + 1) numbers the days.
  • access: "public" gives a link Slack can fetch for an image block in the team channel.

Helpers used

Template folder

templates/metrics-card/template.htmlJinja2
{% set orders = days | sum('orders') %}
{% set revenue = days | sum('revenue') %}
{% set change = ((orders - previous.orders) / previous.orders * 100) | round(1) %}
{% set busiest = days | sortBy('orders', 'desc') | first %}
<main class="card">
  <header>
    <p class="brand">{{ shop.name }}</p>
    <h1>{{ period | date('MMMM yyyy') }} in numbers</h1>
  </header>

  <section class="kpis">
    <div>
      <span>Orders</span>
      <strong>{{ orders | number(0) }}</strong>
      <em class="{{ 'up' if change >= 0 else 'down' }}">{{ '▲' if change >= 0 else '▼' }} {{ change if change >= 0 else -change }}% on {{ previous.label }}</em>
    </div>
    <div>
      <span>Revenue</span>
      <strong>{{ revenue | money }}</strong>
      <em>{{ (revenue / orders) | money }} per order</em>
    </div>
    <div>
      <span>Busiest day</span>
      <strong>{{ busiest.date | date('EEE d MMM') }}</strong>
      <em>{{ busiest.orders }} orders</em>
    </div>
  </section>

  {{ chart({
    type: 'bar',
    labels: range(1, (days | length) + 1),
    values: days | pluck('orders'),
    color: '#818cf8',
    options: {
      scales: {
        x: { ticks: { color: '#94a3b8', font: { size: 13 } }, grid: { display: false } },
        y: { ticks: { color: '#94a3b8', font: { size: 13 } }, grid: { color: 'rgba(148, 163, 184, 0.18)' } }
      }
    }
  }, { width: 1040, height: 290 }) }}
</main>
templates/metrics-card/style.cssCSS
body { margin: 0; }
.card {
  box-sizing: border-box;
  width: 1200px;
  height: 675px;
  padding: 52px 80px 40px;
  background: #0f172a;
  font-family: 'Inter', sans-serif;
  color: #f8fafc;
}
header { display: flex; justify-content: space-between; align-items: baseline; }
.brand { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: 0.2em; text-transform: uppercase; color: #a5b4fc; }
h1 { margin: 0; font-size: 36px; font-weight: 700; }
.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; margin: 34px 0 30px; }
.kpis div { padding: 20px 24px; border-radius: 16px; background: #1e293b; }
.kpis span { display: block; font-size: 17px; color: #94a3b8; }
.kpis strong { display: block; margin: 6px 0 4px; font-size: 40px; font-weight: 700; }
.kpis em { font-size: 17px; font-style: normal; color: #94a3b8; }
.kpis em.up { color: #4ade80; }
.kpis em.down { color: #f87171; }
.ff-chart { display: block; }
templates/metrics-card/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/metrics-card/settings.jsonJSON
{
  "image": {
    "width": 1200,
    "height": 675,
    "deviceScaleFactor": 1,
    "format": "png"
  },
  "locale": "en-GB",
  "timezone": "Europe/London",
  "currency": "GBP"
}
templates/metrics-card/data/default.jsonJSON
{
  "shop": {
    "name": "Fennlor Studio shop"
  },
  "period": "2026-09-01",
  "previous": {
    "label": "August",
    "orders": 2231
  },
  "days": [
    {
      "date": "2026-09-01",
      "orders": 64,
      "revenue": 5376
    },
    {
      "date": "2026-09-02",
      "orders": 71,
      "revenue": 5989
    },
    {
      "date": "2026-09-03",
      "orders": 69,
      "revenue": 5846
    },
    {
      "date": "2026-09-04",
      "orders": 83,
      "revenue": 7047
    },
    {
      "date": "2026-09-05",
      "orders": 104,
      "revenue": 8836
    },
    {
      "date": "2026-09-06",
      "orders": 112,
      "revenue": 9533
    },
    {
      "date": "2026-09-07",
      "orders": 58,
      "revenue": 5022
    },
    {
      "date": "2026-09-08",
      "orders": 66,
      "revenue": 5719
    },
    {
      "date": "2026-09-09",
      "orders": 73,
      "revenue": 6332
    },
    {
      "date": "2026-09-10",
      "orders": 70,
      "revenue": 5880
    },
    {
      "date": "2026-09-11",
      "orders": 88,
      "revenue": 7417
    },
    {
      "date": "2026-09-12",
      "orders": 109,
      "revenue": 9206
    },
    {
      "date": "2026-09-13",
      "orders": 117,
      "revenue": 9903
    },
    {
      "date": "2026-09-14",
      "orders": 61,
      "revenue": 5224
    },
    {
      "date": "2026-09-15",
      "orders": 68,
      "revenue": 5837
    },
    {
      "date": "2026-09-16",
      "orders": 75,
      "revenue": 6450
    },
    {
      "date": "2026-09-17",
      "orders": 72,
      "revenue": 6223
    },
    {
      "date": "2026-09-18",
      "orders": 91,
      "revenue": 7844
    },
    {
      "date": "2026-09-19",
      "orders": 113,
      "revenue": 9492
    },
    {
      "date": "2026-09-20",
      "orders": 121,
      "revenue": 10189
    },
    {
      "date": "2026-09-21",
      "orders": 63,
      "revenue": 5342
    },
    {
      "date": "2026-09-22",
      "orders": 70,
      "revenue": 5955
    },
    {
      "date": "2026-09-23",
      "orders": 77,
      "revenue": 6568
    },
    {
      "date": "2026-09-24",
      "orders": 74,
      "revenue": 6341
    },
    {
      "date": "2026-09-25",
      "orders": 94,
      "revenue": 8046
    },
    {
      "date": "2026-09-26",
      "orders": 118,
      "revenue": 10087
    },
    {
      "date": "2026-09-27",
      "orders": 126,
      "revenue": 10784
    },
    {
      "date": "2026-09-28",
      "orders": 66,
      "revenue": 5544
    },
    {
      "date": "2026-09-29",
      "orders": 72,
      "revenue": 6073
    },
    {
      "date": "2026-09-30",
      "orders": 79,
      "revenue": 6686
    }
  ]
}

Render it through the API

Save the sample data as data.json and send it with the template’s name. TypeScript and Python use the SDKs; every other language posts the same JSON to the REST API with its standard library. Renders with a key that starts with ff_test_ are not metered; on the Free plan they carry a small watermark.

render.shShell
jq '{template: "metrics-card", data: ., output: "png", access: "public"}' data.json \
  | curl https://api-eu.formfeed.dev/v1/renders \
      -H "Authorization: Bearer ff_test_..." \
      -H "Content-Type: application/json" \
      --data-binary @-
render.tsTypeScript
import { Formfeed } from '@formfeed/sdk';
import data from './data.json' with { type: 'json' };

const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });

const render = await formfeed.renders.create({
  template: 'metrics-card',
  data,
  output: 'png',
  access: 'public',
});
console.log(render.status, render.download_url);
render.pyPython
import json
import os

from formfeed import Formfeed

formfeed = Formfeed(api_key=os.environ["FORMFEED_API_KEY"])

with open("data.json", encoding="utf-8") as file:
    data = json.load(file)

render = formfeed.renders.create(
    template="metrics-card",
    data=data,
    output="png",
    access="public",
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'metrics-card',
    'data' => json_decode(file_get_contents('data.json'), true),
    'output' => 'png',
    'access' => 'public',
];

$curl = curl_init('https://api-eu.formfeed.dev/v1/renders');
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('FORMFEED_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($body),
]);
$render = json_decode(curl_exec($curl), true);

echo $render['status'], ' ', $render['download_url'], PHP_EOL;
main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	data, err := os.ReadFile("data.json")
	if err != nil {
		panic(err)
	}
	body, _ := json.Marshal(map[string]any{
		"template": "metrics-card",
		"data":     json.RawMessage(data),
		"output":   "png",
		"access":   "public",
	})

	request, _ := http.NewRequest("POST", "https://api-eu.formfeed.dev/v1/renders", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+os.Getenv("FORMFEED_API_KEY"))
	request.Header.Set("Content-Type", "application/json")

	response, err := http.DefaultClient.Do(request)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()

	var render struct {
		Status      string `json:"status"`
		DownloadURL string `json:"download_url"`
	}
	json.NewDecoder(response.Body).Decode(&render)
	fmt.Println(render.Status, render.DownloadURL)
}
Program.csC#
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var body = new
{
    template = "metrics-card",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
    output = "png",
    access = "public",
};

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FORMFEED_API_KEY"));

var response = await http.PostAsJsonAsync("https://api-eu.formfeed.dev/v1/renders", body);
var render = await response.Content.ReadFromJsonAsync<JsonElement>();

Console.WriteLine($"{render.GetProperty("status")} {render.GetProperty("download_url")}");
Render.javaJava
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;

public class Render {
  public static void main(String[] args) throws Exception {
    String data = Files.readString(Path.of("data.json"));
    String body = """
        {
          "template": "metrics-card",
          "output": "png",
          "access": "public",
          "data": %s
        }
        """.formatted(data);

    HttpRequest request = HttpRequest.newBuilder(URI.create("https://api-eu.formfeed.dev/v1/renders"))
        .header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.body()); // status, download_url, page_count, units …
  }
}
render.rbRuby
require "json"
require "net/http"

body = {
  template: "metrics-card",
  data: JSON.parse(File.read("data.json")),
  output: "png",
  access: "public",
}

response = Net::HTTP.post(
  URI("https://api-eu.formfeed.dev/v1/renders"),
  JSON.generate(body),
  "Authorization" => "Bearer #{ENV.fetch("FORMFEED_API_KEY")}",
  "Content-Type" => "application/json"
)
render = JSON.parse(response.body)

puts render["status"], render["download_url"]

Start rendering today

The free plan includes 100 units a month and free test renders. Change plans when you need more.