Routing Form Submissions to Slack, Email and Your CRM
How to route a form to Slack, email and your CRM without losing leads: async fan-out, Block Kit payloads, DMARC-safe email, idempotency keys and retries.
A client called us about a lead that went cold. Their contact form said “Thanks, we’ll be in touch”, the submission showed up in nobody’s inbox, and the Slack channel they actually watch had been silent for eleven days. The cause was boring: someone archived the private channel the incoming webhook was bound to, Slack started returning 404 channelnotfound, and the form handler was firing the request without ever reading the response.
That’s the real problem with sending a form to Slack, email and a CRM at once. The hard part isn’t the integrations. It’s that you’ve turned one HTTP request into three or four, each with its own failure mode, and most implementations treat “I called fetch” as “it was delivered”.
Here’s how we build fan-out now, after enough 2am incidents to have opinions about it.
- Accept and acknowledge the submission first, persist it, then fan out asynchronously. A Slack or CRM outage should never turn into a 500 on the user’s form.
- Slack Incoming Webhooks allow roughly one message per second per webhook, cap at 50 blocks, and truncate section text at 3000 characters. Design the message for that, not for a pretty mockup.
- Never put a user-supplied email in the
Fromheader. Send from your own authenticated domain and setReply-Toto the submitter so SPF, DKIM and DMARC alignment all hold. - CRM pushes need an idempotency key and a dedupe rule based on lowercased, trimmed email. Double submits are normal, duplicate contacts are a support ticket.
- Retry Slack and CRM calls with exponential backoff on 429 and 5xx only, give up after four attempts, and write the failure somewhere a human will see it the same day.
One accept, many deliveries
The pattern that keeps failing is the synchronous chain: validate, POST to Slack, send email, POST to HubSpot, return 200. Each step adds latency to the user’s wait, and any one of them failing takes the whole thing down. We’ve seen a form sit at “Sending…” for 14 seconds because a CRM was having a slow morning.
Split it in two. The ingest step validates, writes the submission to durable storage (a database row, a queue message, even a Durable Object or an S3 object with a UUID key), and returns immediately. Everything after that is delivery. Delivery is allowed to be slow, retried, or partially failed.
// Cloudflare Worker: accept fast, deliver after the response is sent
export default {
async fetch(request, env, ctx) {
const form = await request.formData();
const submission = {
id: crypto.randomUUID(), // doubles as the idempotency key
receivedAt: new Date().toISOString(),
name: (form.get('name') || '').toString().trim(),
email: (form.get('email') || '').toString().trim().toLowerCase(),
message: (form.get('message') || '').toString().trim(),
utmsource: (form.get('utmsource') || '').toString(),
pageUri: request.headers.get('referer') || ''
};
if (!submission.email.includes('@') || submission.message.length < 10) {
return Response.json({ ok: false, error: 'invalid' }, { status: 422 });
}
// Durable first. If every downstream dies, the lead still exists.
await env.SUBMISSIONS.put(sub:${submission.id}, JSON.stringify(submission));
// waitUntil keeps the worker alive past the response for the fan-out
ctx.waitUntil(deliver(submission, env));
return Response.json({ ok: true, id: submission.id }, { status: 202 });
}
};
async function deliver(sub, env) {
const results = await Promise.allSettled([
postToSlack(sub, env),
sendEmail(sub, env),
pushToCrm(sub, env)
]);
// Record per-channel outcome so you can answer "did the CRM get it?"
const status = results.map((r, i) =>
({ channel: ['slack', 'email', 'crm'][i], ok: r.status === 'fulfilled',
error: r.status === 'rejected' ? String(r.reason).slice(0, 200) : null }));
await env.SUBMISSIONS.put(status:${sub.id}, JSON.stringify(status));
}
Promise.allSettled matters here, not Promise.all. With all, a Slack 429 cancels your awareness of whether the CRM succeeded. The trade-off with waitUntil is that you get at most around 30 seconds of CPU-adjacent lifetime on Workers, which is plenty for three HTTP calls but not for a retry ladder with long sleeps. Once you need retries measured in minutes, move to a real queue.

