How to send Webflow form leads to Attio CRM with Webflow Cloud

How to send Webflow form leads to Attio CRM with Webflow Cloud

Learn how to connect a Webflow form to Attio with a Next.js app on Webflow Cloud.

How to send Webflow form leads to Attio CRM with Webflow Cloud

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Route every Webflow form lead straight into an Attio list, with no duplicates and no token in the browser.

Every hour a Webflow lead waits in an inbox before someone copies it into Attio is an hour it cools off. A small Next.js app on Webflow Cloud can help you close that gap. It receives each form submission, updates or creates the person in Attio by email, and adds them to a lead list.

I prefer this over a generic automation tool for teams that already deploy to Webflow Cloud, because the Attio token stays server-side and a repeat submission updates one record instead of creating another.

This guide shows how to send every Webflow form submission into an Attio list using a small Next.js app on Webflow Cloud. You’ll create a scoped Attio access token, deploy a Route Handler that upserts each lead by email, wire your Webflow form to it, and confirm the record in Attio.

What do you need to connect Attio CRM to Webflow?

Before setting up the connection between Webflow forms and Attio CRM, ensure you have the required accounts, tools, and access permissions ready.

Here is what you need to complete the integration:

  • A Webflow site on a paid Site plan: Step 5 adds a script to the page's custom code, which requires a paid plan, and Webflow Cloud hosts the app.
  • An Attio workspace: Admin access, because only workspace admins can create access tokens. For client work, create the token inside the client's own workspace.
  • An Attio list for leads: Create a list on the People object (for example, "Inbound leads") and note its slug, which the route uses to add each new contact.
  • A GitHub repository and npm: Webflow Cloud supports Next.js 15 or higher and only the npm package manager, so skip pnpm and yarn for this project.
  • A Webflow form: Use a form on a page you can publish and configure. You will add field names, a form ID, and a short script to it.

Page custom code needs a paid Site plan, and mounting the app on a custom domain requires Premium or higher.

6 steps to connect Attio CRM to Webflow forms in Webflow Cloud

The connection uses one Route Handler that calls Attio's upsert endpoints, deployed to a Webflow Cloud mount path, plus a Webflow form script that posts each submission to it. Create the token and app first, then deploy the handler, wire the form, and test the record in Attio.

1. Create an Attio access token with record write scopes

Create a dedicated token in Attio so it belongs to one job and can be revoked without breaking anything else in the workspace. In Attio, open Workspace settings, click the Developers tab, and click + New access token, as Attio's token guide describes.

This build grants the token four scopes:

  • Records: Read-write, because the route creates new people and updates existing ones when the email address already exists in the workspace.
  • Object configuration: Read, so Attio can resolve the People object and its standard attributes when the route writes to them.
  • List entries: Read-write, which lets the route add a person to your lead list and update that entry when the same person submits again.
  • List configuration: Read, so Attio can resolve the list slug you pass in the request path to the right list.

Leave every other scope off, so a leaked token can only touch people's records and lists.

Copy the token into a password manager or your team's secrets vault for use as a Webflow Cloud environment secret.

2. Scaffold the Next.js app for Webflow Cloud

Start with a fresh Next.js project using the App Router and npm. The built-in fetch can call Attio's REST API without an Attio SDK.

Run this from the folder where you keep projects, pinned to Next.js 15:

npx create-next-app@15 webflow-attio-leads --ts --app --use-npm
cd webflow-attio-leads

The installer prompts for the remaining options, and the defaults work. Push the project to a new GitHub repository once it finishes.

Leave next.config alone. Webflow's configuration docs say "You don't need to add an adapter, a base path, or an output mode," because Webflow Cloud injects the base path at build time.

If you later add middleware, keep it in middleware.ts, because the framework docs state that "Only Edge runtime middleware works on the Workers runtime" and Next.js 16's proxy.ts runs on Node.

3. Write the Route Handler that upserts the lead in Attio

