Resend-based email alerts for the IndexNow Cloudflare Worker. One email per run with per-endpoint status and the URL list, plus an escalated subject line when anything throws.
The problem
The IndexNow Cloudflare Worker runs once a day at noon UTC. It fetches your sitemap, diffs it against the KV snapshot from yesterday, and submits anything new to IndexNow. 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.
Cloudflare Workers give you Log Streams in the dashboard, but log streams are pull-based: you have to open the dashboard and watch. For a Worker that runs once a day, you want push. The Worker tells you what happened, you never go looking.
Email is the simplest push channel. This guide adds Resend to the existing Worker so every run produces a report: the per-endpoint HTTP status from Bing, Yandex, Seznam, and Naver; the full list of URLs submitted; and, if the Worker threw, the error and stack trace. Failures use a distinct subject line so your inbox rules can route them to a priority label.
Why Resend
Cloudflare Workers run on V8 isolates. They have fetch and nothing else. No SMTP client, no mail command, no AWS SES SDK. To send an email from a Worker, you call an external HTTP API.
Resend is the friendliest option for this. 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.
SendGrid, Mailgun, and Postmark all work the same way through their REST APIs. The Worker snippet below is a thin JSON POST, so you can swap endpoints and the auth header if you already have an account elsewhere.
What you need
The IndexNow Cloudflare Worker already deployed
A domain where you can add DNS records (usually the same domain the Worker runs on)
An inbox where the alerts should land
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 Cloudflare → your domain → DNS → Records and add all 3 exactly as shown. Leave the proxy status as DNS only (grey 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 Worker
Permission: Sending access (full access is overkill for a Worker that only sends)
Domain: your verified domain
Click Add and copy the key. It starts with re_ and is shown only once.
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
});
}
}
}
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
});
}
}
}
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
});
}
}
}
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
Three 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.
Step 6: Test
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.
Step 7: Set up inbox filters
Two-subject 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 subject: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 subject PARTIAL for a medium-severity label, and OK for a low-noise label that skips the inbox. Same pattern works in Outlook and Fastmail rules.
How it works after setup
Every day at noon UTC:
Cron fires, scheduled(event, env) runs
runWithAlerts calls run, which fetches the sitemap, diffs against KV, and submits changed URLs to each IndexNow partner individually
sendEmail sends OK: with the per-endpoint table and URL list; if any partner errored the subject is PARTIAL:
If run threw, sendEmail sends FAILED: with the stack trace, and the Worker re-throws so the error still appears in Cloudflare logs
You get one email every day. That is intentional: it turns the Worker is silent when healthy into the Worker 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 cron sends 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 subrequest limit on the free plan.
Total added cost: zero.
Troubleshooting
Resend send failed 403 in the Worker logs. The API key has no access to the ALERT_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: one of the records is still proxied (orange cloud) in Cloudflare, 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. 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. The Worker threw before sendEmail could execute, usually a syntax error in the code. Check Workers → Observability → Live Logs for the last run, syntax errors appear before any fetch call runs. Also confirm RESEND_API_KEY, ALERT_FROM, and ALERT_TO are all set under Settings → Variables and Secrets.
Related manuals