Protocol
Node reference
The protocol has seven node types, shared modifiers, and a few fixed lists of values. Each example below is rendered with the same engine used by the widget.
Modifiers
Every node accepts these, decoded from the same object as the node's own fields. They are how a node gets a background, a size or an inset without a wrapper.
| Field | Type | Description |
|---|---|---|
padding | number | edges | A number insets all four sides, in points. An object insets only the edges it names — top, bottom, leading, trailing — and leaves the rest at 0. Leading and trailing swap sides on a right-to-left device, matching the rest of the layout. |
bg | colour | Fill drawn behind the node, inside its corner radius. |
corner | number | Corner radius of bg, in points. Drawn as a continuous curve, matching iOS. |
opacity | number | 0 to 1, applied to the node and everything inside it. |
w | number | Fixed width in points. Without it the node sizes to its content. |
h | number | Fixed height in points. |
accentable | boolean | Keeps the node legible when the Home Screen is in a tinted mode. |
vstack · hstack · zstack
Lays its children out in a column, a row, or stacked on top of one another. Every layout is made of these.
| Field | Type | Default | Description |
|---|---|---|---|
children | node[] | [] | Nodes to lay out, in order. |
spacing | number | 8 | Gap between children, in points. The default is fixed by the protocol, not inherited from SwiftUI. |
align | align | center | Cross-axis alignment. A zstack uses it to position children over one another. |
The root node always fills the widget, so a bg on it covers the whole face. Every other node sizes to its content unless you give it w or h.
A stack with no children is how you draw a solid shape: give it w, h, corner and bg.
{
"v": 1,
"view": {
"type": "hstack",
"spacing": 10,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "vstack",
"spacing": 4,
"align": "leading",
"padding": 10,
"bg": "#232B36",
"corner": 10,
"children": [
{
"type": "text",
"text": "vstack",
"size": 13,
"weight": "semibold",
"color": "white"
},
{"type": "text", "text": "column", "size": 11, "color": "#9AA4B2"},
{
"type": "text",
"text": "align: leading",
"size": 11,
"color": "#9AA4B2"
}
]
},
{
"type": "zstack",
"align": "center",
"padding": 10,
"bg": "#232B36",
"corner": 10,
"children": [
{
"type": "vstack",
"children": [],
"w": 54,
"h": 54,
"corner": 27,
"bg": "indigo"
},
{
"type": "text",
"text": "zstack",
"size": 12,
"weight": "semibold",
"color": "white"
}
]
},
{"type": "spacer"},
{
"type": "vstack",
"spacing": 6,
"align": "trailing",
"children": [
{
"type": "text",
"text": "spacer pushes",
"size": 11,
"color": "#9AA4B2"
},
{
"type": "text",
"text": "this to the edge",
"size": 11,
"color": "#9AA4B2"
}
]
}
]
}
} text
A run of text. The only node that carries content, and the one you will use most.
| Field | Type | Default | Description |
|---|---|---|---|
text | string | required | The string to draw. |
style | font style | body | Semantic text style. Scales with Dynamic Type. |
size | number | — | Fixed point size. Overrides style and opts out of Dynamic Type. |
weight | font weight | from style | Stroke weight. |
design | font design | default | Typeface variant: default, rounded, serif or monospaced. |
color | colour | primary | Text colour. |
lines | number | — | Maximum lines. Text past the cap shrinks — to 60% at most — and only then truncates with an ellipsis. Unset means no cap. |
align | align | leading | Alignment of wrapped lines within the text block. |
monoDigits | boolean | false | Tabular figures: every digit takes the same width, in the same typeface. Timers always have this. |
shrink | boolean | true | Whether text that does not fit may shrink — to 60% at most — before truncating. false truncates at full size instead. |
Prefer style over size. A semantic style scales when the reader has larger text turned on; a fixed size does not, and small fixed sizes are the most common accessibility problem in a widget.
Set lines on anything you did not write yourself. Text you do not control is text that will eventually be too long.
Set monoDigits on columns of times or counts. It lines the digits up without changing the typeface — design: "monospaced" is for when you want everything to look like code, not for aligning numbers.
{
"v": 1,
"view": {
"type": "vstack",
"align": "leading",
"spacing": 3,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "text",
"text": "largeTitle bold",
"style": "largeTitle",
"weight": "bold",
"color": "white"
},
{"type": "text", "text": "headline", "style": "headline", "color": "white"},
{
"type": "text",
"text": "body rounded",
"style": "body",
"design": "rounded",
"color": "#9AA4B2"
},
{
"type": "text",
"text": "monospaced 0.84",
"style": "footnote",
"design": "monospaced",
"color": "mint"
},
{
"type": "text",
"text": "A caption long enough to wrap, capped at two lines so it truncates rather than pushing the layout off the widget.",
"style": "caption",
"color": "#6D7786",
"lines": 2
}
]
}
} timer
A date that keeps counting on the device. The one node that stays correct without spending a refresh.
| Field | Type | Default | Description |
|---|---|---|---|
until | ISO-8601 | required | The date to count toward. Write from instead when it is in the past; they are the same field. |
style | timer style | timer | timer, relative, offset, date or time. |
size | number | — | Fixed point size. |
weight | font weight | — | Stroke weight. |
color | colour | primary | Text colour. |
The device updates a timer between refreshes, so a countdown does not need a short refresh. Use it and spend your refresh budget on data that actually changed.
A timer's width is measured when the widget is drawn and holds until the next refresh, while the wording carries on. Counting down is safe — the words only get shorter. Counting up outgrows the width it was given: 30 secs becomes 2 min, 28 secs, which draws over whatever sits to its right, or off the edge of the widget. Put a timer counting up at the end of its row, or on a line of its own.
The default timer style is the exception. It takes the full width it is offered, so it never overruns — and pushes anything beside it to the far edge.
{
"v": 1,
"view": {
"type": "vstack",
"align": "leading",
"spacing": 7,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "text",
"text": "timer",
"size": 10,
"design": "monospaced",
"color": "#6D7786"
},
{
"type": "timer",
"until": "2035-01-01T00:00:00Z",
"size": 20,
"weight": "semibold",
"color": "white"
},
{
"type": "text",
"text": "relative",
"size": 10,
"design": "monospaced",
"color": "#6D7786"
},
{
"type": "timer",
"until": "2035-01-01T00:00:00Z",
"style": "relative",
"size": 14,
"color": "mint"
},
{
"type": "text",
"text": "date",
"size": 10,
"design": "monospaced",
"color": "#6D7786"
},
{
"type": "timer",
"until": "2035-01-01T00:00:00Z",
"style": "date",
"size": 14,
"color": "#9AA4B2"
}
]
}
} image
A bitmap fetched over HTTPS. Use it for what nodes cannot express — a chart, a map, a logo.
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | Absolute https URL. |
fit | fit | fill | fit | fit keeps the whole image visible; fill covers the box and crops. |
tint | colour | — | Draws the image as a template in this colour, using its alpha only. |
Give the node a w and h. Otherwise the layout depends on the pixel size of whatever your server happened to return.
tint is how a server-drawn glyph survives tinted and Lock Screen rendering. An untinted photograph will not.
Images are capped at 2 MB and 2000 px on the longest side, and are cached on device between refreshes.
{
"v": 1,
"view": {
"type": "vstack",
"align": "center",
"spacing": 8,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "text",
"text": "Last 24 hours",
"style": "caption2",
"color": "#6D7786"
},
{
"type": "image",
"url": "https://webhookwidget.com/img/sparkline.png",
"fit": "fill",
"h": 44,
"tint": "cyan"
},
{
"type": "text",
"text": "tint: cyan",
"size": 10,
"design": "monospaced",
"color": "#6D7786"
}
]
}
} gauge · progress
A proportion, drawn as a bar or a ring.
| Field | Type | Default | Description |
|---|---|---|---|
value | number | required | Current value, between min and max. |
min | number | 0 | Lower bound. |
max | number | 1 | Upper bound. Must be greater than min. |
label | string | — | Text drawn inside a ring, or above a bar. |
style | bar | ring | bar | Which form to draw. |
tint | colour | accent | Colour of the filled portion. The track is drawn from the same colour at low opacity. |
min and max mean you can pass real units — 24 out of 35 days, 412 of 500 MB — instead of converting to a fraction yourself.
A ring sizes to the space it is given. Set w and h when you want several rings to match.
{
"v": 1,
"view": {
"type": "vstack",
"spacing": 12,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "gauge",
"value": 412,
"min": 0,
"max": 500,
"label": "412 of 500 MB",
"tint": "blue"
},
{
"type": "hstack",
"spacing": 16,
"align": "center",
"children": [
{
"type": "gauge",
"value": 0.25,
"style": "ring",
"label": "25%",
"tint": "mint",
"w": 52,
"h": 52
},
{
"type": "gauge",
"value": 0.6,
"style": "ring",
"label": "60%",
"tint": "yellow",
"w": 52,
"h": 52
},
{
"type": "gauge",
"value": 0.9,
"style": "ring",
"label": "90%",
"tint": "red",
"w": 52,
"h": 52
}
]
}
]
}
} spacer
Takes up whatever room is left. How a layout gets pushed apart rather than measured.
| Field | Type | Default | Description |
|---|---|---|---|
minLength | number | 0 | Smallest gap it will collapse to when the stack runs out of room. |
Two spacers in one stack split the slack between them, which centres whatever sits in the middle.
A spacer is flexible, so w and h do not pin it. When you want a fixed gap, use minLength inside a stack that has no slack, or set spacing on the stack.
Content is measured first. A spacer never squeezes what it separates: text between spacers keeps its full width as long as the stack has room for it.
{
"v": 1,
"view": {
"type": "vstack",
"spacing": 10,
"padding": 14,
"bg": "#151A21",
"children": [
{
"type": "hstack",
"children": [
{"type": "text", "text": "left", "size": 13, "color": "white"},
{"type": "spacer"},
{"type": "text", "text": "right", "size": 13, "color": "white"}
]
},
{"type": "divider"},
{
"type": "hstack",
"children": [
{"type": "spacer"},
{
"type": "text",
"text": "two spacers centre this",
"size": 13,
"color": "mint"
},
{"type": "spacer"}
]
},
{"type": "spacer"},
{
"type": "text",
"text": "and a spacer above pins this to the bottom",
"size": 11,
"color": "#6D7786"
}
]
}
} divider
A hairline rule across the stack. Takes no fields.
No fields of its own.
Drawn at one device pixel, so it stays a hairline at every scale rather than thickening on a 3× screen.
{
"v": 1,
"view": {
"type": "vstack",
"align": "leading",
"spacing": 8,
"padding": 14,
"bg": "#151A21",
"children": [
{"type": "text", "text": "Today", "style": "headline", "color": "white"},
{"type": "divider"},
{
"type": "text",
"text": "12 commits",
"style": "footnote",
"color": "#9AA4B2"
},
{"type": "divider"},
{
"type": "text",
"text": "3 reviews",
"style": "footnote",
"color": "#9AA4B2"
}
]
}
} Colors
Anywhere a colour is accepted you may write a semantic name or a hex value. Names are strongly preferred: they adapt to light and dark automatically and they survive the Home Screen's tinted rendering, which hex values do not.
"color": "primary" // adapts to light and dark
"color": "accent" // the reader's accent colour
"color": "#3DDC84" // six-digit hex
"color": "#3DDC84CC" // eight-digit, with alpha
"color": "#F0A" // three-digit shorthand
"color": "#F0A8" // four-digit, with alpha Semantic names, all case-insensitive:
primary, secondary and tertiary are the text colours
for a given appearance; accent follows the reader's own accent colour. The
rest are the system palette, which is tuned per appearance rather than being one fixed RGB
value.
An opaque hex background pins the appearance inside it
A bg written as a hex value does more than fill a rectangle. It states exactly
what the content on top of it has to be legible against, so the renderer measures its
brightness and draws everything inside that node in the matching appearance. Put
"bg": "#101014" on your root and primary is white inside it, on a
light Home Screen and a dark one alike.
This is why most of the demos on this site offer no Light and Dark control. A payload that pins its own background has already answered the question, and the switch would do nothing — so it is not shown. Each stage is checked against the renderer during the build, and the control appears only where the two appearances genuinely differ.
The alternative is to branch on the scheme request
parameter and send different colours. It works, but read the caveat on that page first:
iOS renders one timeline into both appearances, so a response fetched in light mode can be
shown in dark mode without your server being asked again.
Allowed values
Fields that take one of a fixed set of names. An unrecognised name is a decode error, not a silent fallback — see limits and errors.
Font styles
Scale with Dynamic Type. Setting size instead opts out of that.
Font weights
Overrides the weight a style implies — headline is semibold on its own.
Font designs
rounded is SF Pro Rounded, which reads well for large numbers.
Alignment
One vocabulary for every axis; each stack applies the part that makes sense for it.
Timer styles
timer, relative and offset count on the device between refreshes. date and time draw the date itself and do not move.
Gauge styles
Content fit
Dates
Every date in the protocol — until, from, and an entry's
at — is ISO 8601. Both 2026-08-11T12:00:00Z and the
fractional-second form are accepted, with or without a numeric UTC offset.