Uptime Monitor
Category: Engineering & DevOps
This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.
Uptime monitoring where a Google Sheet is the whole database. The sheet holds the list of sites to watch, the append-only log of every check, and the current up/down state — so the people who care which sites are monitored can edit the monitor without touching a deployment.
A schedule probes every site, appends a log row, upserts the current status, and posts to a webhook only when something is actually down.
The Google connector is a config pattern, not a plugin
There is no Sheets integration to install here, and none is needed. Air Pipe mints a service-account token, and Sheets is then an ordinary REST API:
- name: GetToken
hide_data_on_success: true
google:
credential: main
create_token:
scopes: ["https://www.googleapis.com/auth/spreadsheets"]
- name: ReadSites
http:
url: ".../spreadsheets/a|ap_var::SHEET_ID|/values/Sites!A2:B"
headers: { Authorization: a|GetToken| }
The same two actions reach Drive, Gmail, Docs, Calendar or BigQuery — change the scope and the URL. Nothing in this pack is Sheets-specific except the ranges.
hide_data_on_success: trueon the token action is not optional. Without it the minted access token is returned in the response body.
The fan-out is one action
Probing N sites is a lookup:, with the two things a real monitor needs as plain fields:
- name: Probe
lookup: a|Sites|
lookup_concurrency: 5 # probe 5 at a time
item_timeout: 15s # one hung site cannot stall the run
actions:
- name: Hit
http:
url: a|body::json.url|
expect_status: [1xx, 2xx, 3xx, 4xx, 5xx]
That expect_status is the subtle one. A monitor exists to record bad statuses, so a 500 must
be data, not a failure — without this line the probe fails on the outage it is meant to catch.
About the upsert, honestly
Sheets has no upsert. Anything that offers you one is doing read-then-write underneath, and so is
this pack: ReadStatus → compute each site's row → WriteStatus batch update. Three actions
instead of one.
The reason to show it rather than hide it: an edit made to the Status tab between the read and the write is lost. That is true of every upsert built this way, including the ones that look like a single step. Here you can see where the window is.
Endpoints
| Method | Route | Purpose |
|---|---|---|
| POST | /uptime/check | Probe every site, log it, upsert status, alert if any are down. |
| GET | /uptime/status | Current state of every site as JSON — point a status page here. |
| POST | /uptime/setup | One-off: write the header rows the monitor expects. |
/uptime/check also runs on a schedule (*/15 * * * *). Fifteen minutes is the default because
it keeps a single monitor inside the free tier's monthly request allowance; a faster schedule
works fine and simply costs more requests.
Setup
1. Create the spreadsheet yourself. A service account cannot create one — Google removed
Drive storage for service accounts, so POST /v4/spreadsheets returns PERMISSION_DENIED even
with the right scope. Make the sheet as a human, with three tabs named Sites, Logs and
Status.
2. Enable the Google Sheets API on the project that owns the service account. The 403 you get otherwise names the project and links the console page, but nobody expects the step.
3. Share the sheet with the service account — its client_email, as an Editor. Read-only
access fails at the first append.
4. Set the managed variables:
| Variable | Value |
|---|---|
SHEET_ID | The long id in the sheet's URL, between /d/ and /edit. |
ALERT_WEBHOOK | A Slack (or any) incoming-webhook URL. |
5. Add the Google credential as main, then seed the headers:
curl -X POST https://<your-endpoint>/uptime/setup -H "x-api-key: $KEY"
# {"seeded": true}
The Status tab in particular needs its header row: rows are addressed from A2 down, so
without one, row 1 stays empty and the first site's status lands on the wrong line.
6. List the sites to watch in the Sites tab, one per row — name in column A, URL in
column B:
| name | url |
|---|---|
| Example | https://example.com |
| Marketing | https://your-site.example/ |
Walkthrough
Run a check by hand:
curl -X POST https://<your-endpoint>/uptime/check -H "x-api-key: $KEY"
# {"down": 1}
Logs gains one row per site per run:
| checked_at | name | url | state | status |
|---|---|---|---|---|
| 2026-08-30T09:47:42.080Z | Example | https://example.com | UP | 200 |
| 2026-08-30T09:47:42.080Z | Down test | https://httpbin.org/status/500 | DOWN | 500 |
Status keeps exactly one row per site, updated in place — run it twice and the timestamps
change but the row count does not.
Read the current state back as JSON:
curl https://<your-endpoint>/uptime/status -H "x-api-key: $KEY"
{
"sites": [
{ "url": "https://example.com", "state": "UP", "checked_at": "2026-08-30T09:47:42.080Z" },
{ "url": "https://httpbin.org/status/500", "state": "DOWN", "checked_at": "2026-08-30T09:47:42.080Z" }
]
}
When at least one site is down, the webhook receives:
🚨 1 site(s) DOWN
• Down test — https://httpbin.org/status/500 (HTTP 500)
When everything is up, the alert action is skipped entirely — a healthy run makes no outbound call at all.
Requirements
- An Air Pipe engine with the
code:action (3.3.0+). - A Google service account credential with the Sheets scope.
Configuration
config.yml
name: UptimeMonitor
description: >
Uptime monitoring where a Google Sheet is both the list of sites to watch and
the log of what happened. A schedule probes every site, appends a log row,
upserts a current-status row, and alerts only when something is actually down.
docs: true
# Managed variables to set before deploying:
# SHEET_ID — the spreadsheet id (the long string in its URL)
# ALERT_WEBHOOK — a Slack (or any) incoming-webhook URL for down alerts
#
# Credential:
# google: main — a service account with the Sheets scope. Share the sheet
# with its client_email as an Editor, or it cannot write.
global:
variables:
sheets_api: "https://sheets.googleapis.com/v4/spreadsheets"
sites_range: "Sites!A2:B"
status_range: "Status!A2:C"
log_tab: "Logs"
status_tab: "Status"
interfaces:
# ─────────────────────────────────────────────────────────────────────────
# The monitor. Runs on a schedule, and can also be triggered by hand.
# ─────────────────────────────────────────────────────────────────────────
uptime/check:
output: http
method: POST
summary: Probe every site in the sheet, log the result, alert on failure
description: >
Reads the site list from the Sites tab, probes each site concurrently,
appends one row per site to Logs, upserts one row per site into Status,
and posts to the alert webhook only if at least one site is down.
tags: [monitoring, google-sheets, scheduled]
response_example: |
{"down": 1}
# Every 15 minutes. This is also the floor that keeps a single monitor
# inside the free tier's monthly request allowance — a faster schedule is
# fine, it just costs more requests.
schedule:
enabled: true
cron: "*/15 * * * *"
actions:
# The whole Google "connector" is this action plus plain HTTP. There is
# no Sheets-specific action type in Air Pipe and none is needed: with a
# token, Sheets is an ordinary REST API.
#
# hide_data_on_success keeps the minted access token out of the response
# body. Without it the token is returned to the caller.
- name: GetToken
hide_data_on_success: true
google:
credential: main
create_token:
scopes: ["https://www.googleapis.com/auth/spreadsheets"]
# Sheets answers { values: [[name, url], ...] }. An absent `values` means
# an empty sheet, which is not an error — it is a monitor with nothing to
# do yet.
- name: ReadSites
run_when_succeeded: [GetToken]
http:
method: GET
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values/a|var::sites_range|"
headers:
Authorization: a|GetToken|
post_transforms:
- extract_value: "body"
# Rows arrive positionally. Name the columns once, here, so nothing
# downstream has to remember that column B is the URL.
- name: Sites
run_when_succeeded: [ReadSites]
input: a|ReadSites|
code:
language: js
source: |
const rows = $input.first().json.values || [];
return rows
.filter(r => r && r[1])
.map(r => ({ json: { name: (r[0] || r[1]).trim(), url: r[1].trim() } }));
# The fan-out. One action, with concurrency and a per-item timeout as
# fields rather than as extra nodes and a loop-back edge.
- name: Probe
run_when_succeeded: [Sites]
lookup: a|Sites|
lookup_concurrency: 5
item_timeout: 15s
actions:
- name: Hit
http:
method: GET
url: a|body::json.url|
# The point of a probe is to observe the status, so no status is
# an error here. Without this a 500 fails the action and the
# monitor cannot record the outage it exists to record.
expect_status: [1xx, 2xx, 3xx, 4xx, 5xx]
- name: Results
run_when_succeeded: [Probe]
input: a|Probe|
code:
language: js
source: |
const sites = $('Sites').all().map(i => i.json);
// A lookup collects one record per item, keyed by the inner action's
// name — so the probe's status is at .data.Hit.data.status.
const probes = $('Probe').all().map(i => {
const o = i.json ?? i;
return o?.data?.Hit?.data?.status ?? o?.status ?? null;
});
const now = new Date().toISOString();
return sites.map((s, i) => {
const status = Number(probes[i] ?? 0);
const up = status >= 200 && status < 400;
return { json: {
name: s.name, url: s.url, status,
state: up ? "UP" : "DOWN",
checked_at: now,
} };
});
# ── append one log row per site ──────────────────────────────────────
- name: LogRows
run_when_succeeded: [Results]
input: a|Results|
code:
language: js
source: |
return [{ json: { values: $('Results').all()
.map(i => [i.json.checked_at, i.json.name, i.json.url,
i.json.state, String(i.json.status)]) } }];
- name: AppendLog
run_when_succeeded: [LogRows]
http:
method: POST
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values/a|var::log_tab|!A:E:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS"
headers:
Authorization: a|GetToken|
content-type: application/json
body: |
{ "values": a|LogRows::0.json.values| }
# ── upsert the current status per site ───────────────────────────────
# Sheets has no upsert. Any tool that offers one is doing read-then-write
# underneath, and so is this: read the key column, work out each site's
# row, then write those cells in a single batch.
#
# Worth knowing rather than hiding: a concurrent edit between the read
# and the write is lost. That is true of every upsert built this way.
- name: ReadStatus
run_when_succeeded: [Results]
http:
method: GET
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values/a|var::status_range|"
headers:
Authorization: a|GetToken|
post_transforms:
- extract_value: "body"
- name: StatusWrites
run_when_succeeded: [ReadStatus]
input: a|ReadStatus|
code:
language: js
source: |
const statusTab = "a|var::status_tab|";
const existing = $('ReadStatus').first().json.values || [];
const results = $('Results').all().map(i => i.json);
const rowOf = new Map();
existing.forEach((r, i) => { if (r && r[0]) rowOf.set(r[0].trim(), i + 2); });
let next = existing.length + 2;
const data = results.map(r => {
const row = rowOf.get(r.url) ?? next++;
return { range: `${statusTab}!A${row}:C${row}`,
values: [[r.url, r.state, r.checked_at]] };
});
return [{ json: { data } }];
- name: WriteStatus
run_when_succeeded: [StatusWrites]
http:
method: POST
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values:batchUpdate"
headers:
Authorization: a|GetToken|
content-type: application/json
body: |
{ "valueInputOption": "RAW", "data": a|StatusWrites::0.json.data| }
# ── alert, only if something is actually down ────────────────────────
- name: Down
run_when_succeeded: [Results]
input: a|Results|
code:
language: js
source: |
const down = $('Results').all().map(i => i.json).filter(r => r.state === "DOWN");
return [{ json: { count: down.length,
text: down.length
? `:rotating_light: ${down.length} site(s) DOWN\n` +
down.map(d => `• ${d.name} — ${d.url} (HTTP ${d.status})`).join("\n")
: "" } }];
- name: Alert
run_when_succeeded: [Down]
# Skipped entirely when everything is up, so a healthy run makes no
# outbound call at all.
run_on_assertion:
tests:
- action: Down
value: 0.json.count
is_not_equal_to: 0
http:
method: POST
url: a|ap_var::ALERT_WEBHOOK|
headers:
content-type: application/json
body: |
{ "text": a|Down::0.json.text| }
- name: Respond
run_when_succeeded: [WriteStatus, AppendLog]
json_output: |-
{
"down": a|Down::0.json.count|
}
# ─────────────────────────────────────────────────────────────────────────
# A read-only status API over the same sheet, so the monitor has a front end
# without needing a second data store.
# ─────────────────────────────────────────────────────────────────────────
uptime/status:
output: http
method: GET
summary: Current up/down state for every monitored site
description: >
Returns the Status tab as JSON — one entry per site with its last known
state and the time it was checked. Point a status page at this.
tags: [monitoring, google-sheets]
response_example: |
{"sites":[{"url":"https://example.com","state":"UP","checked_at":"2026-08-30T09:00:00.000Z"}]}
actions:
- name: GetToken
hide_data_on_success: true
google:
credential: main
create_token:
scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"]
- name: ReadStatus
run_when_succeeded: [GetToken]
http:
method: GET
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values/a|var::status_range|"
headers:
Authorization: a|GetToken|
post_transforms:
- extract_value: "body"
- name: Shape
run_when_succeeded: [ReadStatus]
input: a|ReadStatus|
code:
language: js
source: |
const rows = $input.first().json.values || [];
return [{ json: { sites: rows
.filter(r => r && r[0])
.map(r => ({ url: r[0], state: r[1] || "UNKNOWN", checked_at: r[2] || null })) } }];
- name: Respond
run_when_succeeded: [Shape]
json_output: |-
{ "sites": a|Shape::0.json.sites| }
# ─────────────────────────────────────────────────────────────────────────
# One-off setup. Run once after sharing the sheet with the service account.
# ─────────────────────────────────────────────────────────────────────────
uptime/setup:
output: http
method: POST
summary: Write the header rows the monitor expects
description: >
Seeds the header row on the Sites, Logs and Status tabs. Safe to re-run —
it overwrites row 1 only. The Status tab in particular NEEDS a header row,
because rows are addressed from A2 down; without one, row 1 stays blank
and the first site's status is written to the wrong line.
Create the three tabs yourself first: a service account cannot create a
spreadsheet (Google removed their Drive storage), so the sheet has to be
made by a human and shared with the service account as an Editor.
tags: [monitoring, google-sheets, setup]
response_example: |
{"seeded": true}
actions:
- name: GetToken
hide_data_on_success: true
google:
credential: main
create_token:
scopes: ["https://www.googleapis.com/auth/spreadsheets"]
- name: WriteHeaders
run_when_succeeded: [GetToken]
http:
method: POST
url: "a|var::sheets_api|/a|ap_var::SHEET_ID|/values:batchUpdate"
headers:
Authorization: a|GetToken|
content-type: application/json
body: |
{
"valueInputOption": "RAW",
"data": [
{ "range": "Sites!A1:B1", "values": [["name", "url"]] },
{ "range": "Logs!A1:E1", "values": [["checked_at", "name", "url", "state", "status"]] },
{ "range": "Status!A1:C1", "values": [["url", "state", "checked_at"]] }
]
}
- name: Respond
run_when_succeeded: [WriteHeaders]
json_output: |-
{ "seeded": true }