Slack7 min read
Send form submissions to Slack
Post every Ikiform submission to a Slack channel with an incoming webhook and a small relay that verifies the signature and formats the message.
A channel that lights up with each new lead or support request is one of the most useful things to wire to a form. This guide posts each Ikiform submission to Slack as a tidy message with one line per field.
- A visitor submits your form, and it posts to Ikiform.
- Ikiform stores the submission and sends a signed webhook to your relay.
- The relay checks the signature, turns the submission into Block Kit and posts it to the Slack webhook URL.
- Slack answers the relay, and the relay answers Ikiform.
Why there's a relay
Slack's incoming webhooks accept a message shaped as { "text": ... } or { "blocks": [...] }. Ikiform sends a submission event shaped as { "type": ..., "data": ... }, and Slack answers that with 400 no_text.
The relay is about 60 lines. It also keeps the Slack URL private, and it checks Ikiform's signature, so nobody else can post to your channel through it. If you'd rather not deploy code, use n8n, Zapier or Make with their Slack actions.
data.fields, so they can't be mapped there.Before you start
- An Ikiform form that already receives submissions.
- Permission to add apps to your Slack workspace.
- 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:
{
"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.
Set it up
Create a Slack incoming webhook
- Go to api.slack.com/apps, choose Create New App, then From scratch. Name it, for example
Ikiform, and pick your workspace. - Open Incoming Webhooks in the app's settings and turn on Activate Incoming Webhooks.
- Choose Add New Webhook to Workspace, pick the channel, and confirm.
- Copy the webhook URL. It looks like
https://hooks.slack.com/services/T000/B000/XXXX.
Check it works before going further:
Terminal curl -X POST "$SLACK_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d '{"text":"Hello from the terminal"}'Slack answers
okand the message shows up in the channel. Treat the URL as a secret: anyone who has it can post to that channel, and Slack revokes URLs it finds in public code.- Go to api.slack.com/apps, choose Create New App, then From scratch. Name it, for example
Write the relay
Pick the platform you already use. Both versions verify the signature with
@ikiform/sdk, format the message, and turn Slack errors into answers Ikiform retries.src/index.tsimport { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks"; interface Env { IKIFORM_WEBHOOK_SECRET: string; SLACK_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 escape(text: string) { return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } function clip(text: string, max: number) { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } function toSlack(event: WebhookEvent) { if (event.type === "endpoint.test") return { text: "Ikiform test event received. Submissions will appear here." }; const { form, fields, submission_id, truncated } = event.data; const lines = Object.entries(fields) .slice(0, 40) .map(([name, value]) => clip(`*${escape(name)}*\n${escape(valueText(value))}`, 2000)); const sections = Array.from({ length: Math.ceil(lines.length / 10) }, (_, index) => ({ type: "section", fields: lines.slice(index * 10, index * 10 + 10).map((text) => ({ type: "mrkdwn", text })), })); const note = truncated ? "Too large to show here. Open it in Ikiform." : `\`${submission_id}\``; return { text: `New submission on ${escape(form.name)}`, blocks: [ { type: "header", text: { type: "plain_text", text: clip(`New submission: ${form.name}`, 150) } }, ...sections, { type: "context", elements: [{ type: "mrkdwn", text: note }] }, ], }; } 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.SLACK_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(toSlack(event)), }); if (response.status === 429) { const retryAfter = response.headers.get("retry-after") ?? "30"; return new Response("Slack rate limit", { status: 503, headers: { "retry-after": retryAfter } }); } if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 }); return new Response(null, { status: 204 }); }, };- form.name: Contact becomes header: New submission: Contact.
- fields.name: Ada becomes section field: name / Ada.
- fields.email: ada@example.com becomes section field: email / ada@example.com.
- fields.message: Hello becomes section field: message / Hello.
- submission_id becomes context: sub_… id.
Up to 10 fields per section block. More fields start a new section. For the sample submission above, the relay posts this to Slack:
Slack message { "text": "New submission on Contact", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New submission: Contact" } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*name*\nAda" }, { "type": "mrkdwn", "text": "*email*\nada@example.com" }, { "type": "mrkdwn", "text": "*message*\nHello" } ] }, { "type": "context", "elements": [{ "type": "mrkdwn", "text": "`sub_01k7v2c9t3e8m5q4x6z0a1b2c3`" }] } ] }- Slack's limits are 10 fields per section and 2,000 characters per field, so long values are cut and the fields split across sections.
&,<and>are escaped, so a visitor can't inject links or mentions.- A
429from Slack becomes a503with the sameRetry-After, so Ikiform waits before it tries again. Any other Slack error becomes a502, which Ikiform retries.
Deploy it
Cloudflare Worker
Terminal mkdir ikiform-slack && cd ikiform-slack npm init -y npm install @ikiform/sdk npm install -D wranglerSave the Worker code as
src/index.ts, then set the route inwrangler.jsonc:wrangler.jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "ikiform-slack", "main": "src/index.ts", "compatibility_date": "2026-10-01", "routes": [{ "pattern": "hooks.example.com", "custom_domain": true }] }Ikiform refusesworkers.devURLs, so the Worker needs a hostname on a domain you have on Cloudflare, likehooks.example.com. Thecustom_domainroute creates the DNS record and certificate for you on deploy.Add both secrets and deploy:
Terminal npx wrangler secret put IKIFORM_WEBHOOK_SECRET npx wrangler secret put SLACK_WEBHOOK_URL npx wrangler deployYour relay URL is
https://hooks.example.com/.Next.js
Add
@ikiform/sdkto the app, save the route handler, and setIKIFORM_WEBHOOK_SECRETandSLACK_WEBHOOK_URLin your host's environment variables. On Vercel the relay URL ishttps://your-app.vercel.app/api/ikiform/slack. Use the production domain: Vercel's deployment protection blocks requests to protected preview URLs.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 "Slack #leads", 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.createdfor every accepted submission on the forms you chose, andendpoint.testwhen you send a test. A workspace on Pro can have up to 3 endpoints.Add the secret to the relay
Set
IKIFORM_WEBHOOK_SECRETto thewhsec_secret. On Cloudflare runnpx wrangler secret put IKIFORM_WEBHOOK_SECRETagain; 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:
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 Slack.
The test event posts a short line to the channel. A real submission posts the full message, with the submission id at the bottom.
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.
- Slack accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
- Slack is rate limited (429): the relay answers 503 with Slack's Retry-After, and Ikiform waits that long before it tries again.
- Slack answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.
Slack allows about one message per second per webhook. A burst of submissions can hit that, and the relay passes Slack's Retry-After back to Ikiform, so the extra messages arrive 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 Slack answered 400 | Slack refused the message. Check the relay's logs for Slack's error, such as invalid_payload, and that the Slack URL is copied in full. |
502 with Slack answered 403 or 404 | The Slack webhook was revoked, the app was removed, or the channel was archived. Create a new webhook and update SLACK_WEBHOOK_URL. |
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 Slack webhook per channel and one Ikiform endpoint per form. Pro allows 3 endpoints.
- Add a button that links to the submission in your own admin, using
data.submission_id. - Skip duplicates by storing the
webhook-idheader, as in Receiving webhooks.
| Slack reference | Link |
|---|---|
| Incoming webhooks | docs.slack.dev |
| Section block limits | docs.slack.dev |
| Escaping text | docs.slack.dev |
| Rate limits | docs.slack.dev |
Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.