Webhook Widget

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.

Node
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 loseWhy 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

A server-drawn chart in a JSON view
json
{
  "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"
      }
    ]
  }
}
The sparkline is a PNG from the server. Everything else is text, so it still scales, still adapts, and still reads correctly aloud.

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.