Create app/api/lead/route.ts. The handler checks where the request came from, drops honeypot submissions, then makes two Attio calls. Both use PUT on Attio's upsert endpoints instead of POST on the create endpoints.

The matching_attribute=email_addresses parameter tells Attio to update the person who already has that email instead of creating a new one, so a repeat submission never makes a duplicate.

Before pasting the handler, confirm the attribute slugs on the People object in your Attio workspace settings. The code assumes the slugs email_addresses and name; check that your workspace's People object shows those slugs, and use the exact slug for any custom attribute you add later.

The route is public so that anyone can call it, and the origin check only blocks browsers from other sites; any script can send its own Origin header. Before going live, add rate limiting or a server-verified challenge such as Cloudflare Turnstile ahead of the Attio calls. Keep the token in a variable without the NEXT_PUBLIC_ prefix so it never reaches the browser.

Add the handler to app/api/lead/route.ts:

// app/api/lead/route.ts
import { NextResponse } from 'next/server';

const ATTIO_API = 'https://api.attio.com/v2';

type LeadPayload = {
  email?: string | null;
  firstName?: string | null;
  lastName?: string | null;
  website?: string | null; // honeypot field
};

function attioHeaders() {
  return {
    Authorization: `Bearer ${process.env.ATTIO_API_TOKEN}`,
    'Content-Type': 'application/json',
  };
}

export async function POST(request: Request) {
  const allowedOrigin = process.env.ALLOWED_ORIGIN;
  const origin = request.headers.get('origin');
  if (!allowedOrigin || origin !== allowedOrigin) {
    return NextResponse.json({ error: 'Forbidden origin' }, { status: 403 });
  }

  let body: LeadPayload;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
  }

  // Real visitors never see or fill the hidden "website" field.
  if (body.website) {
    return NextResponse.json({ ok: true });
  }

  const email = body.email?.trim().toLowerCase();
  if (!email || !email.includes('@')) {
    return NextResponse.json({ error: 'A valid email is required' }, { status: 400 });
  }

  const firstName = body.firstName?.trim() ?? '';
  const lastName = body.lastName?.trim() ?? '';
  const fullName = `${firstName} ${lastName}`.trim();

  const values: Record<string, unknown> = { email_addresses: [email] };
  if (fullName) {
    values.name = [{ first_name: firstName, last_name: lastName, full_name: fullName }];
  }

  const personRes = await fetch(
    `${ATTIO_API}/objects/people/records?matching_attribute=email_addresses`,
    {
      method: 'PUT',
      headers: attioHeaders(),
      body: JSON.stringify({ data: { values } }),
    }
  );

  if (!personRes.ok) {
    console.error('Attio person upsert failed', personRes.status, await personRes.text());
    return NextResponse.json({ error: 'CRM sync failed' }, { status: 502 });
  }

  const person = await personRes.json();
  const recordId: string = person.data.id.record_id;

  const listSlug = process.env.ATTIO_LIST_SLUG;
  if (listSlug) {
    const entryRes = await fetch(`${ATTIO_API}/lists/${listSlug}/entries`, {
      method: 'PUT',
      headers: attioHeaders(),
      body: JSON.stringify({
        data: { parent_record_id: recordId, parent_object: 'people', entry_values: {} },
      }),
    });

    if (!entryRes.ok) {
      console.error('Attio list entry failed', entryRes.status, await entryRes.text());
      return NextResponse.json({ error: 'List sync failed', recordId }, { status: 502 });
    }
  }

  return NextResponse.json({ ok: true, recordId });
}

Omit export const runtime = 'edge': the OpenNext adapter behind Webflow Cloud's Next.js build does not support that Next.js runtime target. Webflow's docs use "edge" to describe the Workers platform.

Commit and push; the POST endpoint (served at /leads/api/lead once mounted) returns the Attio record ID for every valid submission.

4. Create the Webflow Cloud project and set its environment variables

