Automate on clear nights

Our clear-sky feed answers one question for one site: does tonight have a clear, dark window that passes your rule, and when does it start? It applies the Observing Planner widget's cloud and dark rule with your shortest window and Moon rule on top, and the clear-sky alerts send the same answer. Here it is in Home Assistant, on an iPhone and in Zapier, with the alerts' signed push in Zapier and IFTTT.

The feed

One GET, no key, open CORS. The feed is new, and its fields can still change. The clear-sky alerts page builds the address for your site, or write it from the table below:

curl "https://www.cyclecalcs.com/api/observing-windows?lat=39.74&lon=-104.99&tz=America/Denver&place=Backyard"

The answer, trimmed to what a device usually reads:

{
  "summary": "Clear and moonless from 6:24 pm to 5:04 am (10h 40m).",
  "next_text": "Sat 6:24 pm to 5:04 am (10h 40m)",
  "go_tonight": true,
  "next": {
    "night_of": "2026-11-07",
    "start": "2026-11-07T18:24:00.000-07:00",
    "end": "2026-11-08T05:04:00.000-07:00",
    "minutes": 640,
    "moonless": true,
    "sky": "clear",
    "in_progress": false,
    "starts_in_minutes": 120
  },
  "source": {
    "attribution": "Clouds: MET Norway Locationforecast 2.0 (CC BY 4.0), adapted by CycleCalcs. Dark and the Moon: CycleCalcs."
  }
}

A window is astronomical dark (the Sun 18° down) with every hour of it under your cloud limit and fog under 25%.

ParameterWhat it sets
lat, lonThe site in decimal degrees, at most 4 decimals. Rounded to 2 (about 1 km), the precision of the forecast.
tzRequired. The site's IANA time zone, such as America/Denver. "Tonight" and every time are the site's.
placeOptional. A label for the site, up to 60 characters.
cloudmaxThe cloud limit: every hour of a window is under this much cloud. 10 to 50 percent, default 30.
minThe shortest window worth it: 30 to 360 minutes, default 60.
moondown (the default) counts moonless dark only; any lets the Moon be up.
formatjson (the default) or ics, a calendar with one event per window and a reminder alarm minutes ahead (0 to 720, default 60; 0 for none).

Each answer is cached for five minutes (one minute when there is no forecast), and polling more often repeats it; every 15 minutes is plenty. Past 30 uncached requests a minute from one address, the feed answers HTTP 429 with Retry-After: 60. Clouds come from MET Norway's Locationforecast under CC BY 4.0; keep the source.attribution line wherever you show the answer.

Home Assistant

REST and template sensors in YAML

Nothing to install. Paste this into configuration.yaml, put your own feed address in resource, and restart:

rest:
  - resource: "https://www.cyclecalcs.com/api/observing-windows?lat=39.74&lon=-104.99&tz=America/Denver&place=Backyard"
    scan_interval: 900
    binary_sensor:
      - name: "Clear tonight"
        unique_id: cyclecalcs_clear_tonight
        value_template: "{{ value_json.go_tonight }}"
    sensor:
      - name: "Clear sky plan"
        unique_id: cyclecalcs_clear_sky_plan
        value_template: "{{ value_json.summary }}"
        json_attributes:
          - next
          - computed_at
          - source
      - name: "Next clear window"
        unique_id: cyclecalcs_next_clear_window
        value_template: "{{ value_json.next_text or 'none in the forecast' }}"

template:
  - sensor:
      - name: "Clear window start"
        unique_id: cyclecalcs_clear_window_start
        device_class: timestamp
        state: "{{ (state_attr('sensor.clear_sky_plan', 'next') or {}).get('start') }}"
      - name: "Clear window end"
        unique_id: cyclecalcs_clear_window_end
        device_class: timestamp
        state: "{{ (state_attr('sensor.clear_sky_plan', 'next') or {}).get('end') }}"
      - name: "Clear window starts in"
        unique_id: cyclecalcs_clear_window_starts_in
        unit_of_measurement: "min"
        state: >-
          {%- set n = state_attr('sensor.clear_sky_plan', 'next') -%}
          {%- if n is mapping and now() < as_datetime(n.end) -%}
          {{ [0, ((as_datetime(n.start) - now()).total_seconds() / 60) | round(0) | int] | max }}
          {%- else -%}
          {{ none }}
          {%- endif -%}

The REST sensors ask the feed every 15 minutes, and the plan sensor keeps the credit in its source attribute. The template sensors read the window it returned, and "starts in" recounts every minute on Home Assistant's clock. Then tell yourself when tonight turns clear:

- alias: "Tell me when tonight is clear"
  triggers:
    - trigger: state
      entity_id: binary_sensor.clear_tonight
      to: "on"
  actions:
    - action: notify.notify
      data:
        title: "Clear tonight"
        message: "{{ states('sensor.clear_sky_plan') }}"

Apple Shortcuts

