Blog

Discord8 min read

Send form submissions to Discord

Post every Ikiform submission to a Discord channel as an embed, through a small relay that verifies the signature and formats the message.

Preet SutharFounder, Ikiform

Discord works well as a shared inbox for a small team or a community. This guide posts each Ikiform submission to a channel as an embed, with one field per form input and the submission id in the footer.

How a submission gets from your form to Discordyour formany html form,framework orserverIkiformstores it,signs awebhookrelayworker orroute handleryou deployDiscordchannelwebhook poststhe embedpostsignedapi callHow a submission gets from your form to Discordyour formany html form, framework orserverIkiformstores it, signs a webhookrelayworker or route handler youdeployDiscordchannel webhook posts the embedpostsignedapi call
  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, turns the submission into an embed and posts it to the Discord webhook URL.
  4. Discord answers the relay, and the relay answers Ikiform.
Ikiform signs the delivery. The relay checks it, turns it into an embed, and posts it to Discord.

Why there's a relay

A Discord webhook expects a message with at least one of content, embeds, components, file or poll. Ikiform sends a submission event shaped as { "type": ..., "data": ... }, which has none of them, so Discord refuses it with 400.

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

The relay also keeps the Discord URL private and 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 Discord actions.

Before you start

  • An Ikiform form that already receives submissions.
  • The Manage Webhooks permission on the Discord server.
  • 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
{
  "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

  1. Create a Discord webhook

    1. Open Server Settings, then Integrations, then Webhooks.
    2. Choose New Webhook. Give it a name, such as Ikiform, and pick the channel.
    3. Choose Copy Webhook URL. It looks like https://discord.com/api/webhooks/123/abc.

    Check it works before going further:

    Terminal
    curl -X POST "$DISCORD_WEBHOOK_URL?wait=true" \
      -H "Content-Type: application/json" \
      -d '{"content":"Hello from the terminal"}'

    With ?wait=true, Discord answers 200 with the message it created. Without it, Discord answers 204 even when the message wasn't saved, so the relay always adds it. Treat the URL as a secret: anyone who has it can post to that channel.

  2. Write the relay

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

    import { verifyWebhook, type WebhookEvent, WebhookVerificationError } from "@ikiform/sdk/webhooks";
    
    interface Env {
      IKIFORM_WEBHOOK_SECRET: string;
      DISCORD_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 withWait(url: string) {
      const target = new URL(url);
      target.searchParams.set("wait", "true");
      return target.toString();
    }
    
    interface EmbedField {
      name: string;
      value: string;
      inline: boolean;
    }
    
    function toDiscord(event: WebhookEvent) {
      const allowed_mentions = { parse: [] };
      if (event.type === "endpoint.test") {
        return { content: "Ikiform test event received. Submissions will appear here.", allowed_mentions };
      }
      const { form, fields, submission_id, created_at, truncated } = event.data;
      const title = clip(`New submission: ${form.name}`, 256);
      const description = truncated ? "Too large to show here. Open it in Ikiform." : undefined;
      let budget = 6000 - title.length - submission_id.length - (description?.length ?? 0);
      const embedFields: EmbedField[] = [];
      for (const [name, value] of Object.entries(fields).slice(0, 25)) {
        const field = {
          name: clip(name.trim() || "(unnamed)", 256),
          value: clip(valueText(value).trim() || "(empty)", 1024),
          inline: false,
        };
        if (field.name.length + field.value.length > budget) break;
        budget -= field.name.length + field.value.length;
        embedFields.push(field);
      }
      return {
        allowed_mentions,
        embeds: [{ title, description, fields: embedFields, footer: { text: submission_id }, timestamp: created_at }],
      };
    }
    
    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(withWait(env.DISCORD_WEBHOOK_URL), {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(toDiscord(event)),
        });
        if (response.status === 429) {
          const retryAfter = response.headers.get("retry-after") ?? "30";
          return new Response("Discord rate limit", { status: 503, headers: { "retry-after": retryAfter } });
        }
        if (!response.ok) return new Response(`Discord answered ${response.status}`, { status: 502 });
        return new Response(null, { status: 204 });
      },
    };
    How the relay maps an Ikiform submission to Discord embedIkiform eventDiscord embedform.name: Contacttitle: New submission: Contactfields.name: Adafield: name / Adafields.email: ada@example.comfield: email / ada@example.comfields.message: Hellofield: message / Hellosubmission_idfooter: sub_…created_attimestampHow the relay maps an Ikiform submission to Discord embedIkiform eventDiscord embedform.name:Contacttitle: Newsubmission:Contactfields.name:Adafield: name /Adafields.email:ada@example.comfield: email /ada@example.comfields.message:Hellofield: message/ Hellosubmission_idfooter: sub_…created_attimestamp
    1. form.name: Contact becomes title: New submission: Contact.
    2. fields.name: Ada becomes field: name / Ada.
    3. fields.email: ada@example.com becomes field: email / ada@example.com.
    4. fields.message: Hello becomes field: message / Hello.
    5. submission_id becomes footer: sub_….
    6. created_at becomes timestamp.
    Up to 25 fields per embed. allowed_mentions is empty, so nobody gets pinged.

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

    Discord message
    {
      "allowed_mentions": { "parse": [] },
      "embeds": [
        {
          "title": "New submission: Contact",
          "fields": [
            { "name": "name", "value": "Ada", "inline": false },
            { "name": "email", "value": "ada@example.com", "inline": false },
            { "name": "message", "value": "Hello", "inline": false }
          ],
          "footer": { "text": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3" },
          "timestamp": "2026-10-23T12:00:00.000Z"
        }
      ]
    }
    • Discord allows 25 fields per embed, 256 characters per field name, 1,024 per value and 6,000 across the whole embed. The relay cuts long values and stops adding fields before the total runs out.
    • Discord trims whitespace and refuses empty values, so an empty input shows as "(empty)".
    • allowed_mentions with an empty parse list means a visitor who types @everyone or a user id can't ping anyone.
    • A 429 from Discord becomes a 503 with the same Retry-After, so Ikiform waits before it tries again. Any other Discord error becomes a 502, which Ikiform retries.
  3. Deploy it

    Cloudflare Worker

    Terminal
    mkdir ikiform-discord && cd ikiform-discord
    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
    {
      "$schema": "node_modules/wrangler/config-schema.json",
      "name": "ikiform-discord",
      "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
    npx wrangler secret put IKIFORM_WEBHOOK_SECRET
    npx wrangler secret put DISCORD_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 DISCORD_WEBHOOK_URL in your host's environment variables. On Vercel the relay URL is https://your-app.vercel.app/api/ikiform/discord. Use the production domain: Vercel's deployment protection blocks requests to protected preview URLs.

  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 "Discord #submissions", 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.

  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
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 Discord.

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 short line to the channel. A real submission posts the embed, with the submission id in the footer and the submission time next to it.

How delivery works

The receiver answersIkiform
Any 2xx within 15 secondsMarks the delivery succeeded
A redirectDoesn't follow it, and counts a failure
429 or 503 with Retry-AfterWaits that long, up to the next step of the schedule
410 GoneFails the delivery and disables the endpoint
Anything else, or no answer in 15 secondsRetries

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.

What the relay tells Ikiform when Discord answers2xxok204no contentdonedelivery succeeded429rate limited503same retry-afterwaitsthen tries againother error4xx or 5xx502bad gatewayretries8 retries over about3 d 6 hDiscord answersrelay answersIkiformWhat the relay tells Ikiform when Discord answers2xxok204nocontentdonedeliverysucceeded429ratelimited503sameretry-afterwaitsthentriesagainothererror4xx or5xx502badgatewayretries8 retriesoverabout3 d 6 hDiscordanswersrelayanswersIkiform
  1. Discord accepts the request: the relay answers 204, and Ikiform marks the delivery succeeded.
  2. Discord is rate limited (429): the relay answers 503 with Discord's Retry-After, and Ikiform waits that long before it tries again.
  3. Discord answers with any other error: the relay answers 502, and Ikiform retries, 8 retries over about 3 d 6 h.

Each Discord webhook has its own rate limit. A burst of submissions can hit it, and the relay passes Discord's Retry-After back to Ikiform, so the extra messages arrive a little later rather than not at all.

If a Discord webhook answers 404, it was deleted. Discord asks clients to stop calling deleted webhooks, so create a new one and update DISCORD_WEBHOOK_URL rather than letting retries run.

Troubleshooting

ProblemWhat to do
400 no_matching_signatureThe 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 Discord answered 400Discord refused the message. Check the relay's logs for Discord's error body. A forum channel needs thread_name or thread_id, so post to a text channel instead.
502 with Discord answered 401 or 404The webhook was deleted or its token is wrong. Create a new webhook and update DISCORD_WEBHOOK_URL.
Delivery log shows timeoutNothing answered within 15 seconds. Answer first and do slow work after, or make sure the tool responds right away.
Endpoint is disabledEvery 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 twiceA retry after a slow or failed answer. Every retry carries the same webhook-id, so skip ids you've already handled.
No delivery at allCheck 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 Discord webhook per channel and one Ikiform endpoint per form. Pro allows 3 endpoints.
  • Post into a thread by adding thread_id to the webhook URL. The relay keeps any query string you put there.
  • Skip duplicates by storing the webhook-id header, as in Receiving webhooks.
Discord referenceLink
Execute webhookdocs.discord.com
Embed limitsdocs.discord.com
Rate limitsdocs.discord.com
Intro to webhookssupport.discord.com

Stuck on a step, or want a guide for another tool? Email hello@ikiform.com.