Outgrow builder forms

Outgrow builder forms7 min read

Framer forms: when to add a form backend, and how

Framer forms already send to email, Sheets or a webhook; add Ikiform as the webhook for stored, filtered submissions, team alerts and more destinations.

Preet SutharFounder, Ikiform

Framer's native forms send each submission to email, Google Sheets or a webhook, with spam protection built in, so for one inbox or one sheet you may not need anything else. Add Ikiform as the webhook destination when you want every submission stored with a spam status, validated against your fields, emailed to the team and forwarded to several signed webhooks. Because Framer posts from its servers, either loosen the form's origin checks or put a small relay in between that verifies Framer's signature.

What's different on Framer

Framer is the one builder in this guide without a limit to escape. Its forms can send to email, Google Sheets or any webhook, and Framer has built-in spam protection that flags or blocks suspect submissions. Its webhooks are signed: each request carries a Framer-Signature header, an HMAC-SHA256 of the body and the submission id, and a Framer-Webhook-Submission-Id. Framer retries up to five times when your endpoint doesn't answer 2xx, and doesn't follow redirects.

So be honest about why you'd add a form backend. Here's what each side covers:

You wantFramer aloneWith Ikiform as the webhook
Submissions in one inbox or one sheetYesYes, plus the dashboard
A searchable record with spam marked, and CSV exportYour inbox or sheet is the recordYes
Server-side checks on types, lengths and optionsNoYes, with a field definition
Alerts to several people, with Reply going to the submitterEmail destinationUp to 10 recipients, with reply-to
Several tools fed from one form, each retriedOne webhook per destination you addUp to 3 signed endpoints, retried with a delivery log
The same backend across Framer, Webflow and code sitesNoYes

If the first row is all you need, stay with Framer's destinations. The rest of this page is for when it isn't.

The catch: Framer posts from its servers

A webhook destination means Framer's servers send the post, not the visitor's browser. Three things change on the Ikiform side:

  • No origin. Server posts carry no Origin header, so with Allowed domains set, every post is treated as coming from the wrong site.
  • Shared IP addresses. Ikiform counts rate limits per IP address, and every post comes from a few Framer addresses instead of your visitors'.
  • No honeypot. Framer sends the inputs you built and nothing else, and its form can't run Ikiform's human check.

You can accept that, or fix it with a relay. Both paths are below.

A Framer form posting to Ikiform directly, and through a signed relayDirect webhookSigned relayFramerformFramerserversJSON, nooriginIkiformdomainsempty,RelaxedlimitFramerformFramerserverssigns everypostyourWorkerchecksFramer-SignatureIkiformsecret key,idempotencykey401,droppedsubmitwebhooksubmitwebhookforwardbad signatureA Framer form posting to Ikiform directly, and through a signed relayDirect webhookSigned relayFramer formFramer serversJSON, no originIkiformdomains empty,Relaxed limitFramer formFramer serverssigns every postyour WorkerchecksFramer-SignatureIkiformsecret key,idempotency key401,droppedsubmitwebhooksubmitwebhookforwardbad signature
  1. Direct: a visitor submits the Framer form, Framer's servers send it as JSON with no Origin header, and Ikiform accepts it with Allowed domains empty and a Relaxed rate limit.
  2. Relay: Framer's servers sign every post with your secret and send it to a Worker you deploy.
  3. The Worker recomputes Framer's signature. A post without a valid signature gets a 401 and goes no further.
  4. A valid post is forwarded to Ikiform with your secret key and Framer's submission id as the idempotency key, so a Framer retry never stores a second copy.
The relay is a dozen lines of real logic. In exchange, the form can keep strict allowed domains, because only your Worker holds the secret key.

Option 1: Ikiform as the webhook

  1. In Framer, give each input a lowercase name: name, work_email, company, team_size. Framer uses the names as JSON keys.
  2. Select the form, and next to Send To choose Add… > Webhook. Paste your endpoint. Leave the secret empty; Ikiform doesn't check Framer-Signature.
  3. In the Ikiform form's settings, leave Allowed domains empty, leave Spam check off, and set Rate limit to Relaxed.
  4. Publish. Framer only sends webhooks from the published site.
