Protocol
Bitmap mode
Return an image instead of JSON and it will fill the widget. This is useful for content that is already an image, but JSON views are usually more flexible.
app.get("/widget.png", async (request, response) => {
// Render at the exact pixel size of the slot, or it will be resampled.
const width = Number(request.query.w) * Number(request.query.scale);
const height = Number(request.query.h) * Number(request.query.scale);
const png = await renderChart({ width, height, dark: request.query.scheme === "dark" });
response
.type("image/png")
// The only way a bitmap can ask for a refresh interval.
.set("Cache-Control", "max-age=1800")
.send(png);
});
Use w, h and scale from the request to render at
exactly the right pixel size. A bitmap at the wrong size is resampled, and resampled text
on a Retina screen looks precisely as bad as it sounds.
Trade-offs
A picture cannot tick, and it cannot resize itself: live timer nodes and
Dynamic Type are gone, which is in the nature of the thing rather than a limitation of the
protocol. The costs worth reading about are the three that surprise people, none of which
can be worked around on the server.
| You lose | Why it cannot be recovered |
|---|---|
| Light and dark |
iOS renders one timeline into both appearances without re-fetching, and
the scheme parameter is only a hint. A
bitmap fetched in light mode will be shown in dark mode. Tinted rendering
degrades for the same reason: the JSON path gives iOS semantic nodes to recolour,
this one gives it pixels.
|
| Everything in the envelope | accessibility, url and entries are fields on
the JSON response, and a bitmap response has no JSON. So: a generic VoiceOver
label, no tap target, and no
timeline — one picture per fetch.
|
| The refresh field | Cache-Control: max-age is the only interval you can ask for. Without
it the widget falls back to the default of 15 minutes.
|
Good uses for bitmap mode
- A map tile, a photograph, or a camera snapshot — content that is inherently an image.
- A dense visualisation where every pixel is data and there is no text to speak of.
- A rendering pipeline you already own and trust, where re-expressing the output as nodes would be a rewrite rather than a mapping.
When only part of the widget is an image
{
"v": 1,
"refresh": 1800,
"accessibility": "Revenue today 8,240 pounds, up 12 percent on yesterday",
"url": "https://dashboard.example.com/revenue",
"view": {
"type": "vstack",
"align": "leading",
"spacing": 0,
"padding": 14,
"bg": "#12141C",
"children": [
{
"type": "text",
"text": "REVENUE TODAY",
"style": "caption2",
"weight": "bold",
"color": "#6E7BA8"
},
{
"type": "hstack",
"spacing": 8,
"align": "bottom",
"children": [
{
"type": "text",
"text": "£8,240",
"style": "largeTitle",
"weight": "bold",
"design": "rounded",
"color": "white"
},
{
"type": "text",
"text": "+12%",
"style": "subheadline",
"weight": "semibold",
"color": "mint"
}
]
},
{"type": "spacer"},
{
"type": "image",
"url": "https://webhookwidget.com/img/sparkline.png",
"fit": "fill",
"h": 46,
"tint": "mint"
}
]
}
} Limits
2 MB per image and 2000 px on the longest side, the same ceilings an image node
obeys. Both are checked before decoding, so an oversized file is rejected without being
parsed.