Webhooks
Webhooks let Telescope notify your application when a new forecast version is published. You choose the products and forecast horizon; Telescope sends an HTTPS POST to your endpoint. Your application can then fetch the entire horizon with one API request.
Use webhooks to refresh a forecast cache, start a calculation or update your own dashboard without repeatedly checking for changes.
Set up your first webhook
- Prepare a public HTTPS endpoint on port 443 that accepts JSON POST requests. It needs a valid TLS certificate and must respond directly; redirects and private network addresses are not supported.
- Open Webhooks in your dashboard, then select Create webhook.
- Enter a recognizable name, your endpoint URL, the products you need and a forecast horizon.
- Open the saved webhook and copy its signing secret into your server's secret store. Configure your receiver to verify incoming requests as described below.
- Select Send test. Check that the test appears as Delivered, then confirm your application actually accepted it.
A test has type webhook.test and data.message. It has no forecast updates or dataUrl, and does not change forecast data. It uses the same endpoint and signing secret as real events.
forecasts:read, authorizes data requests to Telescope. These are separate credentials. Do not put either one in the endpoint URL or client-side code.Webhooks belong to the account that creates them. You can configure up to 10 endpoints, with up to 20 products each. Separate production and staging endpoints have separate signing secrets.
Choose a forecast horizon
The horizon determines which delivery days can trigger a notification and which days the event's data link covers. It moves forward each day in the product's timezone.
| Selection | Days included |
|---|---|
| Today | Today's local delivery day |
| Tomorrow | Tomorrow's local delivery day |
| Today + next 3 days | Today and the following 3 days: 4 days in total |
| Today + next 7 days | Today and the following 7 days: 8 days in total |
For example, on 12 October, Today + next 7 days covers 12–19 October inclusive. An update for 20 October does not trigger this webhook. The horizon does not extend a product's forecast availability: some selected days may not have a published forecast yet.
The event includes the actual dates and timezone. Use these dates when processing a delayed event; do not recalculate the horizon from the time you receive it. Local days can contain 23 or 25 hours around daylight saving changes.
Verify incoming requests
Telescope follows the Standard Webhooks signature format. Every request contains:
| Header | Purpose |
|---|---|
webhook-id | Matches the body's event id. |
webhook-timestamp | Time of this delivery attempt, in Unix seconds. |
webhook-signature | v1, followed by a Base64 HMAC-SHA256 signature. |
Verify the original request bytes before parsing JSON. JSON parsing and re-serializing can change whitespace or encoding and invalidate the signature. The signed content is webhook-id.webhook-timestamp.rawBody, using the Base64-decoded part of the signing secret after whsec_ as the key.
These Node.js and Python examples accept timestamps within 5 minutes of the receiver's clock and use a constant-time signature comparison. Your server's clock should be synchronized.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyWebhook(rawBody: Buffer, headers: Headers, secret: string) {
const id = headers.get('webhook-id')
const timestamp = headers.get('webhook-timestamp')
const signatures = headers.get('webhook-signature') ?? ''
if (!id || !timestamp || !/^\d+$/.test(timestamp) || !secret.startsWith('whsec_')) {
throw new Error('Missing or invalid webhook credentials')
}
const seconds = Number(timestamp)
if (!Number.isSafeInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) {
throw new Error('Expired webhook timestamp')
}
const expected = createHmac('sha256', Buffer.from(secret.slice(6), 'base64'))
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest()
const valid = signatures.split(' ').some(entry => {
const [version, encoded] = entry.split(',')
if (version !== 'v1' || !encoded) return false
const received = Buffer.from(encoded, 'base64')
return received.length === expected.length && timingSafeEqual(received, expected)
})
if (!valid) throw new Error('Invalid webhook signature')
const event = JSON.parse(rawBody.toString('utf8'))
if (event.id !== id) throw new Error('Mismatched event ID')
return event
}
import base64
import hashlib
import hmac
import json
import time
def verify_webhook(raw_body: bytes, headers, secret: str):
# headers must provide case-insensitive HTTP header lookup.
event_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp", "")
signatures = headers.get("webhook-signature", "").split()
if not event_id or not timestamp.isascii() or not timestamp.isdecimal():
raise ValueError("Missing or invalid webhook credentials")
if abs(time.time() - int(timestamp)) > 300 or not secret.startswith("whsec_"):
raise ValueError("Expired timestamp or invalid secret")
key = base64.b64decode(secret[6:], validate=True)
signed = f"{event_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
valid = any(hmac.compare_digest(entry, "v1," + expected)
for entry in signatures if entry.isascii())
if not valid:
raise ValueError("Invalid webhook signature")
event = json.loads(raw_body)
if event.get("id") != event_id:
raise ValueError("Mismatched event ID")
return event
For Python, pass the framework's raw body bytes and case-insensitive headers; convert verification failures into 400 or 401 responses.
Configure your framework to preserve the raw request body. For a Web Request handler, read it with Buffer.from(await request.arrayBuffer()) and pass request.headers. With Express, use a raw-body middleware for this route before a global JSON parser.
Reject invalid signatures with 400 or 401. Never log the signing secret. To replace a compromised secret, create a replacement webhook, configure your receiver with its new secret and delete the old webhook.
Understand the event
This example uses illustrative IDs and dates. The webhook body is JSON; explanatory comments are kept outside the payload.
{
"id": "evt_example_123",
"type": "forecast.published",
"schemaVersion": "1",
"occurredAt": "2026-10-12T10:00:29.999Z",
"data": {
"product": "day-ahead-price-de-lu",
"horizon": {
"from": "2026-10-12",
"through": "2026-10-19",
"timezone": "Europe/Berlin"
},
"updates": [
{
"deliveryDay": "2026-10-13",
"forecastVersionId": "example_version_13",
"version": 4,
"kind": "preliminary"
},
{
"deliveryDay": "2026-10-14",
"forecastVersionId": "example_version_14",
"version": 2,
"kind": "preliminary"
}
],
"dataUrl": "/api/v2/customer/forecasts/day-ahead-price-de-lu/data?deliveryFrom=2026-10-12&deliveryTo=2026-10-19&asOf=2026-10-12T10:00:29.999Z&limit=8"
}
}
| Field | How to use it |
|---|---|
id | Identifies this event. Retries and manual resends keep the same ID. Use it to recognize duplicates. |
type | forecast.published carries forecast updates; webhook.test checks your receiver. |
schemaVersion | Payload format version, currently "1". Allow additional fields so compatible additions do not break your receiver. |
occurredAt | UTC snapshot time at the end of the collection window. It stays unchanged on retries. |
data.product | The product variant's offering key. Each event concerns one product. |
data.horizon | Inclusive local delivery dates and the product's timezone. |
data.updates | Only the delivery days updated in this event. It is not the complete forecast horizon. |
forecastVersionId | The exact published version for this delivery day. Keep it for reproducibility or fetch that individual version. |
version | Increasing version number for this product and delivery day. Compare it only within that same product/day. |
kind | Publication stage: preliminary, rolling or final. All three can trigger notifications. |
data.dataUrl | Relative API path to the whole selected horizon, including unchanged days. It fixes asOf so later updates do not silently change the snapshot. |
Accept promptly and handle duplicates
A reliable receiver follows this order:
- Verify the signature and timestamp.
- Check the event type and schema version. Handle tests separately; safely acknowledge event types you intentionally ignore.
- Durably store accepted work with a unique constraint on the event ID. If you already accepted the same event, return success again.
- Respond with any
2xxstatus within 10 seconds. Fetch data and run calculations in a background worker.
If you cannot persist the work, return a server error so Telescope can retry. Avoid returning success before you have saved the event somewhere reliable.
Retries and manual resends can deliver the same event more than once. Dedupe by event ID; when several webhook endpoints feed the same application, also use (product, deliveryDay, forecastVersionId) to avoid processing the same forecast twice across subscriptions. For a cache of the latest forecast, only replace a product/day with a newer version—a delayed older event must not overwrite newer data.
Fetch the whole horizon
After verifying and accepting an event, your background worker can follow dataUrl using its customer API key. Resolve the relative path against https://telescope.energy, not against your own receiver URL or the /api/v2 base path.
// event is an authenticated forecast.published event from your work queue.
const origin = 'https://telescope.energy'
const url = new URL(event.data.dataUrl, origin)
if (url.origin !== origin || !url.pathname.startsWith('/api/v2/customer/forecasts/')) {
throw new Error('Unexpected forecast data URL')
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.TELESCOPE_API_KEY}` },
redirect: 'error',
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) throw new Error(`Forecast request failed: ${response.status}`)
const forecast = await response.json()
// Inspect forecast.deliveryDays for availability and version IDs.
// Process forecast.values using validTime, seriesKey and outputKey.
import json
import os
from urllib.parse import urljoin, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
# event is a verified forecast.published event from your work queue.
url = urljoin("https://telescope.energy", event["data"]["dataUrl"])
parts = urlsplit(url)
if (parts.scheme != "https" or parts.netloc != "telescope.energy"
or not parts.path.startswith("/api/v2/customer/forecasts/")):
raise ValueError("Unexpected forecast data URL")
request = Request(url, headers={"Authorization": "Bearer " + os.environ["TELESCOPE_API_KEY"]})
with build_opener(NoRedirect).open(request, timeout=30) as response:
forecast = json.load(response)
# Inspect forecast["deliveryDays"] before processing forecast["values"].
You do not need one request per entry in updates. The included link requests up to 8 complete days in a single response. Normal API access rules still apply; receiving a webhook does not grant additional permissions.
A day with no publication at that snapshot is returned with available: false and reason NO_VERSION_AT_AS_OF. It is not a zero forecast. Do not repeatedly retry the same snapshot expecting that day to appear. A later publication will produce a new event when it is inside your horizon.
Store the returned version IDs with your calculations. See Historical data for the response format. Retry temporary data-fetch failures in your own worker: once your receiver returns 2xx, Telescope considers the notification accepted.
When notifications arrive
All newly published forecast versions within the selected horizon are eligible, including preliminary, rolling and final versions. A calculation that produces no new published version does not trigger an event.
Updates are collected in a short, approximately 30-second window. Several changed delivery days for one product appear together in one message. If the same day changes more than once within that window, the message includes the latest version for that day. This makes the webhook useful for refreshing your application; it is not a separate notification for every intermediate version.
If you subscribe to several products, each product sends a separate event to your endpoint. There is no guaranteed delivery order, including between two events for the same product.
Creating a webhook starts notifications for new publications. It does not send existing forecasts or create an event merely because the date changed. Fetch your initial data through the Customer API.
Retries and delivery history
Open a webhook to see its latest 50 events from the last 30 days, including payload, last response status, attempt count and next scheduled attempt.
| Status | Meaning |
|---|---|
| Waiting | Queued for its first attempt or a retry. |
| Sending | A request is in progress. |
| Delivered | Your receiver returned 2xx. This does not confirm your background processing succeeded. |
| Failed | No further automatic attempt is scheduled. Inspect the last error and fix the cause. |
| Stopped | Delivery was canceled because the webhook, product or account access changed. |
Network failures, timeouts, HTTP 408, 429 and 5xx responses trigger bounded retries. Delays start at about 30 seconds, then grow through minutes to hours, with at most 10 attempts and no automatic retries beyond 72 hours after the event was created. Delays include jitter and may increase with load. Retry-After is respected up to 24 hours per attempt.
Other HTTP errors and redirects are not retried automatically. Fix the endpoint, then select Resend for a completed event. Resends use the current endpoint URL and signing secret but keep the original body, event ID and snapshot. They have a fresh signature timestamp. There is a limit of 5 manual resends per event and 3 test/resend requests per minute per account. A resend after the original 72-hour retry window gets one attempt.
Pause, edit or delete
- Pause stops new notifications and pending deliveries. Resume starts with future publications; updates missed while paused are not replayed.
- Save changes stops pending deliveries, including retries. Future events use the new products, horizon and URL. The signing secret stays the same. You can explicitly resend a previous completed event if its product is still subscribed.
- Delete removes the webhook from your dashboard and stops future deliveries. Create a new webhook to start again; it receives a new secret.
A request already in progress can still arrive after any of these actions. Access changes can also stop delivery. Once access is restored, save or resume your webhook and send a test before relying on new notifications.
If something is wrong
| Symptom | What to check |
|---|---|
| No events arrive | Is the webhook active? Was a new version actually published for a selected product/day? Creating a subscription or crossing midnight alone does not send an event. |
| Test fails | Check the saved URL, public DNS, TLS certificate, firewall and your receiver's POST route. Avoid redirects and login pages. |
| Signature is invalid | Use this endpoint's signing secret, the raw body and a synchronized clock. Do not use your API key as the signing secret. |
Receiver returns 404 or 405 | Confirm the exact path and that it accepts POST. |
Receiver returns 429 or times out | Accept into a queue promptly and process asynchronously. Inspect capacity and retry settings. |
| Event is Delivered but data is missing | Check your background worker and API-key permissions. Inspect deliveryDays[].available; a longer horizon cannot create unpublished forecasts. |
| You receive duplicates or older events | Dedupe by event ID and guard each product/day against an older version. |
For support, include the webhook name, event ID, attempt time and last HTTP status. Do not send your signing secret or API key.