# Send form submissions to Microsoft Teams

> Post every Ikiform submission to a Teams channel as an Adaptive Card, using the Workflows app and a small relay that verifies the signature.

Published 2026-10-23 · Guide, Microsoft Teams · https://ikiform.com/blog/form-submissions-to-microsoft-teams

If your team lives in Microsoft Teams, new leads and requests belong in a channel there. This guide posts each Ikiform submission to a channel as an Adaptive Card, with one row per field.

Diagram: How a submission gets from your form to Teams

1. A visitor submits your form, and it posts to Ikiform.
2. Ikiform stores the submission and sends a signed webhook to your relay.
3. The relay checks the signature, builds an Adaptive Card and posts it to the Teams workflow.
4. Teams answers the relay, and the relay answers Ikiform.

Ikiform signs the delivery. The relay checks it, builds an Adaptive Card, and posts it to a Teams workflow.

## Office 365 connectors are gone

Older guides use an **Incoming Webhook** connector with a `webhook.office.com` URL. Microsoft retired Office 365 connectors in Teams, and they stopped working in May 2026. Workflow URLs on `logic.azure.com` stopped working too. Today the way in is a webhook from the **Workflows** app, which runs on Power Automate.

## Why there's a relay

The Workflows template expects a message with an Adaptive Card in its `attachments`. Ikiform sends a submission event shaped as `{ "type": ..., "data": ... }`, which the template can't turn into a card.

> Ikiform sends its own JSON shape to every endpoint and can't send a custom or templated body. Teams expects its own shape, so pasting the Teams URL straight into Ikiform doesn't work. A small relay in between verifies the signature and reformats the message.

If you'd rather not deploy code, [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md), [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) and [Make](https://ikiform.com/blog/form-submissions-to-make.md) can post to Teams with their own Microsoft Teams actions. If you know Power Automate well, you can also build your own flow that parses Ikiform's JSON; it won't check the signature, though.

## Before you start

- An Ikiform form that already receives submissions.
- A Microsoft 365 account with Teams and permission to add workflows to the channel.
- Somewhere to run the relay: a Cloudflare account with a domain on it, or a Next.js app on Vercel or any other host.

## What Ikiform sends

Every delivery is a `POST` with a JSON body. The body has the same shape for every endpoint, and you can't change it. A submission looks like this:

`submission.created`:

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-23T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-23T12:00:00.000Z",
    "truncated": false
  }
}
```

The form's values are in `data.fields`, keyed by the input `name`. A test from the dashboard sends an `endpoint.test` event instead, with `data.endpoint_id` and `data.message` and no fields. The full reference is on [Webhooks](https://ikiform.com/docs/webhooks.md#payloads).

## Set it up

### Step 1: Create a Teams workflow webhook

1. In Teams, open the channel, choose **More options** (**⋯**) next to its name, then **Workflows**.
2. Pick the template **Send webhook alerts to a channel**.
3. Name the workflow, sign in if asked, then pick the team and channel.
4. Save the workflow and copy the webhook URL it shows.

The URL is long, often over 255 characters, and its host ends in `environment.api.powerplatform.com`. Check it works before going further:

`Terminal`:

```bash
curl -X POST "$TEAMS_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","contentUrl":null,"content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","text":"Hello from the terminal"}]}}]}'
```

A card shows up in the channel, posted by the Workflows bot. Treat the URL as a secret: with the default settings, anyone who has it can post to that channel.

> Keep the trigger's **Who can trigger the flow** setting on **Anyone**. The relay can't sign in to Microsoft, and the URL stays private inside the relay.

### Step 2: Write the relay

Pick the platform you already use. Both versions verify the signature with `@ikiform/sdk`, build the card, and turn Teams errors into answers Ikiform retries.

**Cloudflare Worker** (`src/index.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

interface Env {
  IKIFORM_WEBHOOK_SECRET: string;
  TEAMS_WEBHOOK_URL: string;
}

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

function clip(text: string, max: number) {
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}

function bytes(text: string) {
  return new TextEncoder().encode(text).length;
}

