← Sorta Modular

Custom URL complications

Put your own data on a Sorta Modular watchface with a tiny JSON response from any URL you control, rendered natively on the watch.

Point any slot at an HTTP endpoint.

The phone fetches it on a schedule you control, validates the response, and pushes the result to the watch.

The watch renders it with the same renderers the built-in complications use.

Refresh is clamped to at most once per 5 minutes so misbehaving script can’t drain the battery.

Sorta Modular face rendering nine custom URL widgets at once

How it works

  1. Serve JSON. Any HTTP endpoint that returns a small JSON document works: a Node/Python/PHP script, a Home Assistant template, or a static file behind any web server.
  2. Point a slot at it. In the watchface settings, set a circle, the center slot, or a bezel edge to Custom URL and paste your URL into its Widget URL field.
  3. The watch renders it. The phone fetches on watchface launch and on settings changes, then re-fetches per the refresh you return. The last good widget is cached.

To quickly get started run the sample service (node server.js) and point a slot at http://<your-computer>:8090/. A minimal endpoint of your own is a few lines:

// hello.js - the smallest possible widget service
require('http').createServer(function (req, res) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    v: 1,
    refresh: 600,
    widget: { type: 'text', label: 'HELLO', value: 'WORLD' }
  }));
}).listen(8090);

The request

The phone appends query parameters to your URL, so one endpoint can serve every slot:

GET <your-url>?v=1&slot=circle&time=1783286288&tzoff=120&units=c
Param Meaning
v Protocol version, currently 1.
slot circle, center or bezel - which geometry is asking. A script serving several placements branches on this; it is never told which circle or edge.
time Current time as epoch seconds.
tzoff The phone’s UTC offset in minutes east (e.g. 120 for UTC+2, -300 for UTC−5).
units c or f - the watchface’s temperature unit setting.
Note: No personal data like steps, heart rate, or location is ever sent out.

The response

Respond with any 2xx status and a JSON body under 4 KB:

{
  "v": 1,
  "refresh": 600,
  "widget": { "type": "...", ... }
}
Field Meaning
v required Must be 1. Anything else is treated as a failed fetch, so old watchface versions degrade gracefully against newer scripts.
refresh optional Seconds until the next fetch. Clamped to [300, 86400] (5 minutes to 24 hours), default 1800.
widget one of the two One of the templates below. Its type must fit the slot geometry that made the request. Shorthand for a one-entry timeline that is active immediately.
timeline one of the two Dated widgets for the hours ahead, flipped locally by the phone - see Timelines. A response carries exactly one of widget or timeline.

Two rules apply everywhere:

Circle · text

The six round slots (top and bottom rows) are 44 px circles. The text template shows a small label on top and a big value - the same renderer the built-in date complication uses.

Field Meaning
value required The big text. Up to 8 characters; 4–5 fit comfortably.
label optional Small caption above the value. Up to 10 characters, but the circle only fits about 5 - keep it short.
color optional Colors the value text.
{
  "v": 1,
  "refresh": 600,
  "widget": {
    "type": "text",
    "label": "BUS",
    "value": "09:12"
  }
}
Circle showing BUS over 09:12
Label + value
{
  "v": 1,
  "refresh": 900,
  "widget": {
    "type": "text",
    "label": "GARAGE",
    "value": "OPEN",
    "color": "#FF5500"
  }
}
Circle showing GARAGE over OPEN in orange
With a color
{
  "v": 1,
  "widget": {
    "type": "text",
    "value": "42"
  }
}
Circle showing just the number 42
The minimum valid payload: refresh and label omitted

Circle · gauge

A horseshoe scale around the circle with a dot at percent, and optional text in the middle - the look of the built-in weather and UV bands.

Field Meaning
percent required Dot position, 0–100.
value optional Center text, up to 8 characters.
label optional Small caption above the value.
color optional Colors the scale (when colors is absent).
colors optional Up to 8 { "to", "color" } segments. Each paints the scale from the previous segment’s end up to its to percent - like the UV band’s risk colors. to values must not decrease.
{
  "v": 1,
  "refresh": 1800,
  "widget": {
    "type": "gauge",
    "label": "TANK",
    "value": "64",
    "percent": 64
  }
}
Gauge circle labelled TANK at 64 percent
Plain gauge
{
  "v": 1,
  "refresh": 900,
  "widget": {
    "type": "gauge",
    "label": "SOLAR",
    "value": "3.1kW",
    "percent": 78,
    "color": "#FFAA00"
  }
}
Orange gauge circle labelled SOLAR at 78 percent
One color for the whole scale
{
  "v": 1,
  "refresh": 600,
  "widget": {
    "type": "gauge",
    "label": "CO2",
    "value": "612",
    "percent": 27,
    "colors": [
      {
        "to": 50,
        "color": "#55FF00"
      },
      {
        "to": 75,
        "color": "#FFAA00"
      },
      {
        "to": 100,
        "color": "#FF0000"
      }
    ]
  }
}
CO2 gauge circle with green, orange and red scale segments
Colored segments, dot at the reading