Create a Webflow Cloud project for your site from the GitHub repository. When you create the environment, pick the main branch and set the mount path to /leads. Every route in the app now lives under that prefix, so the lead Route Handler answers at /leads/api/lead.

Add these three variables to the environment and mark the token as a secret:

Variable Example value Secret
ATTIO_API_TOKEN The Attio access token Yes
ATTIO_LIST_SLUG inbound_leads No
ALLOWED_ORIGIN https://www.example.com No
Variable → Example value → Secret
ATTIO_API_TOKEN
The Attio access token
Yes
ATTIO_LIST_SLUG
inbound_leads
No
ALLOWED_ORIGIN
https://www.example.com
No

Webflow's environment variable docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," and secrets are redacted from build logs.

Set ALLOWED_ORIGIN to the exact scheme and host where the form is published, with no trailing slash; if the form is published on a staging domain, use that domain's origin instead.

Check the environment variables and the /leads mount path before deploying. Trigger a deployment. When it finishes, the environment shows a successful build, and the app is live under /leads on your site's domain.

5. Wire the Webflow form to the route with page custom code

Name the form fields so the script can read them, then attach a listener that posts each submission to the app. In the Designer, set each input's Name in its element settings to email, first-name, and last-name.

Add one more text input named website, give it a class with display set to none, and leave it empty; bots that fill every field reveal themselves there. Finally, select the Form element inside the Form Block and set its ID to lead-form.

Paste this script into the Before </body> tag field under Custom code in Page settings:

<script>
  document.addEventListener('DOMContentLoaded', function () {
    var form = document.querySelector('#lead-form');
    if (!form) return;

    form.addEventListener('submit', function () {
      var data = new FormData(form);
      fetch('/leads/api/lead', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        keepalive: true,
        body: JSON.stringify({
          email: data.get('email'),
          firstName: data.get('first-name'),
          lastName: data.get('last-name'),
          website: data.get('website')
        })
      }).catch(function (err) {
        console.error('Attio sync failed', err);
      });
    });
  });
</script>

The script never calls preventDefault, so it does not block the form's normal submit, and the form still sends to Webflow as before. The fetch path includes /leads because nothing rewrites it for you. Publish the site, and each submission now also posts to your Attio route.

If you would rather not run a page script, set the form's Send to destination to Webhook pointing at the route; Webflow then shows a secret key for authenticating its messages, and the webhook security guide explains why that check matters.

6. Submit a test lead and confirm the record in Attio

Test the route directly before touching the form; this lets you isolate a failure to one layer instead of two. Replace the domain with your published site, and keep the Origin header identical to your ALLOWED_ORIGIN value.

Send a sample lead from your terminal:

curl -X POST https://www.example.com/leads/api/lead \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.example.com" \
  -d '{"email":"test.lead@example.com","firstName":"Test","lastName":"Lead"}'

A healthy response is {"ok":true,"recordId":"..."}. Open People in Attio and search for the test email; the record should carry the name you sent, and your lead list should show one entry for it.

Now run the same command a second time. Compare the second recordId with the first, and count the entries for that email on your lead list. If you see a second person, the request is not matching on email; check the matching_attribute query in the handler.

Then fill in the live form on your published page with a different address. After submitting, look for that person in Attio's People view and in the lead list.

What causes the Attio and Webflow Cloud connection to fail?

Most failures come from the build or a missing mount path; the rest come from token scopes or attribute validation. The build log or the route's response tells you which to investigate.

The Webflow Cloud build fails on an API route that compiled before

Cause: The route may contain export const runtime = 'edge', an unsupported runtime directive. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter on Cloudflare Workers. Its bring-your-own-app page still tells readers to add the directive to API routes. That instruction describes the platform, but the directive selects a Next.js runtime that the adapter cannot build.

Fix: Delete the directive from every route file and redeploy. If you copied a snippet from elsewhere, search the project for runtime = before pushing, since one stray line in any route breaks the whole build.

