Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.weaverse.io/llms.txt

Use this file to discover all available pages before exploring further.

Contact Form with Klaviyo

Introduction

This guide shows you how to build a contact form as a Weaverse section whose submissions are emailed to a notification inbox through a Klaviyo API-based transactional flow. Instead of sending mail directly from your storefront (which requires an SMTP provider, deliverability tuning, and secret management), the form fires a single Klaviyo event. A Klaviyo flow listens for that event’s metric and renders a transactional email. You get spam protection (hCaptcha), a tamper-resistant recipient, and a merchant-editable form — without running your own mail infrastructure.

Architecture at a glance

┌──────────────────────────┐      POST /api/contact      ┌────────────────────────────┐
│  Weaverse section         │  ───────────────────────▶  │  Resource route action      │
│  app/sections/            │   name, email, message,     │  app/routes/api/contact.ts  │
│  contact-form/index.tsx   │   inquiry_type, recipient,  │                             │
│  (useFetcher + hCaptcha)  │   h-captcha-response        │  1. verify hCaptcha         │
└──────────────────────────┘                              │  2. resolve recipient       │
                                                          │  3. POST Klaviyo event      │
                                                          └──────────────┬──────────────┘
                                                                         │ Create Event

                                                          ┌────────────────────────────┐
                                                          │  Klaviyo                    │
                                                          │  metric ▸ flow ▸ email      │
                                                          │  (marked transactional)     │
                                                          └──────────────┬──────────────┘

                                                              Notification inbox
The submitter is not the email recipient. The event’s profile.email is your notification inbox; the submitter’s details ride along as event properties that the flow email template renders.

Prerequisites

1

Klaviyo private API key

A private key with the events:write scope (Full access also works but is broader than needed). Create it in Klaviyo under Settings → API keys → Create Private API Key → Custom Key. You only see the key once.
2

hCaptcha keys

A site key and secret key from hCaptcha. The Pilot theme already uses hCaptcha for the newsletter form, so you can reuse the same pair.
3

Environment variables

Set the following on Oxygen (or in .env for local dev, then npx shopify hydrogen env pull):
# Klaviyo private key (must include events:write)
KLAVIYO_PRIVATE_API_TOKEN=pk_xxxxxxxxxxxxxxxxxxxxxxxxx

# Default notification inbox (used when the section field is blank)
CONTACT_FORM_RECIPIENT_EMAIL=you@yourdomain.com

# Recommended: allowlist of addresses the section is permitted to send to.
# Comma-separated. If unset, any valid email set in the section is trusted.
CONTACT_FORM_ALLOWED_RECIPIENTS=you@yourdomain.com,support@yourdomain.com

# hCaptcha (PUBLIC_ prefix exposes the site key to the browser)
PUBLIC_HCAPTCHA_SITE_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
HCAPTCHA_SECRET_KEY=0xXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Step 1 — Create the API route

The form posts to a resource route at app/routes/api/contact.ts. The action does three things: verify the captcha, resolve a safe recipient, then POST a Klaviyo event. Only the parts that carry the integration logic are shown below — the rest (reading form fields, 400s for missing email/message/captcha) is routine. Resolve a safe recipient. The section’s recipient arrives as a tamperable form field, so it’s only honored when it passes the optional allowlist (see the security model):
function resolveRecipient(configured, envRecipient, allowList) {
  const fallback = envRecipient?.trim() || FALLBACK_RECIPIENT;
  if (!configured || !EMAIL_PATTERN.test(configured)) return fallback;
  const allowed = (allowList || "")
    .split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
  if (allowed.length === 0 || allowed.includes(configured.toLowerCase())) {
    return configured;
  }
  return fallback;
}
Verify hCaptcha server-side before sending anything to Klaviyo:
async function verifyHCaptcha(token: string, secret: string) {
  const body = new URLSearchParams({ secret, response: token });
  const res = await fetch("https://api.hcaptcha.com/siteverify", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body,
  });
  return ((await res.json()) as { success: boolean }).success === true;
}
Build and POST the Klaviyo event — the core of the integration. Note profile.email is the recipient (your inbox), and the submitter’s data goes in properties:
const payload = {
  data: {
    type: "event",
    attributes: {
      properties: {
        sender_name: name, sender_email: email, sender_phone: phone,
        subject, message, inquiry_type: inquiryType, is_urgent: isUrgent,
        source_url: request.headers.get("referer") || "",
        submitted_at: new Date().toISOString(),
      },
      metric:  { data: { type: "metric",  attributes: { name: metricName } } },
      profile: { data: { type: "profile", attributes: { email: recipientEmail } } },
    },
  },
};

