Skip to content

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 make 1,249.50, which is not a number), integers {value}.
  • Booleans and null come from conditions: @?{product.qty > 0 "true" : "false"}. Tsuku has no true/false literals; a bare true in 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.

tsuku-json
{
  "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
<?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',
    ],
];
json
{
  "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"
  }
}
php
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:

tsuku-json
"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.

tsuku-json
{
  "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
<?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
    ],
];
json
{
  "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
  }
}
php
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.

tsuku-html
<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
<?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,
    ],
];
html
<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>
php
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().

tsuku-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
<?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'],
        ],
    ],
];
json
{
  "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"
    }
  ]
}
php
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.

tsuku-json
{
  "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
<?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],
            ],
        ],
    ],
];
json
{
  "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}
      ]
    }
  ]
}
php
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:

php
$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 ​

Released under the MIT License.