The form submits, but the browser console shows a 404 for /api/lead

Cause: The script's fetch URL is missing the mount path. Webflow's own docs note that client-side fetch calls must include the base path manually. With the app mounted at /leads, the handler answers at /leads/api/lead, not /api/lead.

Fix: Put the full mount path in the script's fetch URL and republish the page. For client components inside the Next.js app that call this route, expose the mount path as NEXT_PUBLIC_BASE_PATH and prefix their requests with it, since Next.js will not prepend it to fetch either.

Submit the published form again and look for the person in Attio's People view and lead list.

The route returns 502 "CRM sync failed," and the log shows Attio 401 or 403

Cause: The deployed environment is sending a missing or different token, or the token lacks a scope. A missing variable goes out as the header Bearer undefined, which Attio rejects with a 401. If the project has more than one environment, check that the token was added to the environment serving your mount path.

Fix: Find the handler's console.error line in the environment's runtime logs. Use the Attio status and response body it records to separate an authentication error from a scope error. Confirm the variable name matches exactly in the environment serving your mount path, check the Records and List entries scopes, and compare ALLOWED_ORIGIN character by character with the page's origin.

The route returns 502 "CRM sync failed" with an Attio validation error

Cause: The payload does not match the attribute's type or slug in your workspace. This can happen when someone adds a custom attribute and writes its display name into the code instead of its slug, or sends a plain string where Attio expects the structured name value.

Use the handler's structured name value as a reference; any attribute you add must follow the value shape its type requires.

Fix: Read the Attio error body that the handler's console.error line logged. Compare the value shape against Attio's API reference for that attribute type after checking the slug on the People object in your Attio workspace settings.

If the response says "List sync failed" instead, the person was saved, and the ATTIO_LIST_SLUG value or the List entries scope is the problem.

What you can build next with Attio and Webflow

Hidden form fields can carry the page URL or campaign parameters into custom Attio attributes, and a second form can route demo requests to a separate list with its own stages. The same webhook pattern sends leads to Pipedrive if another team member uses a different CRM.

For the list side of the build, Attio's list entry reference documents every field the second call can set, and Webflow's developer docs show the payload a form webhook sends if you switch to that option.

Frequently asked questions

Can I connect Attio to Webflow without writing code?

Yes. Webflow's Attio integration page lists form embeds that write to Attio directly, such as Attio Forms or Fillout, and automation platforms such as Zapier, whose Attio action can create or update a record matched on email. The Route Handler suits teams that already deploy to Webflow Cloud and want the token and the matching rules in their own code.

What happens to a lead if Attio is unavailable when the form is submitted?

The form still submits, and because the form also sends to Webflow, the lead stays in the site's form submissions. Replay any failed lead from there with the curl test command, then confirm the record in Attio.

Does the origin check stop spam submissions?

No. A bot can bypass both the origin check and honeypot, because a script can send its own Origin header. For forms that draw abuse, I recommend adding route-level rate limiting or a server-verified challenge before the Attio calls. You should also assess a spend cap before deploying the route.

How do I record UTM parameters on each Attio lead?

Add hidden inputs, fill them from the page URL with a short script in the same Before </body> tag custom code as the lead form script, and include them in the JSON body. In Attio, create matching text attributes in your workspace, then add their slugs to the handler's values or entry_values object.

Who should own the Attio token on a client site?

Create the token in the client's own Attio workspace, and name it after the site so you can identify its purpose. To rotate it, create a new token in Attio, paste the new value into the Webflow Cloud secret, redeploy, then delete the old token.


Last Updated
October 30, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Lancez-vous gratuitement

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.

Get started — it’s free
Voir la démo

Essayez Webflow aussi longtemps que vous le souhaitez avec notre forfait Starter gratuit. Souscrivez à un forfait Site payant pour publier, héberger et débloquer des fonctionnalités supplémentaires.