How to connect Printful to Webflow Ecommerce with Webflow Cloud

How to connect Printful to Webflow Ecommerce and receive order events with Webflow Cloud

Learn how to build a Webflow Cloud app that sends Webflow orders to Printful and marks them fulfilled when they ship.

How to connect Printful to Webflow Ecommerce and receive order events with Webflow Cloud

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

Printful no longer syncs with Webflow on its own, so a small Webflow Cloud app can carry each order to Printful and each shipment back.

Printful ended its Webflow integration on March 3, 2025, so orders from a Webflow Ecommerce store no longer reach Printful on their own. Webflow's Printful integration page now points to automation tools or a custom build, and this guide takes the custom route.

In this build, a small Webflow Cloud app receives Webflow's new order webhook and creates the matching Printful order through Printful's API. When Printful ships the package, the same app adds the tracking number to the Webflow order and marks it fulfilled, so the customer gets Webflow's shipping email.

What do you need to connect Printful to Webflow Ecommerce in Webflow Cloud?

You need five things:

  • Webflow Ecommerce site: A site on an Ecommerce plan with the products you will print, and a site token with the ecommerce:read, ecommerce:write and sites:write scopes.
  • Printful account: A Printful manual order / API store, the store type Printful offers for API integrations, with your products and their designs created in it.
  • Printful private token: Created in Printful's developer portal for that store, so requests need no separate store ID header.
  • Node.js 22, Next.js 15 and npm: The bring-your-own-app page sets the floors and states, "Currently, Webflow Cloud supports only the npm package manager."
  • A random token for Printful: A long value for the Printful webhook URL, generated with openssl rand -hex 32. The Webflow webhook secret arrives later, when you register that webhook in step 5.

6 steps to connect Printful to Webflow Ecommerce in Webflow Cloud

The build is a Next.js app with two Route Handlers. One receives Webflow orders and creates them in Printful, and the other receives Printful shipments and fulfills the Webflow order.

1. Match each Webflow SKU to its Printful sync variant ID

Printful identifies each product variant you sell, such as a shirt in one size and color, by a sync variant ID. Webflow identifies the same variant by its SKU. The order route turns one into the other by reading the SKU as the Printful ID, so each Webflow variant's SKU field has to hold exactly that number.

List your Printful products, then read the variants of each one with your Printful token:

curl -s https://api.printful.com/store/products \
  -H "Authorization: Bearer $PRINTFUL_API_TOKEN" | jq '.result[] | {id, name}'

curl -s https://api.printful.com/store/products/PRODUCT_ID \
  -H "Authorization: Bearer $PRINTFUL_API_TOKEN" | jq '.result.sync_variants[] | {id, name}'

Create each product in Webflow with matching variants, either by hand in the Designer or with an import, and paste each sync variant id into the matching Webflow variant's SKU field. A variant whose SKU is not a Printful ID cannot be fulfilled, and the order route logs it by name.

At the end of this step, every printable Webflow variant carries a numeric SKU that matches a sync variant in Printful.

2. Scaffold the app and set the environment variables

Create the app with npm and pin Next.js 15, the release Webflow Cloud supports:

npx create-next-app@15 printful-bridge --typescript --app
cd printful-bridge

Leave next.config alone; Webflow's configuration docs say "You don't need to add an adapter, a base path, or an output mode." Do not add export const runtime = 'edge' to either route.

The bring-your-own-app page still suggests it for API routes, but the OpenNext Cloudflare adapter behind Webflow Cloud does not support that Next.js runtime target, and the build fails.

The app reads six variables, set in .env.local locally and in the Webflow Cloud environment for the deploy:

  • WEBFLOW_SITE_ID and WEBFLOW_SITE_TOKEN: The site ID from Site settings and the site token from the prerequisites; mark the token as secret.
  • WEBFLOW_WEBHOOK_SECRET: The secretKey Webflow returns in step 5; mark it secret and leave it empty until then.
  • PRINTFUL_API_TOKEN: The Printful private token; mark it secret.
  • PRINTFUL_WEBHOOK_TOKEN: The random string Printful will send back in the webhook URL; mark it secret.
  • PRINTFUL_AUTO_CONFIRM: Set to true to have Printful start fulfilling orders immediately, or leave it unset to create drafts you confirm in the Printful dashboard.

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

3. Write the order route that sends Webflow orders to Printful

