JSON APIs
API responses, webhooks and structured data. The rules used in every example below:
- Strings go through
@json(value). It prints a complete JSON string, quotes included, so it is never wrapped in"..."in the template. It escapes",',&,<and>as",',&,<and>, and/as\/- valid JSON that decodes to the original text and is safe inside HTML. - Numbers use
@number(value, 2, ".", "")(the default thousands separator would make1,249.50, which is not a number), integers{value}. - Booleans and
nullcome from conditions:@?{product.qty > 0 "true" : "false"}. Tsuku has notrue/falseliterals; a baretruein a template is a variable name.
Each example has the template, the data, the generated output and the PHP call (how the examples work).
Paginated product list
A list endpoint with a meta block. "next" is a URL on every page but the last, where it is null; the space after "next": sits inside both branches so the line reads the same either way. The tags array is written on one line with the same comma idiom as data.
{
"data": [@for(products as product, index)@if(index > 0),@end
{
"id": {product.id},
"sku": @json(product.sku),
"name": @json(product.name),
"price": {
"amount": @number(product.price, 2, ".", ""),
"currency": @json(currency)
},
"in_stock": @?{product.qty > 0 "true" : "false"},
"tags": [@for(product.tags as tag, t)@if(t > 0), @end@json(tag)@end]
}@end
],
"meta": {
"page": {page.current},
"per_page": {page.size},
"total": {page.total},
"next":@if(page.current < page.last) @json(page.next_url)@else null@end
}
}<?php
return [
'currency' => 'EUR',
'products' => [
['id' => 101, 'sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'price' => 49.00, 'qty' => 12, 'tags' => ['teapot', 'clay']],
['id' => 103, 'sku' => 'TEA-TET-080', 'name' => 'Cast Iron Kettle "Tetsubin" 0.8 l', 'price' => 89.00, 'qty' => 0, 'tags' => []],
],
'page' => [
'current' => 1,
'size' => 2,
'total' => 5,
'last' => 3,
'next_url' => '/api/products?page=2&per_page=2',
],
];{
"data": [
{
"id": 101,
"sku": "TEA-KYU-350",
"name": "Kyusu Teapot 350 ml",
"price": {
"amount": 49.00,
"currency": "EUR"
},
"in_stock": true,
"tags": ["teapot", "clay"]
},
{
"id": 103,
"sku": "TEA-TET-080",
"name": "Cast Iron Kettle \u0022Tetsubin\u0022 0.8 l",
"price": {
"amount": 89.00,
"currency": "EUR"
},
"in_stock": false,
"tags": []
}
],
"meta": {
"page": 1,
"per_page": 2,
"total": 5,
"next": "\/api\/products?page=2\u0026per_page=2"
}
}echo $tsuku->process($template, $data);Commas between array items
JSON allows no comma after the last element, and Tsuku has no loop.last. Put the separator in front of every element except the first instead - the loop index is 0 only for the first one:
"data": [@for(products as product, index)@if(index > 0),@end
{
...
}@end
]Each iteration renders , (from the second element on), a line break and the element; the @end right after the element's } keeps the comma on the same line as the previous }. An empty list renders [ and ] on two lines, which is still valid JSON. The index is the array key, so pass array_values($items) when the keys are not 0, 1, 2, ....
@if(index < length(items) - 1),@end does not work: conditions have no arithmetic, and since 1.4.0 the - is a ParseException.
Order webhook
An order.created event with nested objects and an array of items. Timestamps are strings, so @date("c", ...) is wrapped in @json(). "is_guest" uses not, and "shipment" is an object or null depending on the data.
{
"event": "order.created",
"id": @json(event.id),
"created_at": @json(@date("c", event.created_at)),
"data": {
"number": @json(order.increment_id),
"status": @json(order.status),
"currency": @json(order.currency),
"grand_total": @number(order.grand_total, 2, ".", ""),
"customer": {
"email": @json(order.customer.email),
"name": @json(order.customer.name),
"is_guest": @?{not order.customer.id "true" : "false"}
},
"items": [@for(order.items as item, index)@if(index > 0),@end
{
"sku": @json(item.sku),
"qty": {item.qty},
"row_total": @number(item.row_total, 2, ".", "")
}@end
],
"shipment":@if(order.shipment) {
"carrier": @json(order.shipment.carrier),
"tracking_number": @json(order.shipment.tracking)
}@else null@end
}
}<?php
return [
'event' => ['id' => 'evt_01JB7X2K9Q', 'created_at' => strtotime('2026-10-21 14:05:11')],
'order' => [
'increment_id' => '000002203',
'status' => 'pending',
'currency' => 'EUR',
'grand_total' => 108.70,
'customer' => ['id' => null, 'email' => '[email protected]', 'name' => 'Anna de Vries'],
'items' => [
['sku' => 'TEA-KYU-350', 'qty' => 1, 'row_total' => 49.00],
['sku' => 'TEA-SEN-100', 'qty' => 2, 'row_total' => 29.80], // row totals come from PHP
],
'shipment' => null, // not shipped yet
],
];{
"event": "order.created",
"id": "evt_01JB7X2K9Q",
"created_at": "2026-10-21T14:05:11+00:00",
"data": {
"number": "000002203",
"status": "pending",
"currency": "EUR",
"grand_total": 108.70,
"customer": {
"email": "[email protected]",
"name": "Anna de Vries",
"is_guest": true
},
"items": [
{
"sku": "TEA-KYU-350",
"qty": 1,
"row_total": 49.00
},
{
"sku": "TEA-SEN-100",
"qty": 2,
"row_total": 29.80
}
],
"shipment": null
}
}echo $tsuku->process($template, $data);JSON-LD product data
Structured data for search engines, embedded in the product page. JSON-LD keys start with @, which is written @@ in a template: "@@context" prints "@context". The availability is an inline @if / @elseif / @else block that ends in front of the ,, so it renders on one line, and aggregateRating is only added when the product has reviews. Because @json() escapes < and >, a </script> in a product description cannot close the script tag.
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Product",
"sku": @json(product.sku),
"name": @json(product.name),
"description": @json(product.description),
"image": [@for(product.images as image, index)@if(index > 0), @end@json(image)@end],
"brand": {
"@@type": "Brand",
"name": @json(product.brand)
},
"offers": {
"@@type": "Offer",
"url": @json(product.url),
"price": @number(product.final_price, 2, ".", ""),
"priceCurrency": @json(currency),
"availability": @if(product.qty > 0)
"https://schema.org/InStock"
@elseif(product.backorders)
"https://schema.org/BackOrder"
@else
"https://schema.org/OutOfStock"
@end,
"itemCondition": "https://schema.org/NewCondition"
}@if(product.review_count > 0),
"aggregateRating": {
"@@type": "AggregateRating",
"ratingValue": @number(product.rating, 1, ".", ""),
"reviewCount": {product.review_count}
}@end
}
</script><?php
return [
'currency' => 'EUR',
'product' => [
'sku' => 'TEA-KYU-350',
'name' => 'Kyusu Teapot 350 ml',
'description' => 'Hand-made in Tokoname. Holds 350 ml, "enough for two cups".',
'images' => [
'https://shop.example.com/media/catalog/product/k/y/kyusu-350.jpg',
'https://shop.example.com/media/catalog/product/k/y/kyusu-350-side.jpg',
],
'brand' => 'Gyokko',
'url' => 'https://shop.example.com/kyusu-teapot-350-ml',
'final_price' => 49.00,
'qty' => 12,
'backorders' => false,
'rating' => 4.4,
'review_count' => 38,
],
];<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"sku": "TEA-KYU-350",
"name": "Kyusu Teapot 350 ml",
"description": "Hand-made in Tokoname. Holds 350 ml, \u0022enough for two cups\u0022.",
"image": ["https:\/\/shop.example.com\/media\/catalog\/product\/k\/y\/kyusu-350.jpg", "https:\/\/shop.example.com\/media\/catalog\/product\/k\/y\/kyusu-350-side.jpg"],
"brand": {
"@type": "Brand",
"name": "Gyokko"
},
"offers": {
"@type": "Offer",
"url": "https:\/\/shop.example.com\/kyusu-teapot-350-ml",
"price": 49.00,
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.4,
"reviewCount": 38
}
}
</script>echo $tsuku->process($template, $data);Validation error
An error response in the Problem Details format (RFC 9457, application/problem+json). The apostrophe in the title and the quotes in a reason are escaped by @json().
{
"type": "https://shop.example.com/problems/validation",
"title": @json(problem.title),
"status": {problem.status},
"detail": @json(problem.detail),
"instance": @json(problem.instance),
"invalid_params": [@for(problem.invalid_params as param, index)@if(index > 0),@end
{
"name": @json(param.name),
"reason": @json(param.reason)
}@end
]
}<?php
return [
'problem' => [
'title' => "Your request parameters didn't validate.",
'status' => 422,
'detail' => 'Two fields of the shipping address are invalid.',
'instance' => '/api/carts/8f3a/shipping-address',
'invalid_params' => [
['name' => 'postcode', 'reason' => 'must match "1234 AB" for the Netherlands'],
['name' => 'telephone', 'reason' => 'must not be empty'],
],
],
];{
"type": "https://shop.example.com/problems/validation",
"title": "Your request parameters didn\u0027t validate.",
"status": 422,
"detail": "Two fields of the shipping address are invalid.",
"instance": "\/api\/carts\/8f3a\/shipping-address",
"invalid_params": [
{
"name": "postcode",
"reason": "must match \u00221234 AB\u0022 for the Netherlands"
},
{
"name": "telephone",
"reason": "must not be empty"
}
]
}echo $tsuku->process($template, $data);Category tree
Nested arrays, as a navigation menu for a headless storefront. Each loop has its own index variable (i, j) for its own commas; the children are written one object per line.
{
"categories": [@for(categories as category, i)@if(i > 0),@end
{
"id": {category.id},
"name": @json(category.name),
"url": @json(category.url),
"children": [@for(category.children as child, j)@if(j > 0),@end
{"id": {child.id}, "name": @json(child.name), "product_count": {child.product_count}}@end
]
}@end
]
}<?php
return [
'categories' => [
[
'id' => 3,
'name' => 'Tea',
'url' => '/tea',
'children' => [
['id' => 11, 'name' => 'Green tea', 'product_count' => 24],
['id' => 12, 'name' => 'Matcha', 'product_count' => 9],
],
],
[
'id' => 4,
'name' => 'Teaware',
'url' => '/teaware',
'children' => [
['id' => 21, 'name' => 'Teapots & Kettles', 'product_count' => 17],
],
],
],
];{
"categories": [
{
"id": 3,
"name": "Tea",
"url": "\/tea",
"children": [
{"id": 11, "name": "Green tea", "product_count": 24},
{"id": 12, "name": "Matcha", "product_count": 9}
]
},
{
"id": 4,
"name": "Teaware",
"url": "\/teaware",
"children": [
{"id": 21, "name": "Teapots \u0026 Kettles", "product_count": 17}
]
}
]
}echo $tsuku->process($template, $data);Checking the result
Decode generated JSON in a test, so a missing @json() or comma fails there and not in a client:
$json = $tsuku->process($template, $data);
json_decode($json, flags: JSON_THROW_ON_ERROR);
header('Content-Type: application/json; charset=utf-8');
echo $json;Next Steps
- HTML Generation → - product cards and emails
- Loops → - loop variables and keys
- Security & Escaping → - JSON injection