Sending a form to Slack so people actually read it
Most form-to-Slack integrations dump a wall of key: value into a single text message. In a channel with 40 notifications a day, that’s noise, and noise gets muted.
Use Block Kit. Lead with the thing that determines action (the message body, the budget, the plan tier), and put metadata in a context block at the bottom where it’s visually quiet. Add a link back to the submission in your own admin so the channel is an index, not an archive.
async function postToSlack(sub, env) {
const payload = {
// Fallback text shows in the notification banner and on mobile push
text: New enquiry from ${sub.name || sub.email},
blocks: [
{
type: 'section',
text: { type: 'mrkdwn',
// Slack truncates section text at 3000 chars. Trim before they do.
text: New enquiry from ${sub.name || 'Unknown'}\n${sub.message.slice(0, 2500)} }
},
{
type: 'actions',
elements: [
{ type: 'button', text: { type: 'plain_text', text: 'Reply by email' },
url: mailto:${sub.email}?subject=Re:%20your%20enquiry },
{ type: 'button', text: { type: 'plain_text', text: 'Open in CRM' },
url: https://app.hubspot.com/contacts/${env.HS_PORTAL}/objects/0-1/views/all/list }
]
},
{
type: 'context',
elements: [{ type: 'mrkdwn',
text: ${sub.email} | ${sub.utm_source || 'direct'} | <${sub.pageUri}|source page> | id \${sub.id}\`` }]
}
]
};
const res = await fetch(env.SLACKWEBHOOKURL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
// Incoming webhooks return "ok" as plain text, not JSON. Check the body.
const body = await res.text();
if (!res.ok || body !== 'ok') throw new Error(slack ${res.status} ${body});
}
Three constraints worth knowing before you design the message. Incoming webhooks are rate limited to about one message per second per webhook, with short bursts tolerated, so a bot run against your form will start eating 429s fast. A message can hold 50 blocks maximum. A webhook URL is permanently bound to one channel, which is exactly why the archived-channel failure is silent unless you read the response body.
If routing depends on the submission (enterprise leads to one channel, support to another), don’t create six webhooks. Use a bot token with chat.postMessage and pass channel at call time. It’s a slightly heavier setup, but it gives you thread replies, reactions as a triage signal, and real error codes. It also survives channel renames.
Email notifications that actually arrive
Email is the channel everyone assumes is solved. It isn’t. The single most common mistake we fix on inherited projects is a From header set to the submitter’s address so that “reply” works. That breaks SPF alignment, fails DMARC on any domain with a p=reject policy, and your notifications land in spam or get rejected outright. Gmail and Yahoo have been enforcing authentication requirements on bulk senders since early 2024, and tolerance for unaligned mail has only tightened since.
Send from an address on a domain you control and authenticate. Put the submitter in Reply-To.
async function sendEmail(sub, env) {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
authorization: Bearer ${env.RESENDAPIKEY},
'content-type': 'application/json',
// Resend honours this for at-least-once safety on retries
'Idempotency-Key': email-${sub.id}
},
body: JSON.stringify({
from: 'Website Forms <[email protected]>', // aligned with your DKIM
to: ['[email protected]'],
reply_to: sub.email, // reply goes to the human
subject: Enquiry: ${sub.name || sub.email},
text: ${sub.message}\n\n--\n${sub.email}\n${sub.pageUri}\nID: ${sub.id}
})
});
if (!res.ok) throw new Error(email ${res.status} ${await res.text()});
}
Plain text first, HTML only if someone genuinely needs it. Notification emails are read in a preview pane in under three seconds. And escape anything you do render as HTML: submission bodies are untrusted input, and an injected <img> tracking pixel in your internal lead mail is a worse privacy problem than it sounds.
Form to CRM: mapping, consent and dedupe
The form-to-CRM leg is where the data model fights back. Your form has message; the CRM has a Note object attached to a Contact that may or may not exist, possibly owned by a Deal in a specific pipeline stage. Decide the shape before you write the call.
Four rules we apply on every build:
- Dedupe on normalised email. Lowercase, trim, and strip the
+tagonly if the business agrees thatsam+test@andsam@are the same person. They usually are, but let them decide. - Send an idempotency key. HubSpot, Pipedrive and Salesforce all behave better with one, and double-clicks on submit buttons are far more common than your analytics suggest.
- Capture consent as data, not as an assumption. Store the exact checkbox text shown, the timestamp and the IP. If you ever have to answer a GDPR complaint, “the user ticked a box” is not evidence. The string they agreed to is.
- Map UTM parameters on the client, not the server. Referrer headers lie and get stripped. Read them from the URL on landing, persist to
sessionStorage, write into hidden fields.
async function pushToCrm(sub, env) {
const url = https://api.hsforms.com/submissions/v3/integration/submit/${env.HSPORTAL}/${env.HSFORM_GUID};
const body = {
submittedAt: Date.parse(sub.receivedAt),
fields: [
{ objectTypeId: '0-1', name: 'email', value: sub.email },
{ objectTypeId: '0-1', name: 'firstname', value: sub.name.split(' ')[0] || '' },
{ objectTypeId: '0-1', name: 'message', value: sub.message }
],
context: { pageUri: sub.pageUri, pageName: 'Contact' },
legalConsentOptions: {
consent: {
consentToProcess: true,
// Store the literal text the user saw, not a paraphrase of it
text: 'I agree to be contacted about my enquiry.'
}
}
};
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(crm ${res.status} ${await res.text()});
}
The HubSpot forms endpoint is deliberately the one we reach for first because it handles contact creation and dedupe server-side. Going straight at the CRM objects API gives you more control and considerably more work: you own the search-then-create-or-update dance, and you own the race condition when two submissions arrive 200ms apart.
Retries, idempotency and the dead letter list
Retry on 429 and 5xx. Do not retry on 4xx other than 429. A 400 means your payload is wrong, and sending it four more times just wastes quota and fills logs.
async function withRetry(fn, { attempts = 4, base = 400 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (err) {
lastErr = err;
const status = Number(String(err.message).match(/\b(4\d\d|5\d\d)\b/)?.[1]);
// 400, 401, 403, 404, 422 are permanent. Stop immediately.
if (status && status < 500 && status !== 429) throw err;
// Full jitter: avoids synchronised retries when a provider recovers
const delay = Math.random() base 2 ** i;
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
Four attempts with a 400ms base gives you roughly 0 to 3.2 seconds of total backoff, which clears most transient blips without blowing a serverless time limit. Anything that still fails goes on a dead letter list. The dead letter list needs a human owner. We route ours into a dedicated Slack channel with the raw payload attached, because a failure queue nobody looks at is just a slower way to lose the lead.
One more thing, and it applies to every channel: webhooks are fire-and-forget. If you need the browser to know the delivery outcome, you need a different channel entirely. We wrote about picking between server-sent events, WebSockets and webhooks if your form needs live status rather than a 202.
What to monitor, and what to ignore
Three numbers tell you whether the pipeline is healthy. Delivery success rate per channel over 24 hours, which should sit at 100 percent and is worth alerting on at the first failure, not the tenth. Time from receivedAt to CRM acknowledgement, which should be under two seconds with any upward creep serving as an early warning. And submission volume compared with last week, because a sudden drop to zero usually means a JavaScript error on the form, not a quiet week.
Ignore open rates on internal notification mail. Ignore Slack reactions as a delivery metric. Both drift for human reasons unrelated to whether your pipeline works.
A synthetic submission every 15 minutes from an uptime checker, tagged so it’s filtered out of the CRM, catches the archived-channel class of failure before a real lead does. It costs almost nothing and it’s the single highest-value thing on this list. If you want to go further upstream and understand why people abandon before they even submit, field-by-field drop-off tracking is a separate problem worth solving separately.
Build this or buy it
Write the fan-out yourself when routing logic is genuinely conditional (enterprise leads to a different pipeline, regional routing, scoring before CRM insert), when you need the submission stored in your own database anyway, or when compliance requires data residency you control.
Don’t write it for a five-page marketing site. The maintenance is small but never zero: secrets rotate, Slack deprecates a block type, the CRM changes a field ID, someone needs spam filtering. For static sites and templates, a hosted endpoint like WebForms handles the accept-and-fan-out shape directly, posting the same submission to email, Slack, Telegram, Zapier and arbitrary webhooks without you running anything. That’s the right default for most static and client builds, and you can always move to a custom Worker the day routing gets conditional.
Whichever way you go, add spam handling before you go live rather than after. Fan-out multiplies the cost of every junk submission by the number of destinations, and a bot run that fills your CRM with 900 fake contacts is genuinely expensive to undo. Our rundown of what still works against contact form spam covers the current state of it.
Frequently Asked Questions
Should I use a Slack Incoming Webhook or a bot token?
Use an Incoming Webhook if every submission goes to one fixed channel and you never need to update the message. Use a bot token with chat.postMessage as soon as you need conditional routing, thread replies, message updates, or proper JSON error codes. Bot tokens also survive channel renames, where a webhook bound to an archived channel silently 404s.
How do I stop duplicate CRM contacts from double form submissions?
Generate a UUID per submission server-side and pass it as an idempotency key on the CRM request. Also disable the submit button on first click and normalise the email (lowercase and trim) before any dedupe comparison. Most CRMs will merge on matching email automatically, but only if the casing and whitespace match exactly.
Why do my form notifications go to spam?
Almost always because the From address doesn’t align with the domain that signed the message. Send from your own domain with SPF and DKIM configured at your sending provider, set a DMARC record, and put the submitter’s address in Reply-To instead of From. Check the received headers in a test message for dkim=pass and spf=pass with matching domains.
What happens if the CRM is down when someone submits?
Nothing the user sees, if you’ve structured it correctly. The submission is persisted before any fan-out, you return 202 immediately, and the CRM push retries with backoff and then lands on a dead letter list. The lead exists in your storage regardless, so the worst case is a manual replay rather than a lost enquiry.
Can I do all of this without a backend?
Yes, for the common case. A hosted form endpoint accepts the POST from a static HTML form and fans out to email, Slack and a Zapier or webhook destination that feeds your CRM. You lose conditional routing and your own copy of the data, so if either of those matters, run a small Worker or serverless function instead.
Pick one thing from this to do today: read the response body on your Slack call and throw if it isn’t ok. That’s four lines, and it’s the difference between knowing your pipeline is broken and finding out eleven days later from a client. Everything else here (the queue, the retries, the dead letter list) builds on the simple fact that you can’t recover from a failure you never detected.