Center · columns

The center slot is the wide area under the time: an info line on top and a 152×46 px content area. The columns template is the weather-station look - 2 to 5 label/value pairs.

Field Meaning
columns required 2–5 objects of { "label", "value" }. Labels fit 6 characters, values 8.
info optional The info line above, up to 30 characters.
color optional Colors the values.
{
  "v": 1,
  "refresh": 300,
  "widget": {
    "type": "columns",
    "info": "HOME  LIVING ROOM",
    "columns": [
      {
        "label": "CO2",
        "value": "612"
      },
      {
        "label": "TEMP",
        "value": "21.5°"
      },
      {
        "label": "HUM",
        "value": "48%"
      },
      {
        "label": "PWR",
        "value": "340W"
      }
    ]
  }
}
Center slot showing four home sensor columns
Four columns of home sensors

Center · lines

Two free-text lines in the center slot - a big first line and a smaller second one.

Field Meaning
line1 required The big line, up to 14 characters.
line2 optional The small line, up to 18 characters.
info optional The info line above, up to 30 characters.
color optional Colors both lines.
{
  "v": 1,
  "refresh": 300,
  "widget": {
    "type": "lines",
    "info": "LINE 42  TO DOWNTOWN",
    "line1": "4 MIN",
    "line2": "then every 7 min",
    "color": "#00AAFF"
  }
}
Center slot showing a transit countdown in blue
A transit countdown

Center · textarea

The whole center block - info line included - becomes one free text area. Newlines (\n) start new lines, everything else wraps and centers itself. Short text renders in the bold font; when it doesn’t fit, the face drops to the small font automatically. About 79 characters fit.

Field Meaning
text required The text. Newlines are honored; longer text wraps. Truncated at 79 characters. There is no info field - the info line belongs to the text area.
color optional Colors the whole text.
{
  "v": 1,
  "refresh": 900,
  "widget": {
    "type": "textarea",
    "text": "Dentist 17:30\nCall Grandma",
    "color": "#FFD500"
  }
}
Center block showing a two-line reminder in bold yellow text
Two lines - fits the bold font
{
  "v": 1,
  "refresh": 900,
  "widget": {
    "type": "textarea",
    "text": "Gym 18:00\nCall the bank\nWater the plants\nRent due Friday"
  }
}
Center block showing a four-line to-do list in small text
Four lines - drops to the small font automatically

Bezel · gauge

The left and right screen edges are 0–100 rulers that fill from the bottom, each an independent slot. There is exactly one template and no text - the edge has nowhere to put it.

Field Meaning
percent required Fill level, 0–100.
color optional Colors the fill. Omitted, the edge falls back to its Custom Edge Color setting or the accent color.
{
  "v": 1,
  "refresh": 600,
  "widget": {
    "type": "gauge",
    "percent": 62,
    "color": "#55FF00"
  }
}
Left bezel edge filled green to 62 percent
Left edge, 62% in green
{
  "v": 1,
  "refresh": 600,
  "widget": {
    "type": "gauge",
    "percent": 35
  }
}
Right bezel edge filled to 35 percent in the accent color
Right edge, no color - accent fallback

Timelines

A script often knows more than the current value - a transit script knows the whole morning’s departures. Instead of a single widget, return a timeline: dated widgets for the hours ahead. The phone stores the list and flips the active entry at each at boundary. A flip is a message to the watch, not a network fetch - a timeline fetched once on wifi keeps flipping with no connectivity, and minute-accurate changes don’t fight the 5-minute fetch floor: fetch hourly, flip by the minute.

{
  "v": 1,
  "refresh": 3600,
  "timeline": [
    {
      "at": 1783065600,
      "widget": {
        "type": "text",
        "label": "BUS",
        "value": "09:12"
      }
    },
    {
      "at": 1783066320,
      "widget": {
        "type": "text",
        "label": "BUS",
        "value": "09:31"
      }
    },
    {
      "at": 1783067460,
      "widget": {
        "type": "text",
        "label": "BUS",
        "value": "09:54"
      }
    }
  ]
}
Circle showing BUS over 09:54, the timeline's active entry
Captured after the last at passed - each earlier departure showed until its successor went active

The sample service’s /timeline endpoint serves a live version of this: the next six departures in one response, revised hourly.