A shortcut that checks your site every afternoon and tells you about the night. Build it once in the Shortcuts app on an iPhone, iPad or Mac:

  1. In My Shortcuts, tap + and name the shortcut "Clear tonight".
  2. Add Get Contents of URL and paste your feed address.
  3. Add Get Dictionary Value and set it to get the value for go_tonight in Contents of URL.
  4. Add If, with Dictionary Value as its input, the condition is, and the value true.
  5. Inside If, add Get Dictionary Value for summary in Contents of URL, then Show Notification with that Dictionary Value as the text and "Clear tonight" as the title.
  6. Under Otherwise, add Get Dictionary Value for next_text in Contents of URL, then an If on that Dictionary Value with the condition has any value, and inside it Show Notification with "Not tonight. Next: " followed by the Dictionary Value. Leave Otherwise empty to hear only about clear nights.
  7. Run it once with the play button to check it.
  8. In Automation, tap +, choose Time of Day, pick 4:00 PM and Daily, choose Run Immediately, and have it run the "Clear tonight" shortcut.

Siri runs it too: ask "Clear tonight?"

The signed push

Clear-sky alerts can push to an address of your own. On the alerts page, paste any public https address that is not ntfy, Discord or Slack under "Phone, Discord or Slack", and it receives JSON:

Each push carries a CycleCalcs-Signature header, t=<unix seconds>,v1=<hex>. The hex is the HMAC-SHA256 of the timestamp, a period and the body exactly as received, keyed with the signing key as text (the key looks like hex; use it as it is). Refuse a push whose timestamp is more than five minutes from your clock. In Python:

import hashlib
import hmac
import time


def verify(body: bytes, header: str, signing_key: str, now: float | None = None) -> bool:
    """True when a CycleCalcs push is genuine and under five minutes old.

    body is the raw request body, before any JSON parsing.
    """
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not (t.isascii() and t.isdigit()) or abs((now or time.time()) - int(t)) > 300:
        return False
    expected = hmac.new(signing_key.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return v1.isascii() and hmac.compare_digest(expected, v1)

Zapier

Webhooks by Zapier is a premium app, so both Zaps need a paid Zapier plan.

Every afternoon, from the feed

  1. Trigger: Schedule by Zapier, Every Day, at 4 pm.
  2. Action: Webhooks by Zapier, GET, with your feed address as the URL. Zapier lists the answer's fields, such as Go Tonight, Summary and Next Start.
  3. Filter by Zapier: only continue if Go Tonight (Boolean) is true.
  4. Your action: an email, a Slack message or a text, with Summary as the message.

The moment an alert fires, signed

This Zap takes the signed push.

  1. Trigger: Webhooks by Zapier, Catch Raw Hook. It keeps the headers and the exact body, which the signature needs. Copy its address.
  2. On the alerts page, paste that address and send the test push.
  3. Test the trigger in Zapier. The confirmation arrives: copy signing_key from its raw body, then open confirm_url and press the button.
  4. Add Code by Zapier, Run JavaScript. Under Input Data, map body to the Raw Body, signature to the CycleCalcs-Signature header, and key to the signing key you copied. Paste this code:
const crypto = require('crypto');
const parts = Object.fromEntries(String(inputData.signature || '').split(',').map(p => p.split('=')));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
const expected = Buffer.from(crypto.createHmac('sha256', String(inputData.key || ''))
  .update(parts.t + '.' + inputData.body).digest('hex'));
const given = Buffer.from(String(parts.v1 || ''));
const valid = fresh && given.length === expected.length
  && crypto.timingSafeEqual(given, expected);
const push = valid ? JSON.parse(inputData.body) : {};
output = {
  valid,
  type: push.type || '',
  summary: push.summary || '',
  start: (push.window && push.window.start) || '',
};
  1. Filter by Zapier: only continue if Valid is true and Type exactly matches observing_window.
  2. Your action, with Summary as the message.

IFTTT

IFTTT has no trigger that reads a JSON feed on a schedule, so its recipe takes the signed push. Webhooks and filter code are paid IFTTT features.

  1. Create an applet. If This: Webhooks, Receive a web request with a JSON payload, with an event name such as clear_tonight.
  2. Then That: Notifications, Send a rich notification from the IFTTT app.
  3. Add this filter code:
const push = JSON.parse(MakerWebhooks.jsonEvent.JsonPayload);
if (push.type === 'verify') {
  IfNotifications.sendRichNotification.setTitle('Confirm clear-sky alerts');
  IfNotifications.sendRichNotification.setMessage('Open this and press the button to start alerts.');
  IfNotifications.sendRichNotification.setLinkUrl(push.confirm_url);
} else if (push.type === 'observing_window' || push.type === 'week_summary') {
  IfNotifications.sendRichNotification.setTitle(push.title);
  IfNotifications.sendRichNotification.setMessage(push.summary);
  IfNotifications.sendRichNotification.setLinkUrl(push.links.plan);
} else {
  IfNotifications.sendRichNotification.skip('Not a clear-sky alert');
}
  1. Your push address is https://maker.ifttt.com/trigger/clear_tonight/json/with/key/ followed by your Webhooks key (from the Webhooks service's Documentation page). The /json/ part matters: without it the payload arrives empty.
  2. Paste it on the alerts page and send the test push. The confirmation arrives as a notification; open it and press the button.

IFTTT does not pass request headers to filter code, so it cannot check the signature. Your Webhooks key is what keeps the address private. When the check matters, use Zapier or your own server.

Stuck, or found an error in this guide? Contact us at info@cyclecalcs.com.

More build guides