Get Discord Alerts Every Time Someone Fills Out Your Form

Send form submissions to Discord with Formspree's no-code plugin, or route them to different channels with embeds and role pings using a webhook.

in

A form on your site collects a contact request, and seconds later it lands as a formatted message in your team’s Discord channel. Whoever is available claims the thread, replies inline, and the rest of the team sees the exchange without anyone forwarding anything.

This article covers two ways to build that pipeline: Formspree’s native Discord plugin for a single channel with no code, and a webhook-driven handler that routes to different channels, renders Discord embeds, and pings specific roles for urgent submissions. The form HTML stays the same in both approaches. Only what Formspree does with the submission changes.

What Discord adds over email for form notifications

Email routes every submission to one inbox. Whoever owns that inbox becomes the default triager, and the reply thread happens somewhere else (another email chain, a Slack DM, a forwarded ticket). Context splits every time the submission changes hands.

Everyone monitoring a Discord channel sees the post at the same time. When the first person replies in a thread, that reply stays attached to the original submission, so a second person checking five minutes later can see the request is already being handled without asking. Email can’t do that.

Formspree’s submission dashboard is the system of record, and Discord is an alert layer on top. A failed Discord post still leaves the submission stored in Formspree, and you can always pull a specific inquiry from three months ago out of the dashboard even if the Discord message was deleted.

The form

Every path in this article uses the same HTML: a barebones contact form with a category dropdown that drives routing later:

  <form action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
    <input type="hidden" name="_gotcha" style="display:none" />
    <input type="hidden" name="subject" value="{{ category }} from {{ name }}" />

    <label>
      Your name
      <input type="text" name="name" required />
    </label>

    <label>
      Email address
      <input type="email" name="email" required />
    </label>

    <label>
      What is this about?
      <select name="category" required>
        <option value="Sales question">Sales question</option>
        <option value="Support request">Support request</option>
        <option value="Community">Community</option>
        <option value="Other">Other</option>
      </select>
    </label>

    <label>
      Message
      <textarea name="message" rows="6" required></textarea>
    </label>

    <button type="submit">Send</button>
  </form>

Create a Formspree form and replace YOUR_FORM_ID with the ID from your Formspree dashboard before testing.

Two hidden fields do work the submitter never sees. The _gotcha honeypot stays invisible to humans and gets auto-filled by bots, and Formspree discards any submission where it’s filled in. (For what else you can do against spam without a backend, see Frontend-Only Spam Filtering.) The subject hidden field uses Formspree’s mustache syntax, so {{ category }} and {{ name }} resolve to submitted values at submission time. The Discord plugin reads that resolved string to build the message subject line, so the channel sees “Sales question from Alex Chen” at the top of the notification.

Beginner setup: the Discord plugin

Formspree’s Discord plugin is available on the Personal, Professional, and Business plans. Setup takes two steps.

First, create a Discord webhook. Right-click your server icon, open Server Settings, click Integrations, then Webhooks, then New Webhook. Assign it to the channel that should receive submissions, name it something recognizable like “Form Notifications”, pick an avatar, and click Copy Webhook URL. You need Manage Webhooks permission on the channel for this to work.

Treat the webhook URL as a secret. Anyone with the URL can post to that channel with no authentication, so store it in a password manager and regenerate the webhook (from the same menu) if it leaks.

Second, connect the URL in Formspree. Open your form, go to the Workflow tab, click + Add New, and select the Discord plugin:

Selecting the Discord plugin in the Formspree Workflow tab

Paste the webhook URL and click Connect. Formspree posts a test message immediately so you know the connection works. Here’s what a message looks like:

A form submission posted to Discord by the Formspree Discord plugin

The plugin fits well when one team monitors one channel and every submission gets the same level of attention. An indie community with a single #contact-form channel is the obvious case. Volume stays low enough that every notification is worth reading, everyone who might respond is already in the channel, and nothing about the message needs to change based on the category.

Where the single-channel plugin breaks down

Once your Discord server has #support, #sales, #feedback, and #bugs, the plugin’s single-channel model becomes a bottleneck. Every submission still lands in whichever channel you connected, and someone has to read each one and forward it to wherever it belongs. Forwarding is manual routing, which is exactly what the category dropdown on your form was supposed to prevent.

Discord webhook URLs are bound to a single channel by design. Changing the destination requires creating a new webhook in the target channel and reconnecting the plugin in Formspree with the new URL. The plugin also lacks conditional logic (for example, alerting only when a submission crosses a value threshold), embed customization, and role mentions. Any of those requirements pushes you to the webhook path.

Advanced setup: Formspree webhook to a serverless function

Formspree webhooks (available on Professional and Business plans) POST the raw submission payload as JSON to any URL you control. The function that receives that payload picks a Discord webhook URL based on the category, formats an embed, and calls Discord’s webhook endpoint. (The same pattern works for Slack; see Slack for Forms: Beginner Setup vs. Advanced Routing for that version.)