Rules & gotchas

Full JSON schema

The formal contract, as JSON Schema (2020-12). The branch of widget your response may use is decided by the slot parameter of the request: $defs/circleWidget, $defs/centerWidget or $defs/bezelWidget. A document valid against the right geometry is always accepted; the phone is slightly more lenient than the schema.

custom-url-response.schema.json (standalone file)
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Sorta Modular Custom URL response",
  "type": "object",
  "required": ["v"],
  "oneOf": [
    { "required": ["widget"] },
    { "required": ["timeline"] }
  ],
  "properties": {
    "v": {
      "const": 1,
      "description": "Protocol version."
    },
    "refresh": {
      "type": "number",
      "minimum": 300,
      "maximum": 86400,
      "default": 1800,
      "description": "Seconds until the next fetch."
    },
    "widget": {
      "$ref": "#/$defs/widget",
      "description": "What to show now. Shorthand for a one-entry timeline active immediately."
    },
    "timeline": {
      "type": "array",
      "minItems": 1,
      "maxItems": 24,
      "description": "Dated widgets; 'at' ascending, entries >= 60 s apart. The latest entry with at <= now is shown.",
      "items": {
        "type": "object",
        "required": ["at", "widget"],
        "properties": {
          "at": {
            "type": "number",
            "description": "Epoch seconds this entry becomes active."
          },
          "widget": { "$ref": "#/$defs/widget" }
        }
      }
    }
  },
  "$defs": {
    "widget": {
      "anyOf": [
        { "$ref": "#/$defs/circleWidget" },
        { "$ref": "#/$defs/centerWidget" },
        { "$ref": "#/$defs/bezelWidget" }
      ],
      "description": "Must match the geometry of the requesting slot."
    },
    "color": {
      "type": "string",
      "pattern": "^#[0-9a-fA-F]{6}$",
      "description": "Quantized to the watch's 64-color palette; default white."
    },
    "circleWidget": {
      "oneOf": [
        {
          "type": "object",
          "required": ["type", "value"],
          "properties": {
            "type": { "const": "text" },
            "value": { "type": "string", "maxLength": 8 },
            "label": { "type": "string", "maxLength": 10 },
            "color": { "$ref": "#/$defs/color" }
          }
        },
        {
          "type": "object",
          "required": ["type", "percent"],
          "properties": {
            "type": { "const": "gauge" },
            "percent": { "type": "number", "minimum": 0, "maximum": 100 },
            "value": { "type": "string", "maxLength": 8 },
            "label": { "type": "string", "maxLength": 10 },
            "color": { "$ref": "#/$defs/color" },
            "colors": {
              "type": "array",
              "minItems": 1,
              "maxItems": 8,
              "description": "Scale segments; 'to' values must not decrease.",
              "items": {
                "type": "object",
                "required": ["to", "color"],
                "properties": {
                  "to": { "type": "number", "minimum": 0, "maximum": 100 },
                  "color": { "$ref": "#/$defs/color" }
                }
              }
            }
          }
        }
      ]
    },
    "centerWidget": {
      "oneOf": [
        {
          "type": "object",
          "required": ["type", "line1"],
          "properties": {
            "type": { "const": "lines" },
            "line1": { "type": "string", "maxLength": 14 },
            "line2": { "type": "string", "maxLength": 18 },
            "info": { "type": "string", "maxLength": 30 },
            "color": { "$ref": "#/$defs/color" }
          }
        },
        {
          "type": "object",
          "required": ["type", "text"],
          "properties": {
            "type": { "const": "textarea" },
            "text": {
              "type": "string",
              "maxLength": 79,
              "description": "Fills the whole block; newlines start new lines. No info field."
            },
            "color": { "$ref": "#/$defs/color" }
          }
        },
        {
          "type": "object",
          "required": ["type", "columns"],
          "properties": {
            "type": { "const": "columns" },
            "columns": {
              "type": "array",
              "minItems": 2,
              "maxItems": 5,
              "items": {
                "type": "object",
                "required": ["label", "value"],
                "properties": {
                  "label": { "type": "string", "maxLength": 6 },
                  "value": { "type": "string", "maxLength": 8 }
                }
              }
            },
            "info": { "type": "string", "maxLength": 30 },
            "color": { "$ref": "#/$defs/color" }
          }
        }
      ]
    },
    "bezelWidget": {
      "type": "object",
      "required": ["type", "percent"],
      "properties": {
        "type": { "const": "gauge" },
        "percent": { "type": "number", "minimum": 0, "maximum": 100 },
        "color": { "$ref": "#/$defs/color" }
      }
    }
  }
}

Sample projects