Guides

Working with data

Fetch over HTTP against a declared host allowlist, poll politely, persist small state, and prefer host events over polling.

What the sandbox means for data

A widget document has no network and no browser storage. fetch, XHR and WebSockets are blocked by the sandbox's content security policy, remote image URLs will not load, and the sandboxed frame has no localStorage, cookies, or IndexedDB. This is not a missing feature, it is the security model: everything a widget does to the outside world goes through the SDK, where the manifest's grants apply.

So for data there are exactly two doors:

  • dd.http.fetch for the network, against hosts declared in the manifest.
  • dd.storage for persistent state, a small per-instance key-value store.

Fetching over HTTP

Declare the exact hostnames the widget talks to, and nothing else works:

"permissions": {
  "http": { "hosts": ["api.open-meteo.com", "geocoding-api.open-meteo.com"] }
}

Users see this list word for word at install time ("Talks to the internet, only to: ..."), which is why exact hosts beat wildcards: a widget that names two API hosts is easy to trust. Then:

const res = await dd.http.fetch({
  url: "https://api.open-meteo.com/v1/forecast",
  query: { latitude: "44.43", longitude: "26.10", current: "temperature_2m" },
});
if (res.status === 200) render(res.json);

JSON responses arrive pre-parsed on res.json; text rides res.bodyText; images can be fetched with as: "dataUrl" and fed straight to an img or dd-icon (the sandbox allows data: images). Redirects, methods, header rules, size caps and pacing are all specified in the http reference.

Polling with dd.poll

Any fetch-on-a-timer goes through dd.poll. Never hand-roll the loop: the helper owns the interval, exponential backoff on errors, and staleness, and it keeps polling paced even when your render throws. The Weather widget is the complete pattern in one place, settings included:

let settings = {};
let poller = null;

await dd.settings.bind((next) => {
  settings = next;
  poller?.refresh();          // settings changed: re-poll right now
});

poller = dd.poll({
  // read each cycle, so the interval follows the live setting
  every: () => Math.min(180, Math.max(10, Number(settings.refreshMin) || 30)) * 60_000,
  fetch: async () => {
    const query = (settings.city || "").trim();
    if (!query) throw new Error("Set a city in settings");
    const geo = await dd.weather.geocode(query);
    const current = await dd.weather.current({
      lat: geo.lat, lon: geo.lon,
      units: settings.units || "celsius",
    });
    return { current, place: geo.label };
  },
  onData: ({ current, place }) => {
    const d = dd.weather.describe(current.code, current.isDay);
    temp.value = `${Math.round(current.temperature)}°`;
    condition.value = d.label;
  },
  onError: (err) => {
    temp.value = "--°";
    condition.value = `${err?.message || err} · retrying`;
  },
});

The pieces worth copying even when your data source differs: every as a getter so the interval follows a live setting, refresh() from the settings callback, a fetch that throws to signal failure, and an onError that degrades the UI honestly instead of freezing the last good value. Backoff after errors is automatic (from 1 second up to your interval).

Pick intervals that match how fast the data actually changes. Weather defaults to 30 minutes with a floor of 10; a stock ticker might justify a minute. Every instance of your widget polls independently, and rate limits apply per instance.

Secrets in requests

APIs that need a key are the reason the secretRef setting type exists. Declare one, and the user picks a stored credential in the settings dialog; your code then references it by setting key, and the host injects the real value after validating the request:

"settings": [
  { "key": "apiKey", "type": "secretRef", "label": "API key" }
]
const res = await dd.http.fetch({
  url: "https://api.example.com/v1/me",
  headers: { authorization: { $secret: "apiKey" } },
});

The widget never sees the secret's value: not in JS, not in logs, not in error messages. Never ask users to paste keys into a plain text setting; that stores the key as an ordinary readable setting and defeats the whole mechanism.

The built-in weather stack

Weather is common enough that the SDK ships it: dd.weather.geocode, current, describe, and icon wrap Open-Meteo with no API key needed. It is convenience, not privilege: the calls route through your own grants, so the manifest still declares the two Open-Meteo hosts (and storage if you want the geocode cache). Shapes in the reference.

Small persistent state

dd.storage is a per-instance key-value store for JSON-serializable values: caches, counters, the state a widget should wake up with. The Pomodoro widget persists its timer and, crucially, validates on restore:

const stored = await dd.storage.get("state");
if (stored && typeof stored === "object" && typeof stored.phase === "string") {
  state = {
    phase: ["work", "short", "long"].includes(stored.phase) ? stored.phase : "work",
    running: stored.running === true,
    endsAt: Number(stored.endsAt) || 0,
  };
}

Stored shapes outlive widget versions: what you wrote in 0.1 will be read by 0.3, so never trust a stored value's shape blindly. Field-by-field validation with defaults, as above, makes updates painless. Use dd.storage.delete(key) to evict stale cache entries. The store is small by design (caps in the reference); it is for state, not datasets.

Prefer events over polling

Before you poll, check whether the host already pushes what you need. System vitals, now-playing metadata, audio spectrum frames, time ticks, desktop and folder changes, and widget-state changes all arrive as events: fresher than any polite polling interval and cheaper for everyone. The change events that carry no payload (desktop.changed, folders.changed, widgets.changed) follow one pattern: the ping tells you your listing is stale, the matching list call is the read path.

Polling is for data the host does not have, which in practice means external APIs.

When the network is down

Widgets live on the desktop through sleep, wifi changes, and offline stretches, so failure is a normal state, not an exception:

  • Let dd.poll's backoff do the retrying; do not add your own retry loop on top.
  • Degrade visibly but calmly: keep the layout, blank the values, note the retry (the Weather widget shows the error and "retrying" in its condition line).
  • Show staleness honestly. A .dd-status line with "updated 3h ago" beats silently displaying old numbers as current.
  • Cache the last good result in dd.storage if the widget is useful with slightly old data; validate it on restore like any stored shape.