To configure the webhook, open the form in Formspree, go to the Workflow tab, click + Add New, and select Webhook. Paste the URL of your deployed function as the Target URL and leave the Protocol set to “simple webhooks”.

Connect Webhook dialog in Formspree with the Target URL, Protocol, and Authentication fields

Formspree POSTs a JSON payload to that URL on each new submission:

  {
    "form": "your-form-id",
    "keys": ["name", "email", "category", "subject", "message"],
    "submission": {
      "_date": "2026-08-10T10:30:00",
      "name": "Alex Chen",
      "email": "alex@example.com",
      "category": "Sales question",
      "subject": "Sales question from Alex Chen",
      "message": "We're evaluating tools for our team of 40..."
    }
  }

Your form field values sit inside submission. Before writing the handler, create one Discord webhook per destination channel using the same steps from the plugin setup, and store each URL in an environment variable (DISCORD_WEBHOOK_SALES, DISCORD_WEBHOOK_SUPPORT, and so on).

The handler code

The handler below is written as a Next.js API route, but the same logic works in any serverless function. It picks the mapped Discord webhook URL by category value and posts a formatted embed.

  // pages/api/form-webhook.js
  export default async function handler(req, res) {
    if (req.method !== 'POST') return res.status(405).end();

    // In production, verify the Formspree webhook signature here before
    // reading req.body. See the note below the snippet.

    const { name, email, category, subject, message } = req.body.submission;

    // Log metadata only. The body contains personal data (name, email,
    // free-text message) that shouldn't sit in function logs.
    console.log('Formspree webhook received', {
      form: req.body.form,
      category,
      date: req.body.submission._date,
    });

    const channelMap = {
      'Sales question': process.env.DISCORD_WEBHOOK_SALES,
      'Support request': process.env.DISCORD_WEBHOOK_SUPPORT,
      'Community': process.env.DISCORD_WEBHOOK_COMMUNITY,
      'Other': process.env.DISCORD_WEBHOOK_GENERAL,
    };

    const webhookUrl = channelMap[category] ?? process.env.DISCORD_WEBHOOK_GENERAL;

    const colorMap = {
      'Sales question': 0x2ecc71,
      'Support request': 0xe67e22,
      'Community': 0x3498db,
      'Other': 0x95a5a6,
    };

    const discordRes = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        embeds: [{
          title: subject,
          description: message,
          color: colorMap[category] ?? 0x95a5a6,
          fields: [
            { name: 'From', value: `${name} <${email}>`, inline: true },
            { name: 'Category', value: category, inline: true },
          ],
          footer: { text: 'Formspree submission' },
          timestamp: new Date().toISOString(),
        }],
      }),
    });

    if (!discordRes.ok) return res.status(502).end();

    res.status(200).json({ ok: true });
  }

A note on authenticating the webhook. The handler trusts whatever arrives in the request body, which is fine for following along but not for production. The endpoint is public, so anyone who finds the URL can POST a payload and put a convincing-looking message in your team’s channel. The Authentication dropdown in the Connect Webhook dialog offers HMAC signatures or a bearer token. Pick HMAC, and have your handler verify the signature and reject anything that doesn’t match before reading the body. It’s out of scope for this tutorial, but treat it as required before the handler goes live.

The channelMap lookup handles the four known category values, with ?? falling back to a general channel for anything unexpected. Storing URLs in environment variables keeps routing configuration out of source code and lets you change destinations without a redeploy.

Discord’s embed format renders as a colored sidebar with a title, description, and structured fields. color is a decimal integer (hex literals like 0x2ecc71 work fine in JavaScript), fields render as name/value pairs, and timestamp shows a localized time in the footer. The footer is a good place for a “View in Formspree” link back to the dashboard if you want an agent to jump straight to the submission record.

Discord embed with color-coded sidebar, structured fields, and footer

Tightening the routing logic

Four additions worth making once the webhook is in production.

Some submissions warrant more urgency than a channel post. A sales inquiry that needs a fast response should ping the sales role directly. Enable Developer Mode in Discord (User Settings > Advanced > Developer Mode) and right-click the role in Server Settings > Roles to copy the role ID. The mention format is <@&ROLE_ID> inside the message content:

  const urgentTypes = ['Sales question'];
  const mention = urgentTypes.includes(category)
    ? `<@&${process.env.DISCORD_ROLE_SALES}> `
    : '';

Add mention at the start of a content field in the request body. The handler above has no content field, so add one alongside embeds. Discord renders it as a proper role ping, which triggers a notification for every user with that role.

User-supplied content is a hazard when it can include Discord mention syntax. A submitter typing @everyone in the message field will ping your entire server unless you constrain what mentions Discord processes. Set allowed_mentions explicitly:

  allowed_mentions: { parse: [], roles: [process.env.DISCORD_ROLE_SALES] }

parse: [] disables @everyone, @here, and every role or user mention by default, and the roles array whitelists the specific roles you want to ping. Set it once and the problem is gone.

