DEV Community

Cover image for Sending form submissions to Slack, Discord, Airtable and Notion
Muhammad Soliman
Muhammad Soliman

Posted on

Sending form submissions to Slack, Discord, Airtable and Notion

Every integration below has one setup mistake that returns success and delivers nothing. Here is each one, how to spot it, and the webhook payload I ended up designing around them.

I build a form builder, Seagit Forms, and most of the support questions I get aren't about forms at all. They're about a response that should have landed in Slack or Notion and didn't, while every dashboard said it worked.

After wiring up nine destinations, I noticed a pattern. Each service has exactly one mistake that a careful, correct-looking setup still hits, and it never throws an error. No 4xx, no exception, no red banner. Just silence.

Here are the ones worth knowing, whether you use my tool, someone else's, or write the integration yourself.

Slack: 200 OK that posts nothing

You create a Slack app, add the chat:write scope, install it, copy the xoxb- bot token and a channel ID. You call chat.postMessage. Slack answers HTTP 200.

Nothing appears in the channel.

{ "ok": false, "error": "not_in_channel" }
Enter fullscreen mode Exit fullscreen mode

Slack returns errors in the body with a 200 status. A bot with chat:write can't post to a channel it hasn't joined. Two fixes:

/invite @your-app        # in the channel
Enter fullscreen mode Exit fullscreen mode

or add the chat:write.public scope, which lets the bot post to any public channel without joining. Private channels always need the invite.

The rule: never check response.ok on a Slack call. Check body.ok. A client that only looks at the HTTP status reports success for every misconfiguration Slack has.

Also set text on every message, even when you use blocks. Slack uses it for the notification and sidebar preview; without it the message arrives but notifies with a blank line.

Discord: 204 No Content hides rejected messages

Discord channel webhooks are the easiest integration there is: one URL, no auth flow. POST JSON to it, get a 204.

The catch is that a 204 tells you almost nothing. Add ?wait=true to the URL:

https://discord.com/api/webhooks/<id>/<token>?wait=true
Enter fullscreen mode Exit fullscreen mode

Now Discord waits until the message is created and returns it as JSON, or returns a real error if it was rejected (an embed field over the length limit, for example). Without it, a message Discord refused looks exactly like one it posted.

Treat the webhook URL as a password, too. Anyone holding it can post to the channel.

Airtable: one unknown column loses the whole record

Airtable's API matches fields by column name. If your payload has ten fields and one name doesn't match a column exactly, Airtable rejects the entire record, not just that field.

That makes renames dangerous. Someone tidies "Email address" to "Email" in the base, and from then on every submission is rejected.

What worked for me:

  1. Read the table's schema first.
  2. Send only fields whose names match a column.
  3. Surface the unmatched ones to the form owner as a warning, rather than failing.

Also: don't send "" for blank answers. An empty string into a date, number or single-select column is itself a rejection. Omit the field instead.

Notion: 404 for an ID that is correct

You create an internal integration, copy its token, copy your database ID from the URL, and call the API. Notion answers:

{ "object": "error", "status": 404, "code": "object_not_found" }
Enter fullscreen mode Exit fullscreen mode

So you re-check the ID. It's right. You re-copy it. Still 404.

The database exists. It just isn't connected to your integration. In Notion, open the database, then ••• → Connections → add your integration. Notion deliberately reports "not found" instead of "forbidden" so it doesn't confirm the page exists, and that sends everyone after the wrong problem.

Notion properties are also typed. A plain string into an email or date property is a 400 that loses the whole page, so read the database schema and shape each value to its property type first.

Microsoft Teams: the old connector is gone

If a guide tells you to add an "Incoming Webhook" connector to a Teams channel, it's out of date: Microsoft retired Office 365 connectors. You now create the URL through Workflows ("Post to a channel when a webhook request is received"). And note that the Workflows endpoint accepting your request is not the same as the card being posted. The flow runs afterwards.

The webhook: designing for the failures above

All of this pushed me toward making a plain webhook the most reliable destination, and treating every other integration as a notification rather than the record. This is the payload Seagit Forms sends to a webhook:

{
  "responseId": "response-1787712439855-b0t71",
  "formId": "g-1787690899982-nd90p",
  "submittedAt": 1787712439855,
  "data": {
    "firstName": "Ada",
    "teamSize": 12
  },
  "fields": [
    { "id": "firstName", "label": "First name", "type": "text" },
    { "id": "teamSize", "label": "Team size", "type": "number" }
  ],
  "respondent": {},
  "metadata": {
    "userAgent": "Mozilla/5.0 …",
    "referrer": "",
    "submittedFrom": "https://forms.seagit.com/forms/view/g-1787690899982-nd90p"
  }
}
Enter fullscreen mode Exit fullscreen mode

The decisions behind it, which apply to any webhook you design:

  • responseId is an idempotency key. If you ever add retries, the receiver can drop duplicates.
  • fields travels with data. The receiver knows what each key means and its type, without a second API call to fetch the form.
  • Blank answers are absent, not null. That keeps the "don't send empty strings" rule from the Airtable section true downstream.
  • submittedAt is epoch milliseconds, not an ISO string. Pick one and document it; the bug is always a mix.
  • A failed delivery never fails the submission. The response is stored first, so a broken endpoint can't cost you an answer.

To test any webhook without writing a receiver, paste a webhook.site URL in as the endpoint and submit once. It shows the exact body and headers that arrived, which also confirms your auth header is spelled the way your real endpoint expects.

A checklist before you trust any integration

  1. Does the client check the response body, not just the status code? (Slack)
  2. Does the call wait for a real result? (Discord ?wait=true)
  3. What happens when one field doesn't match: is one field dropped or the whole record? (Airtable, Notion)
  4. Has the integration been granted access to the specific resource, not just authenticated? (Slack channel, Notion database)
  5. Is there a record that doesn't depend on the integration, so a silent failure is recoverable?

If you'd rather not write any of this yourself, the setup guides for each destination walk through these exact traps, and webhooks and Telegram are free. Either way, I hope this saves someone an afternoon of re-copying an ID that was right all along.

Top comments (0)