function card(body: unknown[]) {
  return {
    type: "message",
    attachments: [
      {
        contentType: "application/vnd.microsoft.card.adaptive",
        contentUrl: null,
        content: {
          $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
          type: "AdaptiveCard",
          version: "1.4",
          body,
        },
      },
    ],
  };
}

function toTeams(event: WebhookEvent) {
  if (event.type === "endpoint.test") {
    return card([{ type: "TextBlock", text: "Ikiform test event received. Submissions will appear here.", wrap: true }]);
  }
  const { form, fields, submission_id, truncated } = event.data;
  const facts: { title: string; value: string }[] = [];
  let budget = 20_000;
  for (const [name, value] of Object.entries(fields)) {
    const fact = { title: clip(name, 100), value: clip(valueText(value), 1000) };
    const size = bytes(fact.title) + bytes(fact.value);
    if (size > budget) break;
    budget -= size;
    facts.push(fact);
  }
  const more = Object.keys(fields).length - facts.length;
  return card([
    { type: "TextBlock", text: clip(`New submission: ${form.name}`, 200), weight: "Bolder", size: "Medium", wrap: true },
    { type: "FactSet", facts },
    ...(more > 0 ? [{ type: "TextBlock", text: `${more} more fields in Ikiform.`, wrap: true }] : []),
    {
      type: "TextBlock",
      text: truncated ? "Too large to show here. Open it in Ikiform." : submission_id,
      isSubtle: true,
      size: "Small",
      wrap: true,
    },
  ]);
}