Discord’s webhook rate limit is around 30 requests per minute per webhook. Contact form traffic rarely touches that ceiling, but a launch spike or a newsletter send can push past it and trigger a 429 with retry_after (in seconds) in the response body. Return a non-2xx response to Formspree in that case so Formspree retries the delivery.

Double submissions are worth handling too. If the same user submits twice in quick succession (double-click, browser retry, flaky connection), you get duplicate Discord messages for what is effectively the same inquiry. Key the check on a fingerprint of the inquiry itself (email plus category plus message), not on email alone, so a second, different question from the same person still gets through. Don’t include _date in the fingerprint: Formspree assigns it server-side, so it differs between rapid resubmissions. Claim the fingerprint with a single SET NX so two deliveries arriving at the same moment can’t both pass a separate read-then-write check. A Redis call (Upstash works well for serverless) before the Discord call handles this without adding meaningful latency:

  import { createHash } from 'crypto';

  const fingerprint = createHash('sha256')
    .update(`${email}|${category}|${message}`)
    .digest('hex');

  // SET NX returns null if the key already exists, so only the first
  // delivery of this inquiry within 60 seconds claims it.
  const claimed = await redis.set(`seen:${fingerprint}`, '1', { nx: true, ex: 60 });
  if (!claimed) return res.status(200).json({ ok: true, duplicate: true });

What to do when things go wrong

The console.log at the top of the handler is there for exactly this. Logging the form ID, category, and submission date before any routing runs means that if something breaks downstream (an unexpected category value, a Discord API error, a missing environment variable), you can match the log line to the exact submission in the Formspree dashboard and reconstruct what happened. The log deliberately leaves out the name, email, and message: the dashboard already holds the full record, and personal data shouldn’t sit in function logs.

Formspree retries webhook deliveries on non-2xx responses and network errors. Formspree also stores every submission in the dashboard whether the webhook succeeded or not, so a notification failure delays the alert without losing the submission. The handler above returns 502 on Discord API failure, which triggers a Formspree retry. Returning 200 in the same situation closes the delivery and swallows the error.

Set an alert for when your webhook URL goes 24 hours without traffic. Silence can mean a form with no submissions, or a broken webhook URL (a changed route path, a rotated Discord webhook, a misconfigured environment variable). The two look identical until someone notices the silence.

A workaround for teams without a serverless function

Discord’s webhook endpoint accepts Slack-formatted payloads at the /slack suffix. Point Formspree’s Slack plugin at https://discord.com/api/webhooks/YOUR_WEBHOOK/slack and the Slack-shaped message renders in Discord as a plain post.

This path produces a plain text message. Embeds, role mentions, and category routing all require the serverless handler above.

Choosing between the plugin and the webhook

The plugin is the right starting point when one channel covers the whole team, the default message is enough, and no role pings are needed. It requires no deployed code.

The webhook is the right move when different categories need different channels, when embeds add scannability that a plain message cannot match, or when urgent submissions need a role mention. It requires a deployed function and a Professional or Business plan.

Start with the plugin. Send two or three test submissions and see whether the default message and single-channel routing covers your needs. Most contact forms don’t need routing logic on day one. When the single-channel approach starts generating noise or missed handoffs, add the Webhook, deploy the handler, and disconnect the plugin. The form HTML stays exactly as it is.

FAQ

Does Formspree support Discord?

Yes, natively via the Discord plugin on the Personal, Professional, and Business plans. It uses a Discord webhook URL you paste into the form’s Workflow tab.

How do I send Formspree submissions to Discord without code?

Create a Discord webhook in your server (Server Settings, Integrations, Webhooks), copy the URL, and paste it into the Discord plugin under the form’s Workflow tab in Formspree.

How do I create a Discord webhook URL?

Right-click your server icon, open Server Settings, Integrations, Webhooks, then click New Webhook. Pick the destination channel, name the webhook, and click Copy Webhook URL. You need Manage Webhooks permission on the channel.

Can Formspree send Discord embeds (rich messages)?

The native plugin sends a plain message. Rich embeds (colored sidebars, structured fields, footers, timestamps) require the webhook path, where your function builds the embed and POSTs it to the Discord webhook URL.

How do I stop @everyone from being triggered by user input?

Set allowed_mentions: { parse: [] } in your Discord webhook payload. Without it, a message body containing @everyone will actually ping the server.

What are Discord webhook rate limits?

Around 30 requests per minute per webhook. On 429, Discord returns retry_after in seconds. Return a non-2xx status from your handler so Formspree retries the delivery.

Is a Discord webhook URL a secret?

Yes. Anyone with the URL can post to that channel with no authentication. Store it in an environment variable, and regenerate the webhook if it leaks.

What happens if Discord is down when someone submits my form?

Formspree stores the submission regardless. The Discord notification retries on non-2xx responses from your webhook handler, so the alert is delayed but the submission stays available.


Got Feedback?