Resend-based email alerts for your IndexNow automation, whether it runs on a Cloudflare Worker or GitHub Actions. One email per run with per-endpoint status and the URL list, plus an escalated subject line when anything fails.
Update, July 2026. This guide was originally written for the Cloudflare Worker setup. These alerts then caught a real incident on asteroad.com: the Worker started returning 429 Too Many Requests on every run, a shared-IP rate limit no Worker-side change can fix. We moved our daily submission to GitHub Actions. The guide now covers both architectures: the Resend setup is shared, then one part for the Worker and one for GitHub Actions. One tip either way: make sure your inbox rules treat a non-200 status as a failure. Our first 429s looked like routine notifications until we read the status line.
The problem
Your IndexNow automation runs once a day. It fetches your sitemap, diffs it against a snapshot from yesterday, and submits anything new. When it works you see nothing. When it fails you also see nothing, until a week later when Bing still has not picked up a new post and you open the logs wondering why.
Both hosting options for this automation are pull-based when it comes to visibility. Cloudflare Workers give you Log Streams in the dashboard, but you have to open the dashboard and watch. GitHub Actions emails you on workflow failure, but only on workflow failure. A rejected IndexNow submission that your script handles gracefully (as it should, so the retry queue can do its job) never fails the workflow. Success reports with the URL list never arrive at all. For automation that runs once a day, you want push: the run tells you what happened, you never go looking.
Email is the simplest push channel. This guide adds Resend so every run produces a report: the HTTP status per submission, the full list of URLs submitted, and, if the run failed, the error. Failures use a distinct subject line so your inbox rules can route them to a priority label.
Why Resend
Resend is one fetch call with a JSON body, no SDK needed, 3,000 emails/month and 100/day free. Domain verification is DKIM + SPF + a Return-Path record that you add once. And because it's plain fetch, the identical call works from every environment this pipeline might live in: Cloudflare Workers (which have fetch and nothing else: no SMTP client, no mail command), GitHub Actions runners (Node 18+ has fetch built in), or anything else.
SendGrid, Mailgun, and Postmark all work the same way through their REST APIs. The snippets below are a thin JSON POST, so you can swap endpoints and the auth header if you already have an account elsewhere.
Part 1: Set up Resend (both architectures)
Step 1: Create a Resend account
Go to resend.com and sign up (Google or GitHub SSO is fine)
Skip the framework onboarding if it offers one, you are using the raw API
Step 2: Verify your sending domain
Resend will not send from a domain you have not verified. Verification is one-time: DKIM, SPF, and a Return-Path record. On Cloudflare it takes about five minutes.
In Resend go to Domains → Add Domain
Enter your domain, for example yourdomain.com
Resend shows 3 DNS records to add: MX (Return-Path), TXT (SPF), TXT (DKIM)
Open your DNS provider and add all 3 exactly as shown. On Cloudflare, leave the proxy status as DNS only (gray cloud) on each record.
Back in Resend click Verify DNS Records. Propagation usually takes a few minutes.
Once the domain turns green in Resend you can send from any address on it, for example alerts@yourdomain.com. The address does not need a mailbox, Resend does not require it to receive email.
Step 3: Create a Resend API key
In Resend go to API Keys → Create API Key
Name: IndexNow alerts
Permission: Sending access (full access is overkill for a job that only sends)
Domain: your verified domain
Click Add and copy the key. It starts with re_ and is shown only once.
With the key in hand, jump to the part that matches your architecture: Part 2 for the Cloudflare Worker, Part 3 for GitHub Actions.
Part 2: Alerts for the Cloudflare Worker
Step 4: Add the API key and email addresses to the Worker
In the Cloudflare dashboard open your IndexNow Worker → Settings → Variables and Secrets → Add variable
Type: Secret, variable name: RESEND_API_KEY, value: paste the key from Step 3
Click Deploy
While you are here, add two plain variables (not secrets, these are not sensitive):
ALERT_FROM: alerts@yourdomain.com, any address on your verified domain
ALERT_TO: you@yourinbox.com, where the alerts should land
Step 5: Replace the Worker code
Open the Worker in the editor, select all existing code, delete, and paste the following:
const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";
const ENDPOINTS = [
{ name: "Bing", url: "https://www.bing.com/indexnow" },
{ name: "Yandex", url: "https://yandex.com/indexnow" },
{ name: "Seznam", url: "https://search.seznam.cz/indexnow" },
{ name: "Naver", url: "https://searchadvisor.naver.com/indexnow" }
];
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
return new Response(env.INDEXNOW_KEY, {
headers: { "Content-Type": "text/plain" }
});
}
const result = await runWithAlerts(env, "manual");
return new Response(JSON.stringify(result, null, 2), {
headers: { "Content-Type": "application/json" }
});
},
async scheduled(event, env) {
await runWithAlerts(env, "cron");
}
};
async function runWithAlerts(env, trigger) {
const startedAt = new Date().toISOString();
try {
const result = await run(env);
result.trigger = trigger;
result.startedAt = startedAt;
await sendEmail(env, result, false);
return result;
} catch (err) {
await sendEmail(env, {
trigger,
startedAt,
error: err.message,
stack: err.stack
}, true);
throw err;
}
}
async function run(env) {
const sitemapRes = await fetch(SITEMAP_URL);
if (!sitemapRes.ok) {
throw new Error(`Sitemap fetch failed: ${sitemapRes.status}`);
}
const xml = await sitemapRes.text();
const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
.map(m => m[1].trim())
.filter(u => u.startsWith("https://"));
if (currentUrls.length === 0) {
throw new Error("No URLs found in sitemap");
}
const stored = await env.SITEMAP_KV.get(KV_KEY);
const previousUrls = stored ? JSON.parse(stored) : [];
const previousSet = new Set(previousUrls);
const currentSet = new Set(currentUrls);
const newUrls = currentUrls.filter(u => !previousSet.has(u));
const removedUrls = previousUrls.filter(u => !currentSet.has(u));
const changedUrls = [...newUrls, ...removedUrls];
const endpoints = [];
if (changedUrls.length > 0) {
for (const ep of ENDPOINTS) {
try {
const res = await fetch(ep.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: INDEXNOW_HOST,
key: env.INDEXNOW_KEY,
keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
urlList: changedUrls
})
});
endpoints.push({
name: ep.name,
url: ep.url,
status: res.status,
ok: res.ok,
body: (await res.text()).slice(0, 200)
});
} catch (err) {
endpoints.push({
name: ep.name,
url: ep.url,
status: 0,
ok: false,
body: err.message
});
}
}
}
const submitOk = changedUrls.length === 0 || endpoints.some(e => e.ok);
if (submitOk) {
await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));
}
return {
totalUrls: currentUrls.length,
newUrls,
removedUrls,
endpoints
};
}
async function sendEmail(env, payload, isError) {
const anyEndpointFailed =
!isError && payload.endpoints?.some(e => !e.ok);
const changes =
!isError && (payload.newUrls.length + payload.removedUrls.length);
let subject;
if (isError) {
subject = `FAILED: IndexNow Worker (${INDEXNOW_HOST})`;
} else if (anyEndpointFailed) {
subject = `PARTIAL: IndexNow Worker, ${changes || "no"} URLs submitted (${INDEXNOW_HOST})`;
} else {
subject = `OK: IndexNow Worker, ${
changes
? `${payload.newUrls.length} new / ${payload.removedUrls.length} removed`
: "nothing to submit"
} (${INDEXNOW_HOST})`;
}
const html = isError ? errorHtml(payload) : resultHtml(payload);
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: env.ALERT_FROM,
to: env.ALERT_TO,
subject,
html
})
});
if (!res.ok) {
console.error("Resend send failed", res.status, await res.text());
}
}
function resultHtml(r) {
const rows = r.endpoints.length
? r.endpoints.map(e => `
<tr>
<td style="padding:6px 10px;border-bottom:1px solid #eee;">${e.name}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;color:${e.ok ? "#0a7f3f" : "#b00020"};">${e.status || "network error"}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;font-family:monospace;font-size:12px;">${escapeHtml(e.body || "")}</td>
</tr>`).join("")
: `<tr><td colspan="3" style="padding:6px 10px;color:#888;">No submission, no URL changes since last run</td></tr>`;
const urlList =
r.newUrls.length + r.removedUrls.length
? `
<h3>Submitted URLs</h3>
${r.newUrls.length ? `<p><b>New (${r.newUrls.length})</b></p><ul>${r.newUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}
${r.removedUrls.length ? `<p><b>Removed (${r.removedUrls.length})</b></p><ul>${r.removedUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}`
: "";
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2>IndexNow Worker · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${r.trigger}</b> · Started: <b>${r.startedAt}</b> · Sitemap URLs: <b>${r.totalUrls}</b></p>
<h3>Endpoint status</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px;">
<thead><tr>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Endpoint</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Status</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Response</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
${urlList}
</div>`;
}
function errorHtml(f) {
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2 style="color:#b00020;">IndexNow Worker failed · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${f.trigger}</b> · Started: <b>${f.startedAt}</b></p>
<p><b>Error:</b> ${escapeHtml(f.error)}</p>
<pre style="background:#f6f6f6;padding:12px;border-radius:6px;font-size:12px;overflow:auto;">${escapeHtml(f.stack || "(no stack)")}</pre>
</div>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";
const ENDPOINTS = [
{ name: "Bing", url: "https://www.bing.com/indexnow" },
{ name: "Yandex", url: "https://yandex.com/indexnow" },
{ name: "Seznam", url: "https://search.seznam.cz/indexnow" },
{ name: "Naver", url: "https://searchadvisor.naver.com/indexnow" }
];
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
return new Response(env.INDEXNOW_KEY, {
headers: { "Content-Type": "text/plain" }
});
}
const result = await runWithAlerts(env, "manual");
return new Response(JSON.stringify(result, null, 2), {
headers: { "Content-Type": "application/json" }
});
},
async scheduled(event, env) {
await runWithAlerts(env, "cron");
}
};
async function runWithAlerts(env, trigger) {
const startedAt = new Date().toISOString();
try {
const result = await run(env);
result.trigger = trigger;
result.startedAt = startedAt;
await sendEmail(env, result, false);
return result;
} catch (err) {
await sendEmail(env, {
trigger,
startedAt,
error: err.message,
stack: err.stack
}, true);
throw err;
}
}
async function run(env) {
const sitemapRes = await fetch(SITEMAP_URL);
if (!sitemapRes.ok) {
throw new Error(`Sitemap fetch failed: ${sitemapRes.status}`);
}
const xml = await sitemapRes.text();
const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
.map(m => m[1].trim())
.filter(u => u.startsWith("https://"));
if (currentUrls.length === 0) {
throw new Error("No URLs found in sitemap");
}
const stored = await env.SITEMAP_KV.get(KV_KEY);
const previousUrls = stored ? JSON.parse(stored) : [];
const previousSet = new Set(previousUrls);
const currentSet = new Set(currentUrls);
const newUrls = currentUrls.filter(u => !previousSet.has(u));
const removedUrls = previousUrls.filter(u => !currentSet.has(u));
const changedUrls = [...newUrls, ...removedUrls];
const endpoints = [];
if (changedUrls.length > 0) {
for (const ep of ENDPOINTS) {
try {
const res = await fetch(ep.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: INDEXNOW_HOST,
key: env.INDEXNOW_KEY,
keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
urlList: changedUrls
})
});
endpoints.push({
name: ep.name,
url: ep.url,
status: res.status,
ok: res.ok,
body: (await res.text()).slice(0, 200)
});
} catch (err) {
endpoints.push({
name: ep.name,
url: ep.url,
status: 0,
ok: false,
body: err.message
});
}
}
}
const submitOk = changedUrls.length === 0 || endpoints.some(e => e.ok);
if (submitOk) {
await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));
}
return {
totalUrls: currentUrls.length,
newUrls,
removedUrls,
endpoints
};
}
async function sendEmail(env, payload, isError) {
const anyEndpointFailed =
!isError && payload.endpoints?.some(e => !e.ok);
const changes =
!isError && (payload.newUrls.length + payload.removedUrls.length);
let subject;
if (isError) {
subject = `FAILED: IndexNow Worker (${INDEXNOW_HOST})`;
} else if (anyEndpointFailed) {
subject = `PARTIAL: IndexNow Worker, ${changes || "no"} URLs submitted (${INDEXNOW_HOST})`;
} else {
subject = `OK: IndexNow Worker, ${
changes
? `${payload.newUrls.length} new / ${payload.removedUrls.length} removed`
: "nothing to submit"
} (${INDEXNOW_HOST})`;
}
const html = isError ? errorHtml(payload) : resultHtml(payload);
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: env.ALERT_FROM,
to: env.ALERT_TO,
subject,
html
})
});
if (!res.ok) {
console.error("Resend send failed", res.status, await res.text());
}
}
function resultHtml(r) {
const rows = r.endpoints.length
? r.endpoints.map(e => `
<tr>
<td style="padding:6px 10px;border-bottom:1px solid #eee;">${e.name}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;color:${e.ok ? "#0a7f3f" : "#b00020"};">${e.status || "network error"}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;font-family:monospace;font-size:12px;">${escapeHtml(e.body || "")}</td>
</tr>`).join("")
: `<tr><td colspan="3" style="padding:6px 10px;color:#888;">No submission, no URL changes since last run</td></tr>`;
const urlList =
r.newUrls.length + r.removedUrls.length
? `
<h3>Submitted URLs</h3>
${r.newUrls.length ? `<p><b>New (${r.newUrls.length})</b></p><ul>${r.newUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}
${r.removedUrls.length ? `<p><b>Removed (${r.removedUrls.length})</b></p><ul>${r.removedUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}`
: "";
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2>IndexNow Worker · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${r.trigger}</b> · Started: <b>${r.startedAt}</b> · Sitemap URLs: <b>${r.totalUrls}</b></p>
<h3>Endpoint status</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px;">
<thead><tr>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Endpoint</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Status</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Response</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
${urlList}
</div>`;
}
function errorHtml(f) {
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2 style="color:#b00020;">IndexNow Worker failed · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${f.trigger}</b> · Started: <b>${f.startedAt}</b></p>
<p><b>Error:</b> ${escapeHtml(f.error)}</p>
<pre style="background:#f6f6f6;padding:12px;border-radius:6px;font-size:12px;overflow:auto;">${escapeHtml(f.stack || "(no stack)")}</pre>
</div>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}const SITEMAP_URL = "https://yoursite.com/sitemap.xml";
const INDEXNOW_HOST = "yoursite.com";
const KV_KEY = "last_urls";
const ENDPOINTS = [
{ name: "Bing", url: "https://www.bing.com/indexnow" },
{ name: "Yandex", url: "https://yandex.com/indexnow" },
{ name: "Seznam", url: "https://search.seznam.cz/indexnow" },
{ name: "Naver", url: "https://searchadvisor.naver.com/indexnow" }
];
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === `/${env.INDEXNOW_KEY}.txt`) {
return new Response(env.INDEXNOW_KEY, {
headers: { "Content-Type": "text/plain" }
});
}
const result = await runWithAlerts(env, "manual");
return new Response(JSON.stringify(result, null, 2), {
headers: { "Content-Type": "application/json" }
});
},
async scheduled(event, env) {
await runWithAlerts(env, "cron");
}
};
async function runWithAlerts(env, trigger) {
const startedAt = new Date().toISOString();
try {
const result = await run(env);
result.trigger = trigger;
result.startedAt = startedAt;
await sendEmail(env, result, false);
return result;
} catch (err) {
await sendEmail(env, {
trigger,
startedAt,
error: err.message,
stack: err.stack
}, true);
throw err;
}
}
async function run(env) {
const sitemapRes = await fetch(SITEMAP_URL);
if (!sitemapRes.ok) {
throw new Error(`Sitemap fetch failed: ${sitemapRes.status}`);
}
const xml = await sitemapRes.text();
const currentUrls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)]
.map(m => m[1].trim())
.filter(u => u.startsWith("https://"));
if (currentUrls.length === 0) {
throw new Error("No URLs found in sitemap");
}
const stored = await env.SITEMAP_KV.get(KV_KEY);
const previousUrls = stored ? JSON.parse(stored) : [];
const previousSet = new Set(previousUrls);
const currentSet = new Set(currentUrls);
const newUrls = currentUrls.filter(u => !previousSet.has(u));
const removedUrls = previousUrls.filter(u => !currentSet.has(u));
const changedUrls = [...newUrls, ...removedUrls];
const endpoints = [];
if (changedUrls.length > 0) {
for (const ep of ENDPOINTS) {
try {
const res = await fetch(ep.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
host: INDEXNOW_HOST,
key: env.INDEXNOW_KEY,
keyLocation: `https://${INDEXNOW_HOST}/${env.INDEXNOW_KEY}.txt`,
urlList: changedUrls
})
});
endpoints.push({
name: ep.name,
url: ep.url,
status: res.status,
ok: res.ok,
body: (await res.text()).slice(0, 200)
});
} catch (err) {
endpoints.push({
name: ep.name,
url: ep.url,
status: 0,
ok: false,
body: err.message
});
}
}
}
const submitOk = changedUrls.length === 0 || endpoints.some(e => e.ok);
if (submitOk) {
await env.SITEMAP_KV.put(KV_KEY, JSON.stringify(currentUrls));
}
return {
totalUrls: currentUrls.length,
newUrls,
removedUrls,
endpoints
};
}
async function sendEmail(env, payload, isError) {
const anyEndpointFailed =
!isError && payload.endpoints?.some(e => !e.ok);
const changes =
!isError && (payload.newUrls.length + payload.removedUrls.length);
let subject;
if (isError) {
subject = `FAILED: IndexNow Worker (${INDEXNOW_HOST})`;
} else if (anyEndpointFailed) {
subject = `PARTIAL: IndexNow Worker, ${changes || "no"} URLs submitted (${INDEXNOW_HOST})`;
} else {
subject = `OK: IndexNow Worker, ${
changes
? `${payload.newUrls.length} new / ${payload.removedUrls.length} removed`
: "nothing to submit"
} (${INDEXNOW_HOST})`;
}
const html = isError ? errorHtml(payload) : resultHtml(payload);
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: env.ALERT_FROM,
to: env.ALERT_TO,
subject,
html
})
});
if (!res.ok) {
console.error("Resend send failed", res.status, await res.text());
}
}
function resultHtml(r) {
const rows = r.endpoints.length
? r.endpoints.map(e => `
<tr>
<td style="padding:6px 10px;border-bottom:1px solid #eee;">${e.name}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;color:${e.ok ? "#0a7f3f" : "#b00020"};">${e.status || "network error"}</td>
<td style="padding:6px 10px;border-bottom:1px solid #eee;font-family:monospace;font-size:12px;">${escapeHtml(e.body || "")}</td>
</tr>`).join("")
: `<tr><td colspan="3" style="padding:6px 10px;color:#888;">No submission, no URL changes since last run</td></tr>`;
const urlList =
r.newUrls.length + r.removedUrls.length
? `
<h3>Submitted URLs</h3>
${r.newUrls.length ? `<p><b>New (${r.newUrls.length})</b></p><ul>${r.newUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}
${r.removedUrls.length ? `<p><b>Removed (${r.removedUrls.length})</b></p><ul>${r.removedUrls.map(u => `<li>${escapeHtml(u)}</li>`).join("")}</ul>` : ""}`
: "";
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2>IndexNow Worker · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${r.trigger}</b> · Started: <b>${r.startedAt}</b> · Sitemap URLs: <b>${r.totalUrls}</b></p>
<h3>Endpoint status</h3>
<table style="border-collapse:collapse;width:100%;font-size:14px;">
<thead><tr>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Endpoint</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Status</th>
<th style="text-align:left;padding:6px 10px;border-bottom:2px solid #333;">Response</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
${urlList}
</div>`;
}
function errorHtml(f) {
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:640px;">
<h2 style="color:#b00020;">IndexNow Worker failed · ${INDEXNOW_HOST}</h2>
<p style="color:#555;">Trigger: <b>${f.trigger}</b> · Started: <b>${f.startedAt}</b></p>
<p><b>Error:</b> ${escapeHtml(f.error)}</p>
<pre style="background:#f6f6f6;padding:12px;border-radius:6px;font-size:12px;overflow:auto;">${escapeHtml(f.stack || "(no stack)")}</pre>
</div>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}Replace yoursite.com in the first two lines with your actual domain, matching what you set in the original Worker. Click Save and Deploy.
What changed from the original Worker
Four things:
Per-endpoint submission. The original Worker posted once to api.indexnow.org, which forwards to all partners. This version calls each engine directly, so Bing's response is separate from Yandex's, separate from Seznam's. If one partner is down the others still run and you get accurate per-endpoint status.
try/catch at both the run level and the per-endpoint level. A thrown error from run() is caught by runWithAlerts and escalated to a FAILED: email. An exception inside the submission loop (DNS failure, TLS error) is caught per endpoint and shown as a red row in the table without taking down the whole run.
sendEmail always executes. On success it sends a report. On any endpoint failure the subject becomes PARTIAL:. On a thrown error the subject becomes FAILED:. Every run produces exactly one email.
The snapshot only updates on an accepted submission. If every endpoint fails (for example, the 429 rate limit), the KV snapshot stays unchanged and the same URLs are resubmitted on the next run, instead of being silently marked as done. That silent marking is how the original code lost URLs.
Step 6: Test the Worker alerts
Open your Worker's workers.dev URL in a browser. The Worker runs the manual path and sends a report email. Check your inbox within about ten seconds.
To test the failure path, change SITEMAP_URL to something invalid, for example https://yoursite.com/sitemap-does-not-exist.xml, save, and open the workers.dev URL again. You should receive a FAILED: email with the error message Sitemap fetch failed: 404. Change SITEMAP_URL back.
To test the partial-failure path, change one endpoint's URL to something broken, for example https://www.bing.com/indexnowXX, save, open the workers.dev URL. You should get a PARTIAL: email where Bing shows a 404 row and the other three partners are green. Change it back.
Part 3: Alerts from GitHub Actions
If you run your IndexNow submission as a GitHub Actions workflow (the architecture from our 429 manual), the alert pattern is the same fetch to Resend. The script in that manual already includes it. You only need to wire up the configuration.
Why not just use GitHub's built-in notifications?
GitHub emails you when a workflow fails. That covers broken YAML and crashed scripts, but it misses the two things you want to know about a submission pipeline. First, a rejected IndexNow submission should not fail the workflow. The script handles it gracefully so its retry queue can resubmit tomorrow, which means GitHub sees a green run and stays silent. Second, GitHub never tells you what a successful run did: which URLs went out, which endpoint accepted them. Content reports need their own channel.
Wire up the secrets and variables
In the repo: Settings → Secrets and variables → Actions → New repository secret → RESEND_API_KEY, value from Step 3. GitHub masks secret values in workflow logs automatically.
In the workflow file, pass the addresses as plain env on the run step:
- name: Submit new/removed URLs
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
TO_EMAIL: you@yourinbox.com
FROM_EMAIL: alerts@yourdomain.com
run: node scripts/indexnow-submit.mjs - name: Submit new/removed URLs
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
TO_EMAIL: you@yourinbox.com
FROM_EMAIL: alerts@yourdomain.com
run: node scripts/indexnow-submit.mjs - name: Submit new/removed URLs
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
TO_EMAIL: you@yourinbox.com
FROM_EMAIL: alerts@yourdomain.com
run: node scripts/indexnow-submit.mjsThat's the whole setup. The script sends one summary email per run with a subject of IndexNow: OK — 3 new, 0 removed or IndexNow: FAILED — queued for retry, and stays silent when the sitemap didn't change. If RESEND_API_KEY or TO_EMAIL is unset, the script skips email sending entirely (by design, so you can run it before Resend is configured).
Adding the same alerts to any Actions script
If you're writing your own script rather than using ours, the sending function is a dozen lines. Node 18+ on GitHub runners has fetch built in:
async function sendAlert(subject, text) {
if (!process.env.RESEND_API_KEY || !process.env.TO_EMAIL) return;
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: process.env.FROM_EMAIL,
to: process.env.TO_EMAIL,
subject,
text
})
});
if (!res.ok) console.error(`Resend ${res.status}: ${await res.text()}`);
}async function sendAlert(subject, text) {
if (!process.env.RESEND_API_KEY || !process.env.TO_EMAIL) return;
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: process.env.FROM_EMAIL,
to: process.env.TO_EMAIL,
subject,
text
})
});
if (!res.ok) console.error(`Resend ${res.status}: ${await res.text()}`);
}async function sendAlert(subject, text) {
if (!process.env.RESEND_API_KEY || !process.env.TO_EMAIL) return;
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: process.env.FROM_EMAIL,
to: process.env.TO_EMAIL,
subject,
text
})
});
if (!res.ok) console.error(`Resend ${res.status}: ${await res.text()}`);
}The one rule worth copying from our production setup: put the outcome in the subject line (OK / PARTIAL / FAILED), never only in the body. Subjects are what inbox filters and half-asleep humans read.
Set up inbox filters
Outcome-prefixed alerting is only useful if your inbox knows what to do with each subject. In Gmail:
Open Settings → Filters and Blocked Addresses → Create a new filter
Filter: from:alerts@yourdomain.com FAILED
Click Create filter
Apply label IndexNow / Failed (create it), Never send to spam, Always mark as important, optionally Forward to your phone-SMS gateway
Repeat with PARTIAL for a medium-severity label, and OK for a low-noise label that skips the inbox. The word-match (rather than subject:FAILED) catches both subject conventions used in this guide: the Worker's FAILED: IndexNow Worker and the Actions script's IndexNow: FAILED. Same pattern works in Outlook and Fastmail rules.
How it works after setup
Every day, whichever architecture you run:
The schedule fires (Worker cron or Actions cron)
The run fetches the sitemap, diffs it against the snapshot, and submits changed URLs
One email arrives: a report with the URL list on success, an escalated subject on any failure
Failed submissions are retried on the next run. The snapshot/queue logic guarantees a failure costs you a day of latency, not the URLs
You get one email every day something changed. That is intentional: it turns the automation is silent when healthy into the automation is silent when the email is filtered away. If daily mail is too much, archive OK automatically with a filter rule, and the inbox only rings on PARTIAL and FAILED.
Cost
Resend free tier: 3,000 emails/month, 100/day. A daily run sends at most 365/year. Even with manual tests and the occasional retry you will not approach the daily limit.
Cloudflare Workers free tier: unchanged. The extra fetch to api.resend.com/emails counts as one subrequest, well under the 50-per-request limit. GitHub Actions free tier: unchanged. The email adds under a second to a run that takes ten.
Total added cost: zero.
Troubleshooting
Resend send failed 403 in the logs. The API key has no access to the From domain. In Resend open API Keys and confirm the key's domain field matches your From address. If you created the key with Domain = All, any verified domain works.
Resend send failed 422 with "The from address is not a verified domain". DNS did not propagate or the records do not match. In Resend open Domains, click your domain, and click Verify DNS Records again. Common cause on Cloudflare: one of the records is still proxied (orange cloud), set all 3 to DNS only.
Email lands in spam. Send yourself one test, open it, mark it Not Spam. With DKIM/SPF on the sending domain and one training interaction in Gmail, it usually stays in the inbox permanently.
Email arrives but the endpoint table is empty (Worker). The Worker had no changed URLs to submit so endpoints is empty. The table shows No submission, no URL changes since last run. This is the normal state between sitemap changes.
No email at all for a run. On the Worker: it threw before sendEmail could execute, usually a syntax error. Check Workers → Observability → Live Logs and confirm RESEND_API_KEY, ALERT_FROM, and ALERT_TO are set under Settings → Variables and Secrets. On GitHub Actions: remember the script deliberately sends nothing when the sitemap didn't change, and skips email entirely when RESEND_API_KEY or TO_EMAIL is missing. Check the workflow log for the run's JSON report to see which case you're in.
Alerting on silent automation is part of how we run infrastructure as a Bloomreach partner.
Read next
IndexNow on GitHub Actions: The Complete Auto-Submission Setup: the incident these alerts caught, and the full GitHub Actions pipeline this guide's Part 3 plugs into
How to Push New URLs to Bing and Yandex the Moment You Publish: the base Worker this guide adds alerts to
Inline Images in Framer CMS Render at 600px. Here's the Fix.: companion Framer fix for the same publishing pipeline. CSS override that gives inline images full column width on Framer CMS