export default {
  async fetch(request: Request, env: Env) {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const payload = await request.text();
    let event: WebhookEvent;
    try {
      event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
      throw error;
    }
    const response = await fetch(env.TEAMS_WEBHOOK_URL, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(toTeams(event)),
    });
    if (response.status === 429) {
      const retryAfter = response.headers.get("retry-after") ?? "30";
      return new Response("Teams rate limit", { status: 503, headers: { "retry-after": retryAfter } });
    }
    if (!response.ok) return new Response(`Teams answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  },
};
```

**Next.js** (`app/api/ikiform/teams/route.ts`)

```ts
import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function valueText(value: unknown): string {
  if (value === null || value === undefined || value === "") return "(empty)";
  if (Array.isArray(value)) return value.map(valueText).join(", ");
  if (typeof value === "object") return JSON.stringify(value);
  return String(value);
}

function clip(text: string, max: number) {
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}

function bytes(text: string) {
  return new TextEncoder().encode(text).length;
}

function card(body: unknown[]) {
  return {
    type: "message",
    attachments: [
      {
        contentType: "application/vnd.microsoft.card.adaptive",
        contentUrl: null,
        content: {
          $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
          type: "AdaptiveCard",
          version: "1.4",
          body,
        },
      },
    ],
  };
}

function toTeams(event: WebhookEvent) {
  if (event.type === "endpoint.test") {
    return card([{ type: "TextBlock", text: "Ikiform test event received. Submissions will appear here.", wrap: true }]);
  }
  const { form, fields, submission_id, truncated } = event.data;
  const facts: { title: string; value: string }[] = [];
  let budget = 20_000;
  for (const [name, value] of Object.entries(fields)) {
    const fact = { title: clip(name, 100), value: clip(valueText(value), 1000) };
    const size = bytes(fact.title) + bytes(fact.value);
    if (size > budget) break;
    budget -= size;
    facts.push(fact);
  }
  const more = Object.keys(fields).length - facts.length;
  return card([
    { type: "TextBlock", text: clip(`New submission: ${form.name}`, 200), weight: "Bolder", size: "Medium", wrap: true },
    { type: "FactSet", facts },
    ...(more > 0 ? [{ type: "TextBlock", text: `${more} more fields in Ikiform.`, wrap: true }] : []),
    {
      type: "TextBlock",
      text: truncated ? "Too large to show here. Open it in Ikiform." : submission_id,
      isSubtle: true,
      size: "Small",
      wrap: true,
    },
  ]);
}

export async function POST(request: Request) {
  const payload = await request.text();
  let event: WebhookEvent;
  try {
    event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  const response = await fetch(process.env.TEAMS_WEBHOOK_URL!, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(toTeams(event)),
  });
  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "30";
    return new Response("Teams rate limit", { status: 503, headers: { "retry-after": retryAfter } });
  }
  if (!response.ok) return new Response(`Teams answered ${response.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Diagram: How the relay maps an Ikiform submission to Adaptive Card

1. form.name: Contact becomes TextBlock: New submission: Contact.
2. fields.name: Ada becomes FactSet fact: name / Ada.
3. fields.email: ada@example.com becomes FactSet fact: email / ada@example.com.
4. fields.message: Hello becomes FactSet fact: message / Hello.
5. submission_id becomes small subtle TextBlock: sub_….

The card is Adaptive Card 1.4, wrapped in a message attachment.

For the sample submission above, the relay posts this to the workflow:

`Teams message`:

```json
{
  "type": "message",
  "attachments": [
    {
      "contentType": "application/vnd.microsoft.card.adaptive",
      "contentUrl": null,
      "content": {
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "type": "AdaptiveCard",
        "version": "1.4",
        "body": [
          { "type": "TextBlock", "text": "New submission: Contact", "weight": "Bolder", "size": "Medium", "wrap": true },
          {
            "type": "FactSet",
            "facts": [
              { "title": "name", "value": "Ada" },
              { "title": "email", "value": "ada@example.com" },
              { "title": "message", "value": "Hello" }
            ]
          },
          { "type": "TextBlock", "text": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3", "isSubtle": true, "size": "Small", "wrap": true }
        ]
      }
    }
  ]
}
```

- Teams renders Adaptive Cards up to version 1.6. The relay uses 1.4, which works on desktop, web and mobile.
- A Teams message can be about 28 KB. The relay cuts long values and stops adding fields at about 20 KB, then says how many it left out.
- A `429` becomes a `503` with the same `Retry-After`, so Ikiform waits before it tries again. Any other error becomes a `502`, which Ikiform retries.

### Step 3: Deploy it

### Cloudflare Worker

`Terminal`:

```bash
mkdir ikiform-teams && cd ikiform-teams
npm init -y
npm install @ikiform/sdk
npm install -D wrangler
```

Save the Worker code as `src/index.ts`, then set the route in `wrangler.jsonc`:

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ikiform-teams",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }]
}
```

> Ikiform refuses `workers.dev` URLs, so the Worker needs a hostname on a domain you have on Cloudflare, like `hooks.example.com`. The `custom_domain` route creates the DNS record and certificate for you on deploy.

Add both secrets and deploy:

`Terminal`:

```bash
npx wrangler secret put IKIFORM_WEBHOOK_SECRET
npx wrangler secret put TEAMS_WEBHOOK_URL
npx wrangler deploy
```

Your relay URL is `https://hooks.example.com/`.

### Next.js

Add `@ikiform/sdk` to the app, save the route handler, and set `IKIFORM_WEBHOOK_SECRET` and `TEAMS_WEBHOOK_URL` in your host's environment variables. On Vercel the relay URL is `https://your-app.vercel.app/api/ikiform/teams`. Use the production domain: Vercel's deployment protection blocks requests to protected preview URLs.

### Step 4: Add the endpoint in Ikiform

- In the Ikiform dashboard, open **Webhooks** and choose **Add endpoint**.
- Paste your relay URL into **URL**.
- Under **Forms**, pick one form, or **All forms** in the workspace.
- Add a **Description** if you like, such as "Teams Sales channel", then choose **Add endpoint**.
- Copy the signing secret. It starts with `whsec_` and is shown in full only once.

There's no event picker. An endpoint gets `submission.created` for every accepted submission on the forms you chose, and `endpoint.test` when you send a test. A workspace on Pro can have up to 3 endpoints.

### Step 5: Add the secret to the relay

Set `IKIFORM_WEBHOOK_SECRET` to the `whsec_` secret. On Cloudflare run `npx wrangler secret put IKIFORM_WEBHOOK_SECRET` again; it takes effect right away. On Vercel, update the variable and redeploy.

Ikiform only delivers to public `https://` URLs on port 443. IP addresses, `localhost`, internal hostnames, `workers.dev` and `pages.dev` are refused when you save the endpoint.

## Test it

Open the endpoint in Ikiform and choose **Send test**. Ikiform sends one `endpoint.test` event with no retries, and the result shows up in the endpoint's delivery log within a few seconds. Then send a real submission from your form, or from a terminal:

`Terminal`:

```bash
curl https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hello from curl"}'
```

A `201` with `"ok": true` means Ikiform stored it. The delivery appears on the endpoint page, and the message lands in Teams.

> If the form has a human check or allowed domains turned on, a post from curl is refused or marked as spam, and spam never sends a webhook. Test from your real site instead.

The test event posts a one-line card. A real submission posts the full card. If nothing shows up but the delivery succeeded, open the workflow's run history in the Workflows app; the workflow accepts the request first and posts the card a moment later.

## How delivery works

| The receiver answers | Ikiform |
| --- | --- |
| Any `2xx` within 15 seconds | Marks the delivery succeeded |
| A redirect | Doesn't follow it, and counts a failure |
| `429` or `503` with `Retry-After` | Waits that long, up to the next step of the schedule |
| `410 Gone` | Fails the delivery and disables the endpoint |
| Anything else, or no answer in 15 seconds | Retries |

Failed deliveries are retried 8 times over about 3 d 6 h, with the same `webhook-id` each time. If every delivery to an endpoint fails for 5 days, Ikiform disables it and emails the workspace owners.

Diagram: What the relay tells Ikiform when Teams answers

1. Teams accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
2. Teams is rate limited (429): the relay answers 503 with Teams's Retry-After, and Ikiform waits that long before it tries again.
3. Teams answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.

Teams throttles a webhook that gets more than about 4 requests a second. The relay passes the `Retry-After` back to Ikiform, so a burst of submissions arrives a little later rather than not at all.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| `400 no_matching_signature` | The secret is wrong, or the body was parsed and re-serialized before verifying. Use the `whsec_` secret of this endpoint and the raw body exactly as received. After **Roll secret**, update it within 24 hours. |
| `502` with `Teams answered 400` | The workflow refused the body. Check that `TEAMS_WEBHOOK_URL` came from the **Send webhook alerts to a channel** template and wasn't cut off when you copied it. |
| `502` with `Teams answered 401` or `403` | The trigger only accepts signed-in users. Set **Who can trigger the flow** to **Anyone**. |
| `502` with `Teams answered 404` | The workflow was deleted or turned off, or the URL is an old `webhook.office.com` or `logic.azure.com` one. Create a new workflow and update `TEAMS_WEBHOOK_URL`. |
| The delivery succeeds but no card appears | Open the workflow's run history. A failed run usually means the flow's owner lost access to the channel. |
| Delivery log shows `timeout` | Nothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away. |
| Endpoint is disabled | Every delivery failed for 5 days, the URL answered `410`, or its hostname started resolving to a private address. Fix the cause, then turn **Enabled** back on and **Retry now** the failed deliveries. |
| The same submission arrives twice | A retry after a slow or failed answer. Every retry carries the same `webhook-id`, so skip ids you've already handled. |
| No delivery at all | Check that the endpoint covers this form, that it's enabled, and that the submission wasn't marked as spam or held because the workspace is over its monthly limit. |

## Next steps

- Send each form to its own channel: add one workflow per channel and one Ikiform endpoint per form. Pro allows 3 endpoints.
- Add an `Action.OpenUrl` button to the card that links to the submission in your own admin, using `data.submission_id`.
- Skip duplicates by storing the `webhook-id` header, as in [Receiving webhooks](https://ikiform.com/docs/guides/webhook-handlers.md#duplicates).

| Microsoft reference | Link |
| --- | --- |
| Create incoming webhooks with Workflows | [learn.microsoft.com](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook) |
| Connectors retirement | [devblogs.microsoft.com](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/) |
| Card reference | [learn.microsoft.com](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference) |
| Adaptive Cards | [adaptivecards.io](https://adaptivecards.io/explorer/) |