Webflow signs webhooks created through its API with two headers: x-webflow-timestamp in milliseconds, and x-webflow-signature, a hex HMAC SHA256 of the timestamp, a colon, and the raw body.

The check lives in its own module so it reads cleanly.

Create lib/webflow-signature.ts:

// lib/webflow-signature.ts
const MAX_AGE_MS = 5 * 60 * 1000;
const encoder = new TextEncoder();

async function hmacHex(secret: string, message: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
  return Array.from(new Uint8Array(signature))
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
}

function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

export async function isSignedByWebflow(request: Request, rawBody: string, secret: string) {
  const timestamp = request.headers.get('x-webflow-timestamp') ?? '';
  const signature = request.headers.get('x-webflow-signature') ?? '';
  const sentAt = Number(timestamp);
  if (!timestamp || !Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > MAX_AGE_MS) {
    return false;
  }
  return safeEqual(signature, await hmacHex(secret, `${timestamp}:${rawBody}`));
}

The order route verifies the signature, maps the Webflow order to a Printful order, and creates it with Printful's orders API.

Route Handler implementation

Create app/api/webflow/order/route.ts:

// app/api/webflow/order/route.ts
import { NextResponse } from 'next/server';
import { isSignedByWebflow } from '@/lib/webflow-signature';

type Address = {
  addressee?: string;
  line1?: string;
  line2?: string;
  city?: string;
  state?: string;
  country?: string;
  postalCode?: string;
};

type WebflowOrder = {
  orderId: string;
  customerInfo?: { email?: string };
  shippingAddress?: Address;
  purchasedItems?: { count: number; variantSKU?: string; variantName?: string }[];
};

export async function POST(request: Request) {
  const secret = process.env.WEBFLOW_WEBHOOK_SECRET;
  const printfulToken = process.env.PRINTFUL_API_TOKEN;
  if (!secret || !printfulToken) {
    return NextResponse.json({ error: 'Route is not configured' }, { status: 500 });
  }

  const rawBody = await request.text();
  if (!(await isSignedByWebflow(request, rawBody, secret))) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(rawBody) as { triggerType?: string; payload?: WebflowOrder };
  const order = event.payload;
  if (event.triggerType !== 'ecomm_new_order' || !order?.orderId) {
    return NextResponse.json({ ignored: true });
  }

  const items = (order.purchasedItems ?? []).map((item) => ({
    sync_variant_id: Number(item.variantSKU),
    quantity: item.count,
    name: item.variantName,
  }));
  const unmapped = items.filter((item) => !Number.isInteger(item.sync_variant_id));
  if (unmapped.length > 0 || items.length === 0) {
    // A mapping error will not fix itself on retry, so log it and answer 200.
    console.error('Order has items without a Printful SKU', order.orderId, unmapped);
    return NextResponse.json({ ok: false, reason: 'unmapped items' });
  }

  const address = order.shippingAddress ?? {};
  const state = address.state ?? '';
  const recipient = {
    name: address.addressee,
    address1: address.line1,
    address2: address.line2,
    city: address.city,
    country_code: address.country,
    zip: address.postalCode,
    email: order.customerInfo?.email,
    ...(state.length <= 3 ? { state_code: state } : { state_name: state }),
  };

  const confirm = process.env.PRINTFUL_AUTO_CONFIRM === 'true' ? '?confirm=1' : '';
  let response: Response;
  try {
    response = await fetch(`https://api.printful.com/orders${confirm}`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${printfulToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        external_id: `webflow-${order.orderId}`,
        recipient,
        items: items.map(({ sync_variant_id, quantity }) => ({ sync_variant_id, quantity })),
      }),
      signal: AbortSignal.timeout(10000),
    });
  } catch {
    // A network failure is worth a retry, so answer non-200 and let Webflow resend.
    return NextResponse.json({ error: 'Printful unreachable' }, { status: 502 });
  }

  const result = await response.text();
  if (response.status >= 500) {
    return NextResponse.json({ error: 'Printful error' }, { status: 502 });
  }
  if (!response.ok) {
    console.error('Printful rejected order', order.orderId, response.status, result);
    return NextResponse.json({ ok: false, status: response.status });
  }

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

The route answers Webflow with a 200 for every problem a retry cannot fix, such as an unmapped SKU or an address Printful rejects, and logs it instead. That matters because Webflow retries any non-200 response up to three more times and deactivates a webhook that keeps failing, so a single bad order should not switch off order delivery for the whole store.

