The actual problem: email is the wrong inbox for forms
If your team lives in Microsoft Teams all day, routing contact form submissions to a personal Gmail or Outlook inbox is friction. Someone has to notice the email, screenshot it, paste it into Teams, then start a thread. By the time a response goes out, the lead has churned.
The fix is direct: post every new submission straight into a Teams channel. The whole team sees it, anyone can claim it, replies happen in a thread, and there's a permanent searchable record. Microsoft built incoming webhooks specifically for this — but the documentation is scattered across three deprecated formats and the new Workflows successor, so most tutorials are out of date.
This guide covers the path that works in 2026: incoming webhook (or Workflows endpoint), Adaptive Card v1.5 payload, optional @mentions, and retry logic that survives the inevitable 429. We'll show both the manual code path and the no-code option through splitforms, which sends the right payload by default.
If you want a more general primer on webhooks before diving in, read send form data to a webhook first — same plumbing, different destination. If Slack is your target instead of Teams, the sibling guide is send form submissions to Slack.
Step 1: Create the incoming webhook in your Teams channel
Teams webhooks are scoped per channel, not per team. Pick the channel that should receive submissions — usually #leads, #sales, or #support.
- Open Microsoft Teams. Navigate to the channel.
- Click the ••• menu on the channel name → Manage channel → Connectors. (In tenants where classic connectors are off, you'll see Workflows instead — the steps are nearly identical.)
- Search for Incoming Webhook and click Add, then Configure.
- Name it something descriptive like
splitforms — contact form. Upload an icon if you want (a 192x192 PNG works). - Click Create. Microsoft generates a long URL that starts with
https://[tenant].webhook.office.com/webhookb2/.... Copy it. You can't see it again later. - Click Done.
Treat this URL like a password. Anyone who has it can post any message into that channel — there's no auth on the request itself, just URL secrecy. Don't commit it to Git, don't expose it to the browser, and rotate it if someone leaves the team.
If your tenant is on Workflows only
Microsoft is migrating tenants from classic connectors to Power Automate Workflows. The flow is: ••• → Workflows → search Post to a channel when a webhook request is received → pick channel → save. The output URL ends in logic.azure.com instead of webhook.office.com, but the payload format is the same Adaptive Card JSON.
Adaptive Card v1.5: the JSON schema that actually renders
Teams supports two message formats. The legacy MessageCard format still works but Microsoft has marked it for sunset, and it can't render mentions, action sets, or column layouts properly on the new Teams desktop client. Use Adaptive Cards v1.5. Here's the minimum viable payload for a form submission:
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": null,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"size": "Medium",
"weight": "Bolder",
"text": "New contact form submission"
},
{
"type": "FactSet",
"facts": [
{ "title": "Name", "value": "Jane Cooper" },
{ "title": "Email", "value": "jane@example.com" },
{ "title": "Subject", "value": "Demo request" }
]
},
{
"type": "TextBlock",
"text": "Hi — interested in your enterprise plan. Can we book a 30 min call this week?",
"wrap": true
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "Reply in dashboard",
"url": "https://splitforms.com/dashboard/submissions"
}
]
}
}
]
}Three things people miss when hand-rolling this:
- The outer wrapper must be
{ "type": "message", "attachments": [...] }. Posting the AdaptiveCard object directly returns 200 but renders nothing. contentTypemust be exactlyapplication/vnd.microsoft.card.adaptive. Typos here silently fall back to a plain text block.versionshould be1.5for new code. Teams desktop supports up to 1.5 as of 2026-05; older mobile clients gracefully degrade.
The no-code path: splitforms → Teams
Hand-rolling the Adaptive Card JSON, signing the request, queueing retries, and storing the webhook URL securely is maybe two hours of work if you've done it before, and a full day if you haven't. The splitforms integration does it in three clicks:
- Sign up at splitforms.com/login. Free tier covers 1,000 submissions per month and webhooks are free (not paywalled like on Formspree).
- In the dashboard, open Integrations → Microsoft Teams. Paste the webhook URL you copied from Teams.
- Pick a template (Default, Minimal, Detailed with attachments, or Custom JSON for the Adaptive Card body). Save.
Drop the standard splitforms form into your site:
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>Every submission now triggers an email and a Teams card. No code changes required when you swap webhook URLs later, no secrets in your repo, no broken cron job to maintain. Framework-specific drop-ins are documented at /forms/nextjs, /forms/react, and /forms/astro.
MessageCard (legacy) vs Adaptive Card v1.5: pick correctly
You'll see both formats in Microsoft's docs and most third-party tutorials still use MessageCard. Don't copy them for new work. Here's the head-to-head:
| Capability | MessageCard (legacy) | Adaptive Card v1.5 |
|---|---|---|
| Renders in 2026 Teams | Yes (degraded) | Yes (native) |
| @mentions of users | No | Yes (via msteams.entities) |
| Action buttons | Limited (HttpPOST, OpenUri) | Full action set (OpenUrl, Submit, ToggleVisibility) |
| Column layout | No | Yes (ColumnSet) |
| Markdown | Partial | Full (in TextBlock) |
| Image / icon support | Section image | Image element, anywhere |
| Microsoft's 2026 stance | Deprecated, no new features | Active, recommended |
If you're maintaining an old MessageCard integration that still works, it'll keep working — Microsoft hasn't flipped the kill switch. But anything new should be Adaptive Card. splitforms generates Adaptive Card v1.5 by default.
@mentions: how to actually page a human
Posting a card is one thing — making sure someone reads it before the lead goes cold is another. Adaptive Cards support @mentions through a small extension block. Here's the pattern:
{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "New lead — <at>Alex</at> you're on call",
"wrap": true
}
],
"msteams": {
"entities": [
{
"type": "mention",
"text": "<at>Alex</at>",
"mentioned": {
"id": "alex@yourcompany.onmicrosoft.com",
"name": "Alex Chen"
}
}
]
}
}
}]
}The id field is the user's Azure AD UPN (User Principal Name) — usually their work email. The <at>Alex</at> token in the body is what Teams replaces with the visible mention pill, and it must match the text field in the entities array exactly. Get either wrong and the mention silently fails — you'll see the literal <at>Alex</at> text in the channel.
Channel-wide mentions (@channel, @team) don't work from incoming webhooks — those require a bot identity. If you need them, route the submission through a Power Automate flow that posts as a bot.
Error handling and retry logic
Teams webhooks fail. Not often, but enough that a naive integration will silently lose submissions. The four failure modes:
| Code | Meaning | Right response |
|---|---|---|
| 400 | Malformed JSON or wrong contentType | Do NOT retry. Log and alert. Fix the payload. |
| 404 | Webhook URL revoked or wrong | Do NOT retry. Alert someone to rotate. |
| 429 | Rate limited (4 req/sec or channel cap) | Retry with exponential backoff. Honor Retry-After header. |
| 5xx | Microsoft transient error | Retry up to 5 times with exponential backoff. |
If you're rolling your own, here's the minimum viable retry loop:
async function postToTeams(payload, attempt = 0) {
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) return;
if (res.status === 400 || res.status === 404) {
console.error("Permanent failure", res.status);
return; // don't retry
}
if (attempt >= 5) throw new Error("Max retries");
const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise(r => setTimeout(r, retryAfter * 1000));
return postToTeams(payload, attempt + 1);
}splitforms handles all of this server-side. Submissions are queued in durable storage, retried with jittered exponential backoff (2s, 4s, 8s, 16s, 32s), and surfaced in the dashboard as "Delivery: failed — last error: 429" if every retry burns. You don't lose the lead even if Teams is down for an hour.
Test the integration without spamming your channel
Before pointing your live contact form at the webhook, sanity-check the payload. Two tricks:
- Adaptive Card Designer. Open
https://adaptivecards.io/designer/, paste your card body, switch the host to "Microsoft Teams". You'll see exactly how it renders before any HTTP request goes out. - Test channel. Create a
#splitforms-testchannel, attach a separate webhook, and target that during dev. Once it looks right, swap to the production webhook URL.
To simulate a submission, splitforms has a "Send test payload" button on the Teams integration page that fires a sample submission through the same code path your real forms use. If that lands correctly, real forms will too. For broader troubleshooting on why a form fails end-to-end, see contact form not working.
Message templates worth stealing
The default splitforms template is fine for most teams, but a few patterns earn their keep. Pick the one that matches how your team actually triages submissions.
The lead-routing template
For sales teams that need to grab leads fast: lead name in the header, three facts (email, company, message preview), and two buttons — "Open in CRM" and "Reply via email". Mention the on-call rep's UPN so their phone buzzes. Don't include the entire submission body — too much vertical space pushes the next card off screen and people stop scrolling.
The support triage template
For support inboxes: short header ("New support request"), a single FactSet with severity (parsed from a dropdown field), and one action button linking to the submission in your help desk. Skip mentions — channel-wide alerts on every ticket train the team to mute the channel within a week.
The detailed-audit template
For low-volume but high-stakes forms (enterprise demo requests, partnership inquiries): every field as a fact row, IP and country in a smaller container at the bottom, and a copy-paste-ready summary in a code block. People who need this template usually only get one or two submissions per day, so the verbosity is fine.
splitforms' dashboard ships all three as one-click presets. You can also drop in raw Adaptive Card JSON and use field tokens like {{ name }} or {{ message }} for full control.
Security: don't leak the webhook URL
Teams incoming webhooks have no request authentication. The URL is the credential. Anyone who has it can post anything to your channel — phishing messages, spam, fake leads. A few rules:
- Never put the webhook URL in client-side code. Browser-visible JavaScript means the URL is public. Always proxy through a server.
- Never commit it to Git. Use environment variables (
TEAMS_WEBHOOK_URL) or a secrets manager. Add it to.gitignore-tracked files like.env.local. - Rotate it when staff leaves. Anyone who saw the URL still has it. Go to the channel connector, delete the existing webhook, create a new one, update splitforms.
- Use one webhook per source. Don't reuse the same Teams webhook for your CRM, your alerts, and your form. If one leaks you have to rotate all of them.
With splitforms, the webhook URL is stored encrypted in our database and never appears in your form HTML, so the browser-side leak vector is gone by default. We also surface a "last delivered" timestamp per integration so you can spot a silently failing webhook before someone notices submissions stopped showing up in chat.
Next steps
- Need Slack instead, or both? Send form submissions to Slack — same pattern, slightly different payload.
- Want the underlying primitives? Send form data to a webhook covers the generic flow.
- Comparing form backends? Best free form backend services 2026 ranks the top options for 2026.
- Migrating from Formspree (which paywalls webhooks)? Migrate from Formspree to splitforms in 5 minutes.
- Reference: /docs, /api-reference, /faq.
- Browse more: splitforms blog, or grab a free HTML contact form wired up out of the box.
- Ready to ship? Create an access key — 1,000 submissions free, webhooks included.
FAQ
Are Microsoft Teams incoming webhooks being retired?
Microsoft has announced that classic Office 365 connector incoming webhooks are deprecated in favor of Workflows (Power Automate) with the equivalent "Post to a channel when a webhook request is received" trigger. The endpoint format changes, but the request body (Adaptive Card JSON) is identical. If you set up a brand new integration in 2026, use the Workflows path — it's drop-in compatible with the same Adaptive Card payload splitforms sends.
Why does my Adaptive Card render as a plain text block?
Three common causes: (1) you posted a raw object instead of wrapping it in `attachments[0]` with the correct `contentType`; (2) your `$schema` URL is wrong or your `version` is higher than what Teams supports (stick to 1.4 or 1.5); (3) you sent the card to a Workflow that's set to "text only" mode. Open the Teams Adaptive Card Designer, paste your JSON, and confirm it renders there before blaming the webhook.
Can I @mention a real Teams user from an incoming webhook?
Yes, but only if you use Adaptive Cards (not the legacy MessageCard format) and you include both an `msteams.entities` block with the user's AAD object ID and a matching `<at>name</at>` token in the card body. Channel mentions and "@team" only work from bot accounts, not webhooks. For an oncall rotation, mention the on-call user's UPN — splitforms can template this dynamically per submission.
How is this different from Power Automate flows?
Power Automate flows are the modern Microsoft path for the same outcome: a webhook trigger fires, the flow posts a card to a channel. They're slightly slower (cold start can add 2-3 seconds) but more flexible — you can route by form field, branch, call Graph API, etc. splitforms talks to both. If you need conditional routing, build the flow; if you just need "new submission → card in channel", incoming webhook is faster and simpler.
What's the rate limit on Teams incoming webhooks?
Microsoft documents 4 requests per second per webhook, and the channel itself has a soft cap of roughly 30 messages per second across all sources. For form submissions you'll never hit this — even a viral landing page rarely sees 4 submissions per second sustained. If you ever do, splitforms' queue absorbs the burst and trickles them out at safe pace. You'll see a 429 response only if you misuse the webhook from custom code without backoff.
Can I post submissions to a private channel?
Yes. Incoming webhooks are scoped per channel, not per team — when you add the webhook connector, you pick the exact channel (private or public). The webhook URL only posts to that one channel. If you need to post to multiple channels, create one webhook per channel and configure two webhook destinations in splitforms. Each gets its own retry budget.
Will action buttons in the card actually do anything?
`Action.OpenUrl` always works — it opens a link in the user's default browser (great for "Reply in dashboard" or "Open in CRM"). `Action.Submit` only works if the channel has a bot installed that can receive the submit payload; from a plain incoming webhook there's nothing listening to the submit, so the button will fail silently. Stick to `Action.OpenUrl` unless you've built a bot.
How do I keep the webhook URL out of my Git repo?
Don't put it in code at all. With splitforms you paste the webhook URL once into the dashboard integration page — it lives in our database, encrypted at rest, and your form HTML never sees it. If you're rolling your own backend instead, store it in an environment variable (`TEAMS_WEBHOOK_URL`) and reference it server-side. Never expose a Teams webhook URL to the browser; anyone with the URL can post anything to your channel.