Skip to content

Instantly share code, notes, and snippets.

@andrewhathaway
Created April 6, 2026 00:30
Show Gist options
  • Select an option

  • Save andrewhathaway/502a12a7b7ee0b5614c00c9f0202f709 to your computer and use it in GitHub Desktop.

Select an option

Save andrewhathaway/502a12a7b7ee0b5614c00c9f0202f709 to your computer and use it in GitHub Desktop.
Octopus Energy API Client Script - Generated by Codex
/**
* Octopus Energy + TRMNL example endpoint.
* Article: https://andrewhathaway.net/blog/ambient-cost-display-for-octopus-energy
* Warning: Untested & not-ran.
*
* Deployment targets:
* - Cloudflare Workers: export default with fetch handler is ready to deploy.
* - Deno Deploy: uncomment `Deno.serve(handleRequest);` at the bottom.
*
* Configuration:
* 1) Set values in `CONFIG` directly, or
* 2) Provide environment variables with the same names:
* - OCTOPUS_API_KEY
* - OCTOPUS_ACCOUNT_NUMBER
* - ENDPOINT_AUTH_TOKEN
* - OCTOPUS_GRAPHQL_URL (optional)
* - ENERGY_TIMEZONE (optional, defaults to Europe/London)
* - ENDPOINT_PATH (optional, defaults to /today)
*/
type RawNumber = number | string | null | undefined;
type GraphQLResult<TData> = {
data?: TData;
errors?: Array<{ message: string }>;
};
type DateTimeParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
type TelemetryReading = {
readAt: string;
costDeltaWithTax?: RawNumber;
consumptionDelta?: RawNumber;
};
type UnitRateWindow = {
validFrom: string;
validTo?: string | null;
value?: RawNumber;
};
type UnitCostModel = number | UnitRateWindow[];
type EnergyStatistic = {
totalWatts: number;
totalCost: number;
};
type AppConfig = {
octopusApiKey: string;
octopusAccountNumber: string;
endpointAuthToken: string;
octopusGraphqlUrl: string;
timeZone: string;
endpointPath: string;
};
type AccountQueryData = {
account?: {
electricityAgreements?: Array<{
meterPoint?: {
meters?: Array<{
smartDevices?: Array<{ deviceId?: string | null }> | null;
}> | null;
} | null;
tariff?: {
unitRate?: RawNumber;
dayRate?: RawNumber;
nightRate?: RawNumber;
unitRates?: UnitRateWindow[] | null;
} | null;
}> | null;
gasAgreements?: Array<{
meterPoint?: {
meters?: Array<{
smartDevices?: Array<{ deviceId?: string | null }> | null;
}> | null;
} | null;
}> | null;
} | null;
};
type TokenQueryData = {
obtainKrakenToken?: {
token?: string | null;
} | null;
};
type TelemetryQueryData = {
elecTelemetry?: TelemetryReading[] | null;
gasTelemetry?: TelemetryReading[] | null;
};
const DEFAULT_GRAPHQL_URL = "https://api.octopus.energy/v1/graphql/";
const DEFAULT_TIMEZONE = "Europe/London";
const DEFAULT_ENDPOINT_PATH = "/today";
const CONFIG: AppConfig = {
octopusApiKey: getEnv("OCTOPUS_API_KEY") ?? "<YOUR_OCTOPUS_API_KEY>",
octopusAccountNumber: getEnv("OCTOPUS_ACCOUNT_NUMBER") ?? "<YOUR_OCTOPUS_ACCOUNT_NUMBER>",
endpointAuthToken: getEnv("ENDPOINT_AUTH_TOKEN") ?? "<YOUR_ENDPOINT_AUTH_TOKEN>",
octopusGraphqlUrl: getEnv("OCTOPUS_GRAPHQL_URL") ?? DEFAULT_GRAPHQL_URL,
timeZone: getEnv("ENERGY_TIMEZONE") ?? DEFAULT_TIMEZONE,
endpointPath: getEnv("ENDPOINT_PATH") ?? DEFAULT_ENDPOINT_PATH,
};
const OBTAIN_KRAKEN_TOKEN_MUTATION = `
mutation ObtainKrakenToken($input: ObtainJSONWebTokenInput!) {
obtainKrakenToken(input: $input) {
token
}
}
`;
const ACCOUNT_INFORMATION_QUERY = `
query($accountNumber: String!) {
account(accountNumber: $accountNumber) {
electricityAgreements(active: true) {
meterPoint {
meters {
smartDevices {
deviceId
}
}
}
tariff {
... on StandardTariff {
unitRate
}
... on DayNightTariff {
dayRate
nightRate
}
... on HalfHourlyTariff {
unitRates {
validFrom
validTo
value
}
}
... on PrepayTariff {
unitRate
}
}
}
gasAgreements(active: true) {
meterPoint {
meters {
smartDevices {
deviceId
}
}
}
}
}
}
`;
const TELEMETRY_QUERY = `
query(
$elecDeviceId: String!
$gasDeviceId: String!
$startUtc: DateTime!
$endUtc: DateTime!
) {
elecTelemetry: smartMeterTelemetry(
deviceId: $elecDeviceId
grouping: HALF_HOURLY
start: $startUtc
end: $endUtc
) {
readAt
costDeltaWithTax
consumptionDelta
}
gasTelemetry: smartMeterTelemetry(
deviceId: $gasDeviceId
grouping: HALF_HOURLY
start: $startUtc
end: $endUtc
) {
readAt
costDeltaWithTax
consumptionDelta
}
}
`;
export async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname !== CONFIG.endpointPath) {
return json({ error: "Not found" }, 404);
}
if (request.method !== "GET") {
return json({ error: "Method not allowed" }, 405, { allow: "GET" });
}
if (!isAuthorized(request, CONFIG.endpointAuthToken)) {
return json(
{ error: "Unauthorized. Send Authorization: Bearer <ENDPOINT_AUTH_TOKEN>." },
401,
{ "www-authenticate": "Bearer" },
);
}
const configErrors = validateConfig(CONFIG);
if (configErrors.length > 0) {
return json(
{
error: "Configuration is incomplete.",
issues: configErrors,
},
500,
);
}
try {
const token = await getRequestToken(CONFIG);
const accountInfo = await getAccountInformation(token, CONFIG);
const [startUtc, endUtc] = getTodayUtcRange(CONFIG.timeZone);
const telemetry = await getEnergyTelemetry(token, accountInfo, startUtc, endUtc, CONFIG);
const gas = calculateEnergyStatistics(telemetry.gasTelemetry, (reading) => {
return toNumber(reading.costDeltaWithTax) ?? 0;
});
const electricity = calculateEnergyStatistics(telemetry.elecTelemetry, (reading) => {
const numberWatts = toNumber(reading.consumptionDelta);
if (numberWatts == null) return 0;
const unitCost = getUnitCostForReading(reading.readAt, accountInfo.electricityUnitCost);
if (unitCost == null) {
// Fallback when unit tariff is unavailable for a timestamp.
return toNumber(reading.costDeltaWithTax) ?? 0;
}
return (numberWatts / 1000) * unitCost;
});
return json(
{
startUtc,
endUtc,
gas,
electricity,
},
200,
{ "cache-control": "no-store" },
);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return json(
{
error: "Failed to load Octopus energy data.",
message,
},
500,
);
}
}
async function getRequestToken(config: AppConfig): Promise<string> {
const data = await graphqlRequest<TokenQueryData>({
config,
query: OBTAIN_KRAKEN_TOKEN_MUTATION,
variables: {
input: {
APIKey: config.octopusApiKey,
},
},
});
const token = data.obtainKrakenToken?.token;
if (!token) {
throw new Error("Octopus auth response did not include a token.");
}
return token;
}
async function getAccountInformation(
token: string,
config: AppConfig,
): Promise<{
electricityDeviceId: string;
gasDeviceId: string;
electricityUnitCost: UnitCostModel;
}> {
const data = await graphqlRequest<AccountQueryData>({
config,
token,
query: ACCOUNT_INFORMATION_QUERY,
variables: {
accountNumber: config.octopusAccountNumber,
},
});
const electricityAgreements = data.account?.electricityAgreements ?? [];
const gasAgreements = data.account?.gasAgreements ?? [];
const electricityDeviceId = findFirstDeviceId(electricityAgreements, "electricity");
const gasDeviceId = findFirstDeviceId(gasAgreements, "gas");
const electricityUnitCost = findElectricityUnitCostModel(electricityAgreements);
return {
electricityDeviceId,
gasDeviceId,
electricityUnitCost,
};
}
async function getEnergyTelemetry(
token: string,
accountInfo: { electricityDeviceId: string; gasDeviceId: string },
startUtc: string,
endUtc: string,
config: AppConfig,
): Promise<{ elecTelemetry: TelemetryReading[]; gasTelemetry: TelemetryReading[] }> {
const data = await graphqlRequest<TelemetryQueryData>({
config,
token,
query: TELEMETRY_QUERY,
variables: {
elecDeviceId: accountInfo.electricityDeviceId,
gasDeviceId: accountInfo.gasDeviceId,
startUtc,
endUtc,
},
});
return {
elecTelemetry: data.elecTelemetry ?? [],
gasTelemetry: data.gasTelemetry ?? [],
};
}
function calculateEnergyStatistics(
telemetry: TelemetryReading[],
getEnergyReadingCost: (reading: TelemetryReading) => number,
): EnergyStatistic {
let totalWatts = 0;
let totalCost = 0;
for (const reading of telemetry) {
const numberWatts = toNumber(reading.consumptionDelta);
if (numberWatts != null) {
totalWatts += numberWatts;
}
totalCost += getEnergyReadingCost(reading);
}
return {
totalWatts,
totalCost,
};
}
function getUnitCostForReading(readAtIso: string, unitCost: UnitCostModel): number | null {
if (typeof unitCost === "number") {
return unitCost;
}
const readAt = Date.parse(readAtIso);
if (Number.isNaN(readAt)) {
return null;
}
for (const rate of unitCost) {
const from = Date.parse(rate.validFrom);
const to = rate.validTo ? Date.parse(rate.validTo) : Number.POSITIVE_INFINITY;
if (Number.isNaN(from) || Number.isNaN(to)) {
continue;
}
if (readAt >= from && readAt < to) {
const parsedRate = toNumber(rate.value);
if (parsedRate != null) return parsedRate;
}
}
return null;
}
function findElectricityUnitCostModel(
agreements: Array<{ tariff?: { unitRate?: RawNumber; dayRate?: RawNumber; nightRate?: RawNumber; unitRates?: UnitRateWindow[] | null } | null }>,
): UnitCostModel {
for (const agreement of agreements) {
const tariff = agreement.tariff;
if (!tariff) continue;
const halfHourly = (tariff.unitRates ?? []).filter(
(rate) => toNumber(rate.value) != null && Boolean(rate.validFrom),
);
if (halfHourly.length > 0) {
return halfHourly;
}
const flatRate =
toNumber(tariff.unitRate) ??
toNumber(tariff.dayRate) ??
toNumber(tariff.nightRate);
if (flatRate != null) {
return flatRate;
}
}
throw new Error("No electricity tariff rate found in active agreements.");
}
function findFirstDeviceId(
agreements: Array<{ meterPoint?: { meters?: Array<{ smartDevices?: Array<{ deviceId?: string | null }> | null }> | null } | null }>,
label: "electricity" | "gas",
): string {
for (const agreement of agreements) {
for (const meter of agreement.meterPoint?.meters ?? []) {
for (const device of meter.smartDevices ?? []) {
if (device.deviceId) {
return device.deviceId;
}
}
}
}
throw new Error(`No ${label} smart meter device ID found in active agreements.`);
}
function getTodayUtcRange(timeZone: string): [string, string] {
const today = getDatePartsInTimeZone(new Date(), timeZone);
const start = zonedDateTimeToUtcIso(
{
year: today.year,
month: today.month,
day: today.day,
hour: 0,
minute: 0,
second: 0,
},
timeZone,
);
const end = zonedDateTimeToUtcIso(
{
year: today.year,
month: today.month,
day: today.day,
hour: 23,
minute: 59,
second: 59,
},
timeZone,
);
return [start, end];
}
function getDatePartsInTimeZone(date: Date, timeZone: string): DateTimeParts {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const parts = formatter.formatToParts(date);
const values: Partial<DateTimeParts> = {};
for (const part of parts) {
if (
part.type === "year" ||
part.type === "month" ||
part.type === "day" ||
part.type === "hour" ||
part.type === "minute" ||
part.type === "second"
) {
values[part.type] = Number(part.value);
}
}
if (
values.year == null ||
values.month == null ||
values.day == null ||
values.hour == null ||
values.minute == null ||
values.second == null
) {
throw new Error(`Failed to compute date parts for timezone "${timeZone}".`);
}
return values as DateTimeParts;
}
function zonedDateTimeToUtcIso(parts: DateTimeParts, timeZone: string): string {
// Convert local wall-clock time in a timezone into a UTC timestamp, with a second
// pass to correctly handle timezone offset transitions (DST boundaries).
let utcGuess = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
const firstOffset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
utcGuess -= firstOffset;
const secondOffset = getTimeZoneOffsetMs(new Date(utcGuess), timeZone);
if (firstOffset !== secondOffset) {
utcGuess -= secondOffset - firstOffset;
}
return new Date(utcGuess).toISOString();
}
function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const tzParts = getDatePartsInTimeZone(date, timeZone);
const asUtc = Date.UTC(
tzParts.year,
tzParts.month - 1,
tzParts.day,
tzParts.hour,
tzParts.minute,
tzParts.second,
);
return asUtc - date.getTime();
}
async function graphqlRequest<TData>(args: {
config: AppConfig;
query: string;
variables?: Record<string, unknown>;
token?: string;
}): Promise<TData> {
const response = await fetch(args.config.octopusGraphqlUrl, {
method: "POST",
headers: {
"content-type": "application/json",
...(args.token ? { authorization: `Bearer ${args.token}` } : {}),
},
body: JSON.stringify({
query: args.query,
variables: args.variables,
}),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Octopus GraphQL request failed (${response.status}): ${errorBody}`);
}
const payload = (await response.json()) as GraphQLResult<TData>;
if (payload.errors && payload.errors.length > 0) {
const message = payload.errors.map((error) => error.message).join("; ");
throw new Error(`Octopus GraphQL returned errors: ${message}`);
}
if (!payload.data) {
throw new Error("Octopus GraphQL response did not contain data.");
}
return payload.data;
}
function toNumber(value: RawNumber): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function isAuthorized(request: Request, token: string): boolean {
const header = request.headers.get("authorization");
return header === `Bearer ${token}`;
}
function validateConfig(config: AppConfig): string[] {
const issues: string[] = [];
if (!config.octopusApiKey || config.octopusApiKey.startsWith("<")) {
issues.push("Set OCTOPUS_API_KEY.");
}
if (!config.octopusAccountNumber || config.octopusAccountNumber.startsWith("<")) {
issues.push("Set OCTOPUS_ACCOUNT_NUMBER.");
}
if (!config.endpointAuthToken || config.endpointAuthToken.startsWith("<")) {
issues.push("Set ENDPOINT_AUTH_TOKEN.");
}
return issues;
}
function getEnv(key: string): string | undefined {
const deno = (globalThis as { Deno?: { env?: { get: (name: string) => string | undefined } } }).Deno;
if (deno?.env?.get) {
return deno.env.get(key);
}
const processRef = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
return processRef?.env?.[key];
}
function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body, null, 2), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
...headers,
},
});
}
export default {
fetch: handleRequest,
};
// For Deno Deploy, uncomment:
// Deno.serve(handleRequest);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment