Send form submissions to Slack and Discord with webhooks
The Formward TeamFormward, Stockholm7 min read
To send form submissions to Slack or Discord, point your form backend at an incoming webhook URL from the channel you want notified. When someone submits the form, the backend POSTs the submission to that URL and the message appears in the channel within a second or two. No polling, no email parsing, no glue code running on a server you have to maintain. With Formward you enable a webhook on the form, paste the incoming webhook URL, and you are done. This post covers how outbound webhooks actually work, how to wire up Slack and Discord specifically, and why the signing secret is the part you should not skip.
A webhook is just an HTTP POST that one system sends to another when an event happens. Instead of you asking the form backend every minute whether anything new arrived, the backend pushes the event to a URL you control the instant it occurs. That inversion is the whole value: the data shows up where your team already works, in real time, without you writing or hosting a listener. The form backend is the sender; Slack, Discord, Microsoft Teams, Mattermost, or your own endpoint is the receiver.
Slack and Discord both expose this on their side as an incoming webhook. In Slack you create one from the channel settings or an app configuration, and you get a URL that looks like hooks.slack.com followed by a long opaque path. In Discord you open the channel's integration settings, create a webhook, and copy its URL from discord.com/api/webhooks. Anyone who has that URL can post to the channel, so treat it like a password: do not commit it to a public repo, and rotate it if it leaks.
The format each platform expects differs slightly, and a good form backend handles that for you. Slack wants a JSON body with a text field and optionally a blocks array. Discord wants a JSON body with a content field. Formward formats the outbound payload to match the destination automatically, so the same submission renders as a tidy message in either place: the form name as a heading and the submitted fields listed underneath. You pick Slack or Discord when you create the webhook and the formatting follows.
There is a subtlety that bites people who roll their own integration: the field values in a submission are attacker-controlled. A spammer can put @everyone or @here in a message field, and if you interpolate that straight into a Discord or Slack payload, you have just let a stranger ping your entire team. The fix is to neutralise mention syntax and markdown before it reaches the channel. Formward does this by breaking the @ character and escaping markup markers, and on Discord it also sets allowed_mentions so the platform refuses to resolve any mention regardless of content. If you build your own, do the same on both layers.
For a plain HTTP endpoint rather than a chat platform, the payload is structured JSON: the form id, the form name, the submission id, a timestamp, the field values, and any AI enrichment such as a spam score or a one-line summary. That is the generic webhook, and it is what you point at your own service, a queue, an automation tool, or a logging sink. The chat formatters are conveniences on top of the same delivery machinery.
Now the part that matters most and that most tutorials gloss over: signing. When your endpoint receives a POST claiming to be a form submission, how do you know it really came from your form backend and not from someone who guessed your URL? Without verification, anyone who learns the endpoint can forge submissions, and your downstream automation will treat the forgery as real. Signing solves this. The sender computes a cryptographic signature over the request body using a secret only the two of you share, and attaches it as a header. Your receiver recomputes the signature and rejects anything that does not match.
Formward signs generic webhook deliveries with HMAC-SHA256 and a per-form signing secret. The signature travels in an X-Formward-Signature header shaped t=<unix-timestamp>,v1=<hex>. The hex is not a hash of the bare body; it is a hash of the string v1:<timestamp>:<body>. Binding the timestamp into the signed value is what makes the scheme replay-resistant: a captured delivery cannot be re-sent later and still look fresh, because your receiver checks that the timestamp is recent before trusting it. This is the same construction Stripe uses for its webhooks, for the same reason.
Verifying on your side is four steps. Parse t and v1 out of the header. Recompute HMAC-SHA256 over v1:<t>:<rawBody> using the shared secret. Compare your result to v1 using a constant-time comparison, not a plain string equals, so you do not leak timing information about where the two diverge. Then reject the request if the timestamp is older than your tolerance, say five minutes. Use the raw request body exactly as received for the hash; if you parse the JSON and re-serialise it first, the bytes change and the signature will never match.
A practical note on secret rotation. If you ever need to change the signing secret without dropping in-flight deliveries, accept both the old and the new secret during the overlap window and treat a request as valid if it verifies against either one. Formward's verification helper takes a list of candidate secrets precisely so a rotation does not cause a window where legitimate deliveries get rejected. Chat platforms like Slack and Discord do not verify our signature, since their authentication is the secret URL itself, so signing applies to your own generic endpoints.
Why does this matter for a privacy-first setup specifically? Because a webhook moves personal data out of the form backend to wherever you send it. If you send submissions to Slack or Discord, those messages now live on that platform's infrastructure, with that platform's data location and retention. That is a perfectly reasonable choice for an internal notification, but it is a choice you are making, so make it deliberately: list the destination in your records, and prefer sending a short notification plus a link back to the dashboard over dumping every field if the data is sensitive.
One more thing worth getting right is what happens when the receiver is down. A webhook is fire-and-forget unless the sender retries. Formward queues deliveries and retries on failure rather than dropping the event on the first hiccup, and it refuses to follow redirects or post to private network addresses, so a webhook URL cannot be turned into a tool for probing internal services. If you write your own sender, build in retries with backoff and the same outbound guards; a webhook that silently loses events is worse than no webhook, because you will trust it.
To recap the setup: create an incoming webhook in Slack or Discord, copy its URL, add it to your form as a webhook destination, and submissions start flowing into the channel immediately. For your own endpoints, also set a signing secret and verify the X-Formward-Signature header on every request before acting on the body. Sending form submissions to Slack and Discord is a five-minute job; the signature verification is the ten extra minutes that keeps the integration from becoming a hole someone else can post through.
If you want to test the whole path before relying on it, send one real submission and watch it land, then deliberately POST a body with a wrong signature to your own endpoint and confirm your code rejects it. An integration you have only seen succeed is an integration you have not actually tested. Once both the happy path and the rejection path behave the way you expect, you can wire it into the automation you care about and stop checking your inbox.