Network failures and Printful server errors get a 502, and Webflow's retry is the right response to those. Sending the Webflow order ID as external_id lets Printful refuse a second copy of the same order if a retry arrives after a success.

Printful accepts a two-letter state code, and Webflow sometimes sends the full state name, so the route passes whichever form it receives in the matching Printful field.

4. Write the shipping route that marks Webflow orders fulfilled

When Printful ships an order, it sends a package_shipped webhook with the carrier and tracking number. Printful's v1 webhooks are not signed, so the route checks a secret token in its own URL instead.

It reads the Webflow order ID from external_id, adds the tracking details via Webflow's update order endpoint, and then marks the order fulfilled, which sends the customer Webflow's order-fulfilled email.

Create app/api/printful/shipped/route.ts:

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

type ShippedEvent = {
  type?: string;
  data?: {
    shipment?: { carrier?: string; service?: string; tracking_number?: string; tracking_url?: string };
    order?: { external_id?: string };
  };
};

async function webflow(path: string, method: string, body: unknown) {
  return fetch(`https://api.webflow.com/v2/sites/${process.env.WEBFLOW_SITE_ID}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.WEBFLOW_SITE_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(8000),
  });
}

export async function POST(request: Request) {
  const expected = process.env.PRINTFUL_WEBHOOK_TOKEN;
  const token = new URL(request.url).searchParams.get('token') ?? '';
  if (!expected || token !== expected) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  const event = (await request.json().catch(() => null)) as ShippedEvent | null;
  const externalId = event?.data?.order?.external_id ?? '';
  if (event?.type !== 'package_shipped' || !externalId.startsWith('webflow-')) {
    return NextResponse.json({ ignored: true });
  }

  const orderId = externalId.slice('webflow-'.length);
  const shipment = event.data?.shipment ?? {};

  try {
    const tracking = await webflow(`/orders/${orderId}`, 'PATCH', {
      shippingProvider: [shipment.carrier, shipment.service].filter(Boolean).join(' '),
      shippingTracking: shipment.tracking_number,
      shippingTrackingURL: shipment.tracking_url,
    });
    const fulfilled = await webflow(`/orders/${orderId}/fulfill`, 'POST', {
      sendOrderFulfilledEmail: true,
    });
    if (!tracking.ok || !fulfilled.ok) {
      console.error('Webflow update failed', orderId, tracking.status, fulfilled.status);
      return NextResponse.json({ error: 'Webflow update failed' }, { status: 502 });
    }
  } catch {
    return NextResponse.json({ error: 'Webflow unreachable' }, { status: 502 });
  }

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

Here, a non-200 answer is deliberate on any failure, because Printful retries a webhook that fails and Webflow's API errors are usually brief. The tracking update runs before the fulfill call so the fulfilled email can carry the tracking link.

The URL token comparison is a simple equality check, which is fine for a long random value only Printful and your environment know.

5. Deploy the app and register both webhooks

Deploy the app to Webflow Cloud and mount it, for example, at /printful, then register the two webhooks against the deployed routes. Both registrations are API calls; neither has a dashboard form that works for this build.

Register Webflow's new order webhook through the Data API, because Webflow signs only webhooks created there:

curl -X POST "https://api.webflow.com/v2/sites/$WEBFLOW_SITE_ID/webhooks" \
  -H "Authorization: Bearer $WEBFLOW_SITE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"triggerType":"ecomm_new_order","url":"https://www.example.com/printful/api/webflow/order"}'

The response includes a secretKey, shown only once. Save it as WEBFLOW_WEBHOOK_SECRET in the Webflow Cloud environment and push a commit so the deployment picks it up.

Then register the shipping webhook with Printful:

curl -X POST https://api.printful.com/webhooks \
  -H "Authorization: Bearer $PRINTFUL_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://www.example.com/printful/api/printful/shipped?token=YOUR_PRINTFUL_WEBHOOK_TOKEN","types":["package_shipped"]}'

Printful allows one webhook URL per store in v1, and this call replaces any configuration the store already had, so check with GET https://api.printful.com/webhooks first if another tool uses the store. Both URLs include the mount path, because each route lives under it.

6. Place a test order end to end

With PRINTFUL_AUTO_CONFIRM unset, place a real order on the published store using a test payment. Within a few seconds, the order should appear in the Printful dashboard as a draft with the matching items and the customer's address, and the Webflow Cloud logs should show no errors from the order route.

A draft costs nothing, because Printful charges only for confirmed orders.

To test the return path without shipping anything, send the shipping route the shape Printful sends, using a real Webflow order ID:

curl -X POST "https://www.example.com/printful/api/printful/shipped?token=YOUR_PRINTFUL_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"package_shipped","data":{"order":{"external_id":"webflow-ORDER_ID"},"shipment":{"carrier":"USPS","service":"Ground","tracking_number":"TEST123","tracking_url":"https://example.com/track/TEST123"}}}'

A response of {"ok":true} and a fulfilled order with tracking details in Webflow's Orders view mean both directions work. Delete the test draft in Printful, then set PRINTFUL_AUTO_CONFIRM to true when you are ready for orders to go straight to production.

What causes the Printful and Webflow Cloud integration to fail?

Most failures come from SKU mapping or webhook registrations; the rest come from Webflow Cloud's build and environment. Each entry starts from what the logs or the dashboards show.

A Webflow order never appears in Printful

Cause: The order route logged the order instead of sending it. "Order has items without a Printful SKU" means a variant's SKU is not a numeric sync variant ID. "Printful rejected order" means Printful refused the request, most often for an address it could not validate or a sync variant that was deleted in Printful.

If the logs show nothing, the webhook was created in the dashboard, so it arrived unsigned and the route returned 401, or it was never registered.

Fix: Correct the SKU in Webflow or the address in Printful, and create the missing order by hand for that customer. For a missing or unsigned webhook, list the site's webhooks with GET /v2/sites/{site_id}/webhooks, delete any dashboard copy, and register it again as in step 5 with the new secretKey saved.

Orders reach Printful but never get marked fulfilled

Cause: The shipping webhook is missing, points at a URL without the mount path or token, or was replaced when another tool registered its own webhook on the same Printful store. A 502 from the route in Printful's retry history points instead at the site token, which needs the ecommerce:write scope.

Fix: Run GET https://api.printful.com/webhooks and compare the URL with the deployed route, including the mount path and ?token=. Register it again if it differs, and regenerate the Webflow site token with ecommerce:write if the route reports a failed update.

The Webflow Cloud build fails on a route that works under next dev

Cause: A route declares export const runtime = 'edge', which the OpenNext Cloudflare adapter does not support, or a Next.js 16 upgrade renamed middleware to proxy.ts.

Webflow's framework customization docs state that "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."

Fix: Delete the directive from every route and redeploy; the routes already run on Workers without it. Keep any middleware file named middleware.ts, regardless of the Next.js version.

Every webhook returns 500 after deploy

Cause: A variable exists in .env.local but not in the Webflow Cloud environment, so the route's configuration check fails before it reads the request.

Fix: Add the missing variable in the environment and push a commit so a new deployment picks it up. Secrets are redacted from build logs, so confirm a value by pasting it again rather than by reading the log.

What you can build next with Printful and Webflow

The same bridge can listen for Printful's stock and product update events and keep Webflow inventory in step, using Webflow's inventory endpoint for each SKU. Both directions rely on signed webhooks, and Webflow's guide to webhook security explains what the signature check protects against.

For the full order object and the other order endpoints, see Webflow's developer docs.

Frequently asked questions

Does this charge me for every order straight away?

Only if PRINTFUL_AUTO_CONFIRM is true. Printful charges for orders once they are confirmed, so with the variable unset, every order waits as a draft until someone confirms it in the Printful dashboard. Running drafts for the first few days catches mapping mistakes before any money moves.

What happens if Printful is down when an order comes in?

The order route answers 502, and Webflow retries the webhook up to three more times, ten minutes apart. If Printful is still down after that, the order stays in Webflow's Orders view, and you can create it in Printful by hand. Repeated failures can deactivate the Webflow webhook, so check its status after any long Printful outage.

Can I keep using Printful's product mockups and pricing in Webflow?

Not automatically. Mockups and prices live in Printful, and nothing copies them into Webflow now that the native integration is gone. Upload the mockups as product images and set prices in Webflow when you create each product, and keep the SKU equal to the sync variant ID so orders still map.


Last Updated
October 19, 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

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.