Webhook Widget

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.

vstack — live
json
{
  "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.

text — live
json
{
  "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.

timer — live
json
{
  "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.

image — live
json
{
  "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.

gauge — live
json
{
  "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.

spacer — live
json
{
  "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.

divider — live
json
{
  "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.

Accepted forms
"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:

primarysecondarytertiaryaccentredorangeyellowgreenminttealcyanblueindigopurplepinkbrowngraywhiteblackclear

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.

The system palette, both appearances
Switch to light. Nothing in this payload names a hex value, so every swatch and every label changes on its own.

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

largeTitletitletitle2title3headlinesubheadlinebodycalloutfootnotecaptioncaption2

Scale with Dynamic Type. Setting size instead opts out of that.

Font weights

ultraLightthinlightregularmediumsemiboldboldheavyblack

Overrides the weight a style implies — headline is semibold on its own.

Font designs

defaultroundedserifmonospaced

rounded is SF Pro Rounded, which reads well for large numbers.

Alignment

leadingcentertrailingtopbottom

One vocabulary for every axis; each stack applies the part that makes sense for it.

Timer styles

timerrelativeoffsetdatetime

timer, relative and offset count on the device between refreshes. date and time draw the date itself and do not move.

Gauge styles

barring

Content fit

fitfill

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.