Webhook URL
https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx

Ikiform answers 201 once the submission is stored, which Framer counts as delivered. A 422 validation_failed fails the same way on all five retries, so fix the input names rather than wait. Spam scoring still runs on every post: links, spam phrases, disposable addresses and repeats.

Option 2: a signed relay

Deploy a Cloudflare Worker that checks Framer's signature and forwards valid posts with your secret key. Then the Ikiform form can list your Framer domain under Allowed domains with Reject, so anything that isn't a browser on your site or your Worker is refused.

src/index.ts
interface Env {
  FRAMER_SECRET: string;
  IKIFORM_SECRET_KEY: string;
}

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const encoder = new TextEncoder();

async function signatureFor(secret: string, body: ArrayBuffer, submissionId: string) {
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const id = encoder.encode(submissionId);
  const message = new Uint8Array(body.byteLength + id.byteLength);
  message.set(new Uint8Array(body), 0);
  message.set(id, body.byteLength);
  const digest = new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
  return `sha256=${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}

function sameText(a: string, b: string) {
  const left = encoder.encode(a);
  const right = encoder.encode(b);
  return left.byteLength === right.byteLength && crypto.subtle.timingSafeEqual(left, right);
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response(null, { status: 405 });
    const signature = request.headers.get("framer-signature") ?? "";
    const submissionId = request.headers.get("framer-webhook-submission-id") ?? "";
    const body = await request.arrayBuffer();
    if (!submissionId || !sameText(signature, await signatureFor(env.FRAMER_SECRET, body, submissionId))) {
      return new Response("Bad signature", { status: 401 });
    }

    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        "Idempotency-Key": submissionId,
      },
      body,
    });
    if (response.ok) return new Response(null, { status: 204 });
    return new Response(await response.text(), { status: response.status });
  },
};

Deploy it

Terminal
npx wrangler secret put FRAMER_SECRET
npx wrangler secret put IKIFORM_SECRET_KEY
npx wrangler deploy
  • FRAMER_SECRET is the secret you set on Framer's webhook destination. Framer requires at least 32 characters and won't show it again, so generate it, store it, and paste it in both places.
  • IKIFORM_SECRET_KEY is a secret API key from the Ikiform dashboard. Never put it in Framer.
  • The signature is sha256= plus the hex HMAC of the raw body followed by the submission id. Read the body once as bytes and use those same bytes for the check and the forward.
  • Idempotency-Key makes a Framer retry replay Ikiform's first answer instead of storing a duplicate. Keys are kept for 24 hours.
  • The Worker passes Ikiform's status through. Framer retries any answer that isn't 2xx, up to five times, so a 429 or 5xx gets another chance, while a 422 fails the same way each time until you fix the input names.

Then point Framer's webhook at the Worker's URL instead of the endpoint, with the secret filled in.

Every post now comes from your Worker. The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

Option 3: skip Framer's form

A code component renders a real form and posts from the visitor's browser, so origin checks, the honeypot and per-visitor rate limits work as on any site. You lose Framer's visual form editing for that form. The Framer docs guide has the component.

Check it works on Framer

  1. Publish the Framer site and submit the form there, not in the editor preview.
  2. Open Submissions in the dashboard. The new submission is there with every field you sent.
  3. Check the field names match your input names exactly, with no Framer-added keys.
  4. For the relay: send a request to the Worker with a made-up Framer-Signature and confirm it answers 401.
  5. For the relay: in the submission's details, the origin is empty and the status is received, not spam.
  6. Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an email field.
  7. If you added a webhook, open the endpoint in Webhooks. The delivery shows succeeded with your server's answer.

Gotchas

ProblemWhat to do
Every webhook post is under spam with origin_mismatchAllowed domains is set while Framer posts directly. Clear it, or put the relay in between.
The Worker answers 401 to real postsThe secret differs between Framer and the Worker, or something read the body as text and changed it. Paste the secret again and hash the raw bytes.
429 rate_limited during a launchAll posts share Framer's or Cloudflare's addresses. Choose Relaxed, or a Custom limit.
422 validation_failedThe post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id.
403 challenge_requiredThe form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off.

Questions

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