const res = await fetch("https://a.klaviyo.com/api/events", {
  method: "POST",
  headers: {
    accept: "application/vnd.api+json",
    revision: "2024-10-15",
    "content-type": "application/vnd.api+json",
    Authorization: `Klaviyo-API-Key ${apiToken}`,
  },
  body: JSON.stringify(payload),
});
// res.status === 202 → accepted; anything else is an error
The action exports a result type the section reads via useFetcher:
export type ContactFormApiPayload = {
  ok: boolean;
  error?: string;
  klaviyoData?: unknown;
};

Why the Klaviyo payload looks like this

Payload partPurpose
metric.data.attributes.nameThe metric your Klaviyo flow triggers on. Klaviyo auto-creates it on first event. Configurable per-section so one codebase can power multiple flows.
profile.data.attributes.emailThe recipient of the resulting transactional email — your notification inbox, not the submitter.
attributes.properties.*Submitter data exposed to the flow email template as {{ event.<key> }} variables.
revision headerKlaviyo API version pin. Keep it explicit so a server-side API change can’t silently alter behavior.
A successful Create Event call returns HTTP 202 with an empty body.

Step 2 — Register the route

Add the route inside the api prefix block in app/routes.ts:
...prefix("api", [
  route("contact", "routes/api/contact.ts"),
  route("countries", "routes/api/countries.ts"),
  // ...existing api routes
]),
This makes the action reachable at POST /api/contact (and /:locale/api/contact).

Step 3 — Build the Weaverse section

The section at app/sections/contact-form/index.tsx is a standard Weaverse component — a default-exported component plus a schema. Below are the integration-specific pieces; the rest is ordinary form markup (name / email / message / optional checkbox / dropdown). Submit with useFetcher and gate on the captcha token:
const fetcher = useFetcher<ContactFormApiPayload>();
const hCaptchaSiteKey = useRouteLoaderData<RootLoader>("root")?.hCaptchaSiteKey;
const [captchaToken, setCaptchaToken] = useState("");

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  if (hCaptchaSiteKey && !captchaToken) {
    e.preventDefault();
    setLocalError("Please complete the captcha before submitting.");
  }
}

// Reset the captcha after every response so the token can't be reused
useEffect(() => {
  if (fetcher.state === "idle" && fetcher.data) {
    captchaRef.current?.resetCaptcha();
    setCaptchaToken("");
    if (fetcher.data.ok) formRef.current?.reset();
  }
}, [fetcher.state, fetcher.data]);
The form posts to the route, carrying metric and recipient as hidden fields plus the captcha token:
<fetcher.Form method="POST" action="/api/contact" onSubmit={handleSubmit}>
  <input type="hidden" name="metric" value={metricName} />
  <input type="hidden" name="recipient" value={recipientEmail} />

  {/* name / email* / message* / urgent checkbox / inquiry_type select … */}

  <input type="hidden" name="h-captcha-response" value={captchaToken} />
  {hCaptchaSiteKey && (
    <HCaptcha
      ref={captchaRef}
      sitekey={hCaptchaSiteKey}
      onVerify={(token) => setCaptchaToken(token)}
      onExpire={() => setCaptchaToken("")}
    />
  )}

  <button
    type="submit"
    disabled={isSubmitting || (!!hCaptchaSiteKey && !captchaToken)}
  >
    {buttonText}
  </button>
</fetcher.Form>
Expose metricName and recipientEmail in the schema so merchants can point the form at the right Klaviyo flow and inbox without touching code (other inputs — labels, dropdown options, button text — follow the same pattern):
export const schema = createSchema({
  type: "contact-form",
  title: "Contact form",
  settings: [
    {
      group: "Content",
      inputs: [
        {
          type: "text",
          name: "metricName",
          label: "Klaviyo metric name",
          defaultValue: "Contact Form Submission",
          helpText: "Must match the metric your Klaviyo flow triggers on.",
        },
        {
          type: "text",
          name: "recipientEmail",
          label: "Notification recipient email",
          placeholder: "you@yourstore.com",
          helpText: "Where submissions are emailed (subject to the allowlist).",
        },
        // …heading, description, field labels, dropdown options, button text
      ],
    },
    ...sectionSettings,
  ],
  presets: { metricName: "Contact Form Submission", recipientEmail: "" },
});
The hCaptcha site key is read from the root loader (rootData.hCaptchaSiteKey, sourced from PUBLIC_HCAPTCHA_SITE_KEY). The Pilot theme already exposes this for its newsletter form. If your root loader does not return it yet, add it there — the section renders without a captcha when the key is absent, and the server rejects captcha-less submissions, so you’d be blocked until the key is wired up.

Step 4 — Register the section

Add the section to app/weaverse/components.ts so the Weaverse builder can discover it:
// Alongside the other section imports (keep alphabetical):
import * as ContactForm from "~/sections/contact-form";

// In the registered components array:
export const components: HydrogenComponent[] = [
  // ...other components
  ContactForm,
  // ...
];

Step 5 — Declare the environment types

Add the new variables to the Env interface in env.d.ts so context.env.* is typed:
interface Env extends HydrogenEnv {
  // ...existing vars
  KLAVIYO_PRIVATE_API_TOKEN: string;
  CONTACT_FORM_RECIPIENT_EMAIL: string;
  CONTACT_FORM_ALLOWED_RECIPIENTS: string;
  PUBLIC_HCAPTCHA_SITE_KEY: string;
  HCAPTCHA_SECRET_KEY: string;
}

Step 6 — Configure the Klaviyo flow

Code alone won’t deliver mail — the event needs a flow that produces a transactional email.
1

Trigger the metric once

Submit the form once (or use the curl smoke test below) so the Contact Form Submission metric appears in Analytics → Metrics. You can also pre-create it to build the flow first.
2

Create the flow

Flows → Create Flow → Create from Scratch. Set the trigger to Metric → Contact Form Submission. Leave the trigger filter empty so every submission emails you. Drag a Flow Email action in right after the trigger.
3

Template the email with event variables

Reference the event properties the action sends:
Subject: New contact form message from {{ event.sender_name|default:"a visitor" }}

From: {{ event.sender_name }} <{{ event.sender_email }}>
Phone: {{ event.sender_phone|default:"—" }}
Inquiry type: {{ event.inquiry_type|default:"—" }}
Urgent: {{ event.is_urgent }}
Source: {{ event.source_url }}
Submitted at: {{ event.submitted_at }}

---
{{ event.message }}
4

Mark the email transactional

Edit email → Settings → Apply for transactional status / This is a transactional email → ON. This is what lets the email deliver regardless of marketing consent. Then set Smart Sending OFF — every submission goes to the same inbox, so smart sending would silently drop repeat notifications inside its window.
5

Go live

Save the flow and switch it from Draft → Live.
Branded sending domain required for production. Klaviyo will skip transactional sends whose from address is not on your verified branded sending domain (“Sender email addresses need to use your branded sending domain”). Set this up in Klaviyo under Settings → Domains and complete DNS (CNAME) verification before launch, or notifications will silently stop.

Event property → template variable map

Form field (name=)Event propertyTemplate variable
namesender_name{{ event.sender_name }}
emailsender_email{{ event.sender_email }}
phonesender_phone{{ event.sender_phone }}
subjectsubject{{ event.subject }}
messagemessage{{ event.message }}
inquiry_typeinquiry_type{{ event.inquiry_type }}
urgentis_urgent (bool){{ event.is_urgent }}
— (server-set)source_url{{ event.source_url }}
— (server-set)submitted_at{{ event.submitted_at }}

Security model: the recipient allowlist

The “Notification recipient email” section setting is convenient for merchants but is a public, tamperable input — it travels as a normal form field, so anyone can POST /api/contact with an arbitrary recipient. Without a guard, your endpoint becomes an open mail relay. resolveRecipient() resolves the address in this order:
  1. The section’s recipient field — only if it’s a valid email and (when CONTACT_FORM_ALLOWED_RECIPIENTS is set) it appears in that allowlist.
  2. CONTACT_FORM_RECIPIENT_EMAIL (server-side env).
  3. The hardcoded FALLBACK_RECIPIENT constant.
Always set CONTACT_FORM_ALLOWED_RECIPIENTS in production. With it unset, any well-formed email configured in the section is trusted — fine for local dev, unsafe for a public storefront. hCaptcha raises the cost of automated abuse, but the allowlist is what actually contains where mail can go.

Step 7 — Test end to end

  1. Open the page on the storefront (or npm run dev), fill the form, complete hCaptcha, submit.
  2. The fetch to /api/contact should return 202; the form resets and the success banner shows.
  3. In Klaviyo: Analytics → Metrics → Contact Form Submission shows the event within ~30s, and Flows → your flow → Analytics shows one recipient processed.
  4. Check the resolved recipient inbox.
Curl smoke test (bypasses hCaptcha — only meaningful if you temporarily comment out the captcha check while debugging; it hits Klaviyo directly):
curl -X POST https://a.klaviyo.com/api/events \
  -H "Authorization: Klaviyo-API-Key $KLAVIYO_PRIVATE_API_TOKEN" \
  -H "revision: 2024-10-15" \
  -H "accept: application/vnd.api+json" \
  -H "content-type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "event",
      "attributes": {
        "properties": { "sender_name": "Test", "message": "Hello" },
        "metric":  { "data": { "type": "metric",  "attributes": { "name": "Contact Form Submission" } } },
        "profile": { "data": { "type": "profile", "attributes": { "email": "you@yourdomain.com" } } }
      }
    }
  }'
A successful call returns HTTP 202 with an empty body.

Troubleshooting

SymptomLikely cause / fix
Missing KLAVIYO_PRIVATE_API_TOKENEnv var not set on Oxygen or not pulled locally. Re-run npx shopify hydrogen env pull.
Captcha verification is requiredPUBLIC_HCAPTCHA_SITE_KEY not exposed at runtime, or the root loader doesn’t return hCaptchaSiteKey. Verify the PUBLIC_ prefix.
403 from KlaviyoThe private key is missing the events:write scope.
202 but no emailFlow still in Draft, email not marked transactional, or Smart Sending filtered the repeat send.
Event arrives but flow doesn’t fireSection’s Klaviyo metric name doesn’t match the metric the flow triggers on.
Email arrives but variables are blankTemplate uses wrong property names — they must be event.sender_name, event.sender_email, event.sender_phone, event.subject, event.message, event.inquiry_type, event.is_urgent, event.source_url, event.submitted_at.
”Update this action to align with your branded sending domain”The flow email’s from address isn’t on a verified branded sending domain. Complete domain DNS verification in Klaviyo.
Notifications going to the wrong/unexpected inboxThe section recipient failed the allowlist (or is invalid) and fell back to CONTACT_FORM_RECIPIENT_EMAIL / FALLBACK_RECIPIENT. Check CONTACT_FORM_ALLOWED_RECIPIENTS.

Creating Components

Weaverse section fundamentals — the default export + schema pattern used here.

Custom Routing

How resource routes like /api/contact are defined and matched.

Third-party Integrations

General patterns for wiring external APIs into Weaverse themes.

Input Settings

Every schema input type used in the contact form’s settings.