Google Chat is where a lot of Workspace-based teams already live, and an outage message in the team's space reaches more people faster than an email to one inbox. Chat's incoming webhooks make posting easy: one POST, one JSON body, no OAuth. The catch is that Chat wants a message object with a text field, and CronAlert's webhook channel sends a different, fixed document. Paste the Chat URL straight into CronAlert and Chat answers 400, which CronAlert logs as a failed delivery, and nothing appears in the space.
So this is a relay guide. A small Cloudflare Worker sits between the two, verifies CronAlert's signature, reshapes the payload, posts to Chat, and threads the recovery under the outage. It is the same pattern as the Opsgenie and incident.io relays, specialised for Chat's formatting and threading. The webhook channel is on every CronAlert plan, including Free.
Step 1: create the webhook in the space
- Open Google Chat in a browser. Google's documentation notes that webhooks are not configurable from the mobile app.
- Go to the space that should receive alerts. Next to the space title, click the arrow and choose Apps & integrations.
- Click Add webhooks. Name it
CronAlert; the avatar URL is optional (a red circle PNG makes the messages easy to spot). - Save, then open the webhook's menu and Copy link.
The URL has the form https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=KEY&token=TOKEN. The key is shared across webhooks; the token is unique and must be kept secret, because anyone holding the full URL can post to the space. Each webhook works only in the space where it was created. If Add webhooks is not there, your Workspace organization has not allowed incoming webhooks; see the last section.
Test it from a terminal before building anything:
curl -sS -X POST 'https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=KEY&token=TOKEN' \
-H 'Content-Type: application/json; charset=UTF-8' \
-d '{"text": "CronAlert relay test *bold* _italic_ "}' Chat answers with a Message object whose name identifies the new message. For webhook requests the response is trimmed to name and thread.name plus what you sent.
Step 2: the relay Worker
CronAlert sends one POST on monitor.down and one on monitor.recovered, with the monitor's name and URL, the incident's startedAt and resolvedAt, the check that triggered it, and an HMAC-SHA256 signature of the raw body in X-CronAlert-Signature. The webhook reference has the full payload and the field-by-field notes. The Worker below verifies the signature, builds a Chat message in Chat's own markup, and posts it with a thread key so recovery lands under the outage:
// CronAlert webhook → Google Chat incoming webhook, one thread per outage
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("ok");
const raw = await request.text();
if (!(await verify(raw, request.headers.get("X-CronAlert-Signature"), env.CRONALERT_WEBHOOK_SECRET))) {
return new Response("bad signature", { status: 401 });
}
const a = JSON.parse(raw);
const down = a.event === "monitor.down";
const detail = a.check.statusCode ? `HTTP ${a.check.statusCode}` : (a.check.errorMessage ?? "no response");
const where = a.check.region ? ` from ${a.check.region}` : "";
const critical = /\[p1\]/i.test(a.monitor.name);
const text = down
? `🔴 *${a.monitor.name}* is DOWN — ${detail}${where}\n<${a.monitor.url}|${a.monitor.url}>${critical ? "\n<users/all> please acknowledge" : ""}`
: `🟢 *${a.monitor.name}* recovered after ${minutes(a.incident)} min`;
// Same key for the down and recovered messages of one incident → one thread per outage
const threadKey = `cronalert-${hash(a.monitor.url + a.incident.startedAt)}`;
const url = new URL(env.GOOGLE_CHAT_WEBHOOK);
url.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
url.searchParams.set("requestId", crypto.randomUUID()); // makes a retry idempotent
let res;
for (let attempt = 0; attempt < 3; attempt++) {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({ text, thread: { threadKey } }),
});
if (res.status !== 429) break;
await new Promise((r) => setTimeout(r, 1200 * (attempt + 1))); // 1 write/second per space
}
return new Response(null, { status: res.ok ? 204 : 502 }); // 502 → CronAlert logs a failed delivery
},
};
const minutes = (i) => Math.max(1, Math.round((new Date(i.resolvedAt) - new Date(i.startedAt)) / 60000));
const hash = (s) => [...s].reduce((h, c) => (Math.imul(31, h) + c.charCodeAt(0)) | 0, 7).toString(36).replace("-", "m");
async function verify(raw, header, secret) {
if (!header || !secret) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw));
const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
return hex.length === header.length && crypto.subtle.timingSafeEqual(new TextEncoder().encode(hex), new TextEncoder().encode(header));
} Deploy it with wrangler deploy, then set two secrets: GOOGLE_CHAT_WEBHOOK (the full URL from step 1) and CRONALERT_WEBHOOK_SECRET (a random string you will paste into CronAlert in step 3). The Worker runs well inside Cloudflare's free tier at any plausible alert volume.
Step 3: point CronAlert at the Worker
- In CronAlert, open Settings → Alert channels and add a Webhook channel.
- URL: your Worker's address, e.g.
https://cronalert-gchat.you.workers.dev. Secret: the same value you stored asCRONALERT_WEBHOOK_SECRET. - Save and use Send test. A test message should appear in the space within a second or two.
Alert channels in CronAlert are team-wide: every monitor on the team sends to every enabled channel. If some monitors should reach a different space, create a second team with its own webhook channel and relay, or make the relay choose the Chat URL from a tag in the monitor name. Alert routing with teams explains both approaches.
Formatting that works in Chat
Incoming webhooks default to Chat's own markup, not Markdown. In the text field: *bold*, _italic_, ~strikethrough~, backticks for monospace and triple backticks for a block, <https://example.com|label> for a link with text, \n for a line break, and * or - at the start of a line for a bullet. Numbered lists are not supported in this syntax, and custom emoji cannot be used in webhook messages; standard Unicode emoji like 🔴 and 🟢 are fine and make the two message types scannable.
To get attention, <users/all> mentions everyone in the space and <users/USER_ID> mentions one person by their numeric ID. The Worker above adds <users/all> only when the monitor name carries a [p1] tag, which keeps the mention meaningful; mentioning everyone on every alert is how a space gets muted. Alert fatigue has more on that.
Chat also accepts rich cards via cardsV2 (a header with a title, sections of widgets such as textParagraph). They look good but add surface area, and a short text message with a link is read faster on a phone. Start with text; add a card later if people ask for it.
Threading, limits, and retries
- One thread per outage. The Worker sends
thread.threadKeyin the body andmessageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREADon the URL. The first message with a key creates a thread; the recovery, with the same key, replies in it. The alternative valueREPLY_MESSAGE_OR_FAILfails instead of starting a thread, which is wrong for a down message. The olderthreadKeyURL parameter is deprecated.messageReplyOptionis only supported in named spaces, so in a group DM the two messages simply appear in sequence. - One write per second per space. Google's limits page puts message creation at 1 write per second per space, shared by everything posting there, with 429 on excess. A single outage is one message; a shared failure (DNS, a reverse proxy) that takes 40 monitors down inside the same check cycle is 40 messages in a few seconds. The Worker retries up to three times with increasing delay. If you expect fleet-wide events, a relay that batches messages per minute, or a second team for the noisy fleet, is a better design.
- 32,000 bytes per message including cards. Alert text is a few hundred bytes; only an enormous
errorMessagecould approach it. Truncatedetailif your monitors return long error bodies. - requestId is a random UUID the API uses to de-duplicate retries, so a retried post after a network blip does not produce two messages.
- The relay must answer quickly. CronAlert records a non-2xx from the relay as a failed delivery for the channel. Returning 502 when Chat refused the message is deliberate: you see the failure in CronAlert's channel log instead of silence. Keep a second channel (push or email) on anything critical, as with any single webhook destination.
If webhooks are disabled in your Workspace
Incoming webhooks are a Google Workspace feature, available to Business and Enterprise accounts, and an admin can turn them off for the organization or for specific organizational units. When they are off, Add webhooks does not appear in Apps & integrations. Your options, in order of effort:
- Ask the admin to allow webhooks for the space's organizational unit. It is one toggle in the Admin console.
- Use a channel CronAlert supports natively. Email to a Google Group that is a member of the space posts into it on some configurations; push, Slack, Microsoft Teams, Telegram, and Discord need no relay at all.
- Build a Chat app with the Chat API and a service account. It can post to spaces it has been added to without webhooks, but it needs a Google Cloud project, OAuth scopes, and admin approval; it is the right answer for a company-wide rollout and the wrong one for a single alert channel.
Frequently asked questions
Does the webhook work in a direct message or group chat?
Google's documentation says incoming webhooks work in direct messages only when all users have Chat apps enabled, and messageReplyOption only in named spaces. A dedicated space for alerts is the predictable choice.
Can the relay post to several spaces?
Yes: store several webhook URLs as secrets and choose by a tag in the monitor name ([billing], [infra]), the same trick the Worker uses for [p1]. The per-second limit is per space, so spreading alerts across spaces also spreads the load.
Can I include the response time or region?
Both are in the payload (check.responseTime in milliseconds, check.region when the monitor is multi-region). Add them to the text. Any field can be null: a timeout has no status code, a heartbeat has no response time.
Is any of this on a paid plan?
No. The webhook channel is on every CronAlert plan, Free included, and the Worker is free to run. Pro adds keyword assertions, heartbeat monitors, and 1-minute checks, which change what you alert on rather than where the alert goes.
Get started
One webhook in the space, one Worker, one channel in CronAlert, and outages show up where the team already is, with the recovery threaded under them. Create a free CronAlert account, add the webhook channel, and send the test. Related reading: the webhook payload and Zapier, Make, n8n, and ntfy recipes, routing alerts with teams, Mattermost, Rocket.Chat, and Zulip alerts, reaching a hundred services through Apprise, and testing your alerts.