Setup
- 1Open the dashboard and create a form. The form ID is under the form name in the form editor and in the address bar.
- 2Under Settings, click Generate API key. The key starts with
sk_live_. Show key displays it again. Regenerate stops the old key immediately. - 3Store the key on your server as
SENDPLANE_API_KEY. The API sends no CORS headers, so a browser cannot call it. A React or Vue app that runs only in the browser calls the API through a server function, such as a Vercel or Netlify function. - 4Test the key and the form ID. The API returns the form with its fields.
curl https://www.sendplane.app/api/v1/forms/FORM_ID \ -H "Authorization: Bearer $SENDPLANE_API_KEY" - 5
API reference
All endpoints are under https://www.sendplane.app/api/v1. Every request carries Authorization: Bearer sk_live_…. Request and response bodies are JSON.
GET/api/v1/forms/{id}
Returns the form and its fields in the order of the form editor.
200 OK
{
"id": "1793cfac-5dda-498c-b0e9-72b0e4b194fb",
"name": "Contact",
"fields": [
{ "id": "first_name", "type": "text", "label": "First name", "required": true, "placeholder": null, "options": null, "maxLength": 500, "accept": null, "maxSizeMb": null, "maxFiles": null },
{ "id": "email", "type": "email", "label": "Email address", "required": true, "placeholder": null, "options": null, "maxLength": 500, "accept": null, "maxSizeMb": null, "maxFiles": null },
{ "id": "topic", "type": "select", "label": "Topic", "required": false, "placeholder": null, "options": ["Order", "Return", "Other"], "maxLength": null, "accept": null, "maxSizeMb": null, "maxFiles": null },
{ "id": "message", "type": "textarea", "label": "Message", "required": false, "placeholder": null, "options": null, "maxLength": 5000, "accept": null, "maxSizeMb": null, "maxFiles": null },
{ "id": "invoice", "type": "file", "label": "Invoice", "required": false, "placeholder": null, "options": null, "maxLength": null, "accept": ["application/pdf"], "maxSizeMb": 10, "maxFiles": 3 }
]
}Cache this response on your side for about a minute. After the merchant changes the form, the next request returns the new fields.
Field Types
| type | in the form editor | value | renders as |
|---|---|---|---|
text | Text | string, at most 500 characters | <input type="text"> |
textarea | Text area (multiple lines) | string, at most 5000 characters | <textarea> |
email | Email address | string, a valid address of at most 500 characters | <input type="email"> |
tel | Phone number | string, at most 500 characters | <input type="tel"> |
number | Number | number, or a numeric string | <input type="number"> |
select | Dropdown | one of options | <select> |
checkbox | Checkbox | boolean, or "true", "on", "1" and their opposites | <input type="checkbox"> |
file | File upload | array of paths from the uploads endpoint, at most maxFiles | <input type="file"> with accept, and multiple when maxFiles is more than 1 |
Every field has id, label, required, placeholder (string or null) and options (array for select, otherwise null). A text, textarea, email or tel field also has maxLength. A file field also has accept (MIME types), maxSizeMb (per file) and maxFiles. Other fields have null there. Labels are in the language the merchant wrote them in.
The merchant sets the field ID when adding a field. The ID is under More options in the form editor. The form editor locks the ID after saving, so code that uses the ID keeps working. The merchant can change labels, order, options and required at any time. A field that the merchant disables is not in this response, and POST /forms/{id}/entries ignores its value. Render the fields from this response, or write the inputs in code with the field IDs.
POST/api/v1/forms/{id}/entries
Stores a response. values maps field IDs to values. sendplane.app ignores unknown field IDs and treats null, "" and [] as empty. A required field that is missing or empty fails. loadedAt is the unix time in milliseconds at which the browser rendered the form. It is optional. Send it: sendplane.app rejects a submit within two seconds of it.
Request
curl https://www.sendplane.app/api/v1/forms/FORM_ID/entries \
-H "Authorization: Bearer $SENDPLANE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Client-IP: 203.0.113.7" \
-d '{
"values": {
"first_name": "Anna",
"email": "anna@example.com",
"topic": "Order",
"message": "When does my order ship?"
},
"loadedAt": 1790259476000
}'201 Created
{ "id": "5cb7d22d-b45e-4ade-bb14-2ae8cc97e2ae", "createdAt": "2026-09-23T14:24:56.645Z" }400 Bad Request
{
"error": {
"code": "validation_failed",
"message": "Een of meer velden zijn ongeldig.",
"fields": { "email": "Ongeldig e-mailadres", "first_name": "Dit veld is verplicht" }
}
}X-Client-IP. sendplane.app counts the responses and uploads of each visitor with it. Without the header, all visitors of your site share one limit.After the 201, sendplane.app mails the response to the notification address of the form. Each form uses the address in Settings, or its own address. The Reply-To of the mail is the value of the first email field, so the merchant replies to the visitor directly. sendplane.app retries a failed mail.
POST/api/v1/forms/{id}/uploads
Returns an upload URL for one file of a file field. The browser sends the file directly to storage.
- 1Send the name, type and size of the file from the browser to your server. Your server calls this endpoint with them.
Request body
{ "fieldId": "invoice", "fileName": "invoice.pdf", "contentType": "application/pdf", "size": 184320 }201 Created
{ "path": "5f0c2d6e-…/1793cfac-…/invoice/9b1d0e47-…/invoice.pdf", "uploadUrl": "https://…/storage/v1/object/upload/sign/attachments/…?token=…" } - 2In the browser, send the file to
uploadUrlwithin two hours, with the same Content-Type.Browser
await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); - 3Send the
paths of the field as an array in the response:{ "values": { "invoice": ["5f0c…/invoice.pdf"] } }. sendplane.app deletes an uploaded file that is not in a response after one day.
sendplane.app checks the file type and size against the field in step 1 and again in step 3. A validation_failed error names the field, also when the file storage of the account is full.
Errors
An error body is { "error": { "code", "message" } }. A validation_failed error also has fields. The messages are Dutch and meant for the visitor.
| status | code | meaning |
|---|---|---|
| 400 | validation_failed | One or more values or files are invalid. error.fields maps field IDs to messages. |
| 400 | invalid_body | The body is not JSON, is larger than 64 KB, or does not have the documented shape. |
| 400 | too_fast | The submit came within two seconds of loadedAt. |
| 401 | invalid_api_key | The Bearer key is missing or wrong. |
| 403 | form_locked | The account has more forms than its plan allows. The oldest forms stay available; the newer forms return this error until the merchant upgrades or deletes forms. |
| 404 | form_not_found | The form ID is unknown or belongs to another account. |
| 429 | rate_limited | Too many requests for this key or this visitor. Retry after a minute. |
| 500 | internal_error | Storing failed. Retry. |
Limits
| limit | value |
|---|---|
| Requests per API key | 600 per minute, all endpoints |
| Requests per visitor | 10 per minute, responses and uploads together, counted by X-Client-IP |
| Response body | 64 KB |
| Fields per form | 30 |
| File size | 10 MB per file; the merchant sets 1 to 100 MB in maxSizeMb |
| Files per field | 1; the merchant sets up to 10 in maxFiles |
| Upload URL | valid for 2 hours |
| Uploaded file without a response | deleted after 1 day |
| Forms per account | Free 2, Basic 5, Pro unlimited |
| File storage per account | Free 500 MB, Basic 5 GB, Pro 20 GB |
| Notification mails | 500 per account per day |
| Custom CSS | 20,000 characters per form |
Iframe embed
sendplane.app renders the form in an iframe. The embed works on any site, also a static one.
- 1In the form editor, open the Embed tab. Switch on Embed on your website and click Save. Anyone with the link can now open the form and send a response.
- 2Copy the code from the Embed tab and paste it where the form goes. The script sets the height of the iframe to the height of the form.
HTML
<iframe src="https://www.sendplane.app/embed/FORM_ID" title="Contact" loading="lazy" style="width: 100%; height: 600px; border: 0"></iframe> <script> addEventListener("message", (event) => { if (event.origin !== "https://www.sendplane.app" || event.data?.type !== "sendplane:height") return; for (const frame of document.querySelectorAll("iframe")) if (frame.contentWindow === event.source) frame.style.height = event.data.height + "px"; }); </script> - 3Write your CSS under Custom CSS in the Embed tab. The preview under the CSS shows the result before you save.
Custom CSS
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"); :root { --sp-font: "Inter", sans-serif; --sp-accent: #2563eb; --sp-radius: 0; } .sp-button { width: 100%; text-transform: uppercase; } .sp-field[data-field="message"] .sp-input { min-height: 12rem; }
The labels, options and placeholders come from the form editor. The button, the thank-you message and the error messages are Dutch. Embed responses go to the same notification address and Slack channel as API responses. After each response, the iframe sends the message { type: "sendplane:sent" } to your page, for example for conversion tracking.
Custom CSS
The embed page adds your CSS after its default styles. The default styles are in a cascade layer, so every selector in your CSS overrides them. The form background is transparent, so the background of your page shows through the iframe. Load a web font with @import at the top of the CSS.
Change the variables to restyle the whole form. The defaults:
Variables
:root {
--sp-font: ui-sans-serif, system-ui, sans-serif;
--sp-font-size: 16px;
--sp-text: #171717;
--sp-muted: #737373;
--sp-background: transparent;
--sp-field-background: #ffffff;
--sp-border: #d4d4d4;
--sp-accent: #171717;
--sp-accent-text: #ffffff;
--sp-error: #dc2626;
--sp-radius: 8px;
--sp-gap: 20px;
}Target one element with its class:
| class | element |
|---|---|
.sp-form | The <form> |
.sp-field | One field: the label, the input and the error. It has data-type with the field type and data-field with the field ID. |
.sp-label | The label above the input |
.sp-required | The * after the label of a required field |
.sp-input | <input>, <textarea> and <select>. A field with an error has aria-invalid="true". |
.sp-input::file-selector-button | The choose button of a file input |
.sp-checkbox | The label around a checkbox and its text |
.sp-hint | The size limit under a file input |
.sp-error | The error under a field or under the button |
.sp-button | The submit button |
.sp-success | The thank-you message. It replaces the form after sending. |
Next.js + React + Tailwind
Three files. The route handler adds the key to responses and uploads. The page gets the fields on the server and revalidates every minute. The client component uploads the files and posts the values.
- 1Put
SENDPLANE_API_KEYin.env.localand in the environment of your deployment. - 2Add the route handler. It accepts
entriesanduploadsand sends the IP address of the visitor.app/api/forms/[id]/[action]/route.ts
import type { NextRequest } from "next/server"; const API = process.env.SENDPLANE_API_URL ?? "https://www.sendplane.app"; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string; action: string }> }, ) { const { id, action } = await params; if (action !== "entries" && action !== "uploads") return new Response(null, { status: 404 }); const response = await fetch(`${API}/api/v1/forms/${id}/${action}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.SENDPLANE_API_KEY}`, "Content-Type": "application/json", "X-Client-IP": request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "", }, body: await request.text(), }); return new Response(response.body, { status: response.status, headers: { "Content-Type": "application/json" }, }); } - 3Get the fields in a server component and pass them to the form.
app/contact/page.tsx
import { ContactForm, type Field } from "./contact-form"; const API = process.env.SENDPLANE_API_URL ?? "https://www.sendplane.app"; const FORM_ID = "1793cfac-5dda-498c-b0e9-72b0e4b194fb"; export default async function ContactPage() { const response = await fetch(`${API}/api/v1/forms/${FORM_ID}`, { headers: { Authorization: `Bearer ${process.env.SENDPLANE_API_KEY}` }, next: { revalidate: 60 }, }); if (!response.ok) throw new Error(`sendplane.app returned ${response.status}`); const form: { fields: Field[] } = await response.json(); return <ContactForm formId={FORM_ID} fields={form.fields} />; } - 4Render the fields, upload the files and post the values. The form shows the field errors of sendplane.app under the input.
app/contact/contact-form.tsx
"use client"; import { useState } from "react"; export type Field = { id: string; type: "text" | "textarea" | "email" | "tel" | "number" | "select" | "checkbox" | "file"; label: string; required: boolean; placeholder: string | null; options: string[] | null; maxLength: number | null; accept: string[] | null; maxSizeMb: number | null; maxFiles: number | null; }; type Result = { path?: string; uploadUrl?: string; error?: { code: string; message: string; fields?: Record<string, string> }; }; export function ContactForm({ formId, fields }: { formId: string; fields: Field[] }) { const [loadedAt] = useState(() => Date.now()); const [errors, setErrors] = useState<Record<string, string>>({}); const [status, setStatus] = useState<"idle" | "sending" | "sent" | "failed">("idle"); async function post(action: "entries" | "uploads", body: unknown): Promise<Result> { const response = await fetch(`/api/forms/${formId}/${action}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return response.json(); } function fail(fieldErrors: Record<string, string> = {}) { setErrors(fieldErrors); setStatus("failed"); } async function submit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); setStatus("sending"); const data = new FormData(event.currentTarget); const values: Record<string, unknown> = {}; try { for (const field of fields) { if (field.type === "checkbox") values[field.id] = data.get(field.id) === "on"; else if (field.type !== "file") values[field.id] = data.get(field.id); else { const paths: string[] = []; for (const file of data.getAll(field.id) as File[]) { if (!file.size) continue; const upload = await post("uploads", { fieldId: field.id, fileName: file.name, contentType: file.type, size: file.size, }); if (upload.error) return fail(upload.error.fields); const put = await fetch(upload.uploadUrl!, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!put.ok) return fail({ [field.id]: "Uploading failed. Try again." }); paths.push(upload.path!); } values[field.id] = paths; } } const result = await post("entries", { values, loadedAt }); if (result.error) return fail(result.error.fields); setStatus("sent"); } catch { fail(); } } if (status === "sent") return <p className="rounded-lg bg-green-50 p-4">Thanks, we will get back to you.</p>; const input = "w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-black focus:outline-none"; return ( <form onSubmit={submit} noValidate className="flex max-w-lg flex-col gap-4"> {fields.map((field) => ( <label key={field.id} className="flex flex-col gap-1 text-sm font-medium"> {field.type === "checkbox" ? ( <span className="flex items-center gap-2"> <input type="checkbox" name={field.id} required={field.required} /> {field.label} </span> ) : ( <> {field.label} {field.type === "textarea" ? ( <textarea name={field.id} required={field.required} placeholder={field.placeholder ?? ""} maxLength={field.maxLength ?? undefined} rows={4} className={input} /> ) : field.type === "select" ? ( <select name={field.id} required={field.required} className={input}> <option value="">{field.placeholder ?? "Choose…"}</option> {field.options?.map((option) => ( <option key={option}>{option}</option> ))} </select> ) : field.type === "file" ? ( <input type="file" name={field.id} required={field.required} accept={field.accept?.join(",")} multiple={(field.maxFiles ?? 1) > 1} className={input} /> ) : ( <input type={field.type} name={field.id} required={field.required} placeholder={field.placeholder ?? ""} maxLength={field.maxLength ?? undefined} className={input} /> )} </> )} {errors[field.id] && <span className="text-xs text-red-600">{errors[field.id]}</span>} </label> ))} <button type="submit" disabled={status === "sending"} className="rounded-lg bg-black px-4 py-2 text-white disabled:opacity-50"> {status === "sending" ? "Sending…" : "Send"} </button> {status === "failed" && Object.keys(errors).length === 0 && <p className="text-sm text-red-600">Sending failed. Try again.</p>} </form> ); }
GraphCommerce (GraphQL Mesh)
The form and the mutations join the GraphQL schema of the shop. Mesh loads .graphqls files under components/ and the resolvers from .meshrc.yml. The component renders the fields with the form elements of @graphcommerce/ecommerce-ui, so the form follows the theme.
- 1Put
SENDPLANE_API_KEYin.env.localand in the environment of your deployment. - 2Add the schema extension.
components/Sendplane/Sendplane.graphqls
type SendplaneForm { id: String! name: String! fields: [SendplaneField!]! } type SendplaneField { id: String! type: String! label: String! required: Boolean! placeholder: String options: [String!] maxLength: Int accept: [String!] maxSizeMb: Int maxFiles: Int } input SendplaneValueInput { id: String! value: String "File fields: the path of each uploaded file." files: [String!] } type SendplaneFieldError { id: String! message: String! } type SendplaneSubmitResult { success: Boolean! entryId: String fieldErrors: [SendplaneFieldError!]! } type SendplaneUploadResult { path: String uploadUrl: String error: String } extend type Query { sendplaneForm(id: String!): SendplaneForm } extend type Mutation { sendplaneSubmit(id: String!, values: [SendplaneValueInput!]!, loadedAt: Float!): SendplaneSubmitResult! sendplaneUpload(id: String!, fieldId: String!, fileName: String!, contentType: String!, size: Int!): SendplaneUploadResult! } - 3Register the resolvers.
.meshrc.yml
additionalResolvers: - components/Sendplane/resolvers.tscomponents/Sendplane/resolvers.ts
import type { MeshContext, Resolvers } from '@graphcommerce/graphql-mesh' import { GraphQLError } from 'graphql' type ApiError = { error?: { code?: string; message?: string; fields?: Record<string, string> } } type Context = MeshContext & { headers?: Record<string, string | undefined> } async function sendplaneFetch(path: string, context: Context, init: RequestInit = {}) { const key = process.env.SENDPLANE_API_KEY if (!key) throw new GraphQLError('SENDPLANE_API_KEY is missing') const url = (process.env.SENDPLANE_API_URL || 'https://www.sendplane.app').replace(/\/$/, '') const ip = context.headers?.['x-forwarded-for']?.split(',')[0]?.trim() return fetch(`${url}${path}`, { ...init, headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', ...(ip ? { 'X-Client-IP': ip } : {}), }, }) } function toGraphQLError(status: number, body: ApiError) { const code = body.error?.code ?? `http_${status}` return new GraphQLError(body.error?.message ?? `Sendplane request failed (${status})`, { extensions: { code: `SENDPLANE_${code.toUpperCase()}` }, }) } export const resolvers: Resolvers = { Query: { sendplaneForm: async (_root, args, context) => { const response = await sendplaneFetch(`/api/v1/forms/${encodeURIComponent(args.id)}`, context) if (response.status === 404) return null if (!response.ok) throw toGraphQLError(response.status, await response.json().catch(() => ({}))) return response.json() }, }, Mutation: { sendplaneSubmit: async (_root, args, context) => { const values = Object.fromEntries(args.values.map((v) => [v.id, v.files ?? v.value ?? null])) const response = await sendplaneFetch( `/api/v1/forms/${encodeURIComponent(args.id)}/entries`, context, { method: 'POST', body: JSON.stringify({ values, loadedAt: Math.round(args.loadedAt) }) }, ) const body = (await response.json().catch(() => ({}))) as ApiError & { id?: string } if (response.status === 201) return { success: true, entryId: body.id ?? null, fieldErrors: [] } if (body.error?.code === 'validation_failed') { return { success: false, entryId: null, fieldErrors: Object.entries(body.error.fields ?? {}).map(([id, message]) => ({ id, message })), } } throw toGraphQLError(response.status, body) }, sendplaneUpload: async (_root, { id, ...file }, context) => { const response = await sendplaneFetch( `/api/v1/forms/${encodeURIComponent(id)}/uploads`, context, { method: 'POST', body: JSON.stringify(file) }, ) const body = (await response.json().catch(() => ({}))) as ApiError & { path?: string; uploadUrl?: string } if (response.status === 201) return { path: body.path ?? null, uploadUrl: body.uploadUrl ?? null, error: null } if (body.error?.code === 'validation_failed') { return { path: null, uploadUrl: null, error: body.error.fields?.[file.fieldId] ?? body.error.message ?? null } } throw toGraphQLError(response.status, body) }, }, } - 4Add the documents.
yarn codegengenerates the typed.gql.tsfiles and rebuilds the mesh.components/Sendplane/*.graphql
# components/Sendplane/SendplaneForm.graphql query SendplaneForm($id: String!) { sendplaneForm(id: $id) { id name fields { id type label required placeholder options maxLength accept maxSizeMb maxFiles } } } # components/Sendplane/SendplaneSubmit.graphql mutation SendplaneSubmit($id: String!, $values: [SendplaneValueInput!]!, $loadedAt: Float!) { sendplaneSubmit(id: $id, values: $values, loadedAt: $loadedAt) { success entryId fieldErrors { id message } } } # components/Sendplane/SendplaneUpload.graphql mutation SendplaneUpload($id: String!, $fieldId: String!, $fileName: String!, $contentType: String!, $size: Int!) { sendplaneUpload(id: $id, fieldId: $fieldId, fileName: $fileName, contentType: $contentType, size: $size) { path uploadUrl error } } - 5Add the type policy. A field ID is unique only within its form, so Apollo must store each field inside its form.
yarn codegenloads the plugin.plugins/sendplaneGraphqlConfig.ts
import type { graphqlConfig as graphqlConfigType, StrictTypedTypePolicies } from '@graphcommerce/graphql' import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config' export const config: PluginConfig = { type: 'function', module: '@graphcommerce/graphql' } const sendplaneTypePolicies: StrictTypedTypePolicies = { SendplaneField: { keyFields: false }, } export const graphqlConfig: FunctionPlugin<typeof graphqlConfigType> = (prev, conf) => { const results = prev(conf) return { ...results, policies: [...results.policies, sendplaneTypePolicies] } } - 6Add the hook. It binds react-hook-form to the mutation and sets the field errors of sendplane.app on the inputs.
components/Sendplane/useSendplaneForm.ts
import { useFormGqlMutation } from '@graphcommerce/react-hook-form' import { useState } from 'react' import type { SendplaneSubmitMutation, SendplaneSubmitMutationVariables } from './SendplaneSubmit.gql' import { SendplaneSubmitDocument } from './SendplaneSubmit.gql' /** A file field holds the paths of its uploaded files. */ export type SendplaneFormValues = SendplaneSubmitMutationVariables & { fields: Record<string, string | number | boolean | string[] | undefined> } export function useSendplaneForm(formId: string) { const [loadedAt] = useState(() => Date.now()) const form = useFormGqlMutation<SendplaneSubmitMutation, SendplaneFormValues>(SendplaneSubmitDocument, { defaultValues: { id: formId, values: [], loadedAt, fields: {} }, onBeforeSubmit: (variables) => ({ id: formId, loadedAt, values: Object.entries(variables.fields ?? {}).map(([id, value]) => Array.isArray(value) ? { id, files: value } : { id, value: value === undefined || value === '' ? null : String(value) }, ), }) as SendplaneFormValues, onComplete: (result) => { const submit = result.data?.sendplaneSubmit if (!submit || submit.success) return submit.fieldErrors.forEach((e) => form.setError(`fields.${e.id}`, { type: 'server', message: e.message })) if (submit.fieldErrors.length === 0) throw new Error('Submission failed') }, }) const { formState, error, handleSubmit } = form const submit = handleSubmit(() => {}) const submitted = formState.isSubmitSuccessful && !error && !Object.keys(formState.errors).length return { form, submit, submitted, disabled: formState.isSubmitting || submitted } } - 7Add the file element. It uploads each file when the visitor picks it and stores the paths in the field. It checks the type, size and number of files first. A refused file stays in the list with its error and blocks the submit until the visitor removes it.
components/Sendplane/SendplaneFileElement.tsx
import { useMutation } from '@graphcommerce/graphql' import type { Control } from '@graphcommerce/react-hook-form' import { useController } from '@graphcommerce/react-hook-form' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' import { Box, Button, FormControl, FormHelperText, FormLabel, LinearProgress } from '@mui/material' import { useEffect, useState } from 'react' import type { SendplaneFormQuery } from './SendplaneForm.gql' import { SendplaneUploadDocument } from './SendplaneUpload.gql' import type { SendplaneFormValues } from './useSendplaneForm' type Field = NonNullable<SendplaneFormQuery['sendplaneForm']>['fields'][number] export function SendplaneFileElement(props: { formId: string field: Field control: Control<SendplaneFormValues> disabled?: boolean }) { const { formId, field, control, disabled } = props const [upload] = useMutation(SendplaneUploadDocument) // A refused file keeps its error in the list until the visitor removes it. const [items, setItems] = useState<{ file: File; path?: string; error?: string }[]>([]) const maxFiles = field.maxFiles ?? 1 const uploading = items.some((item) => !item.path && !item.error) const { field: input, fieldState } = useController({ control, name: `fields.${field.id}`, rules: { validate: (value) => { if (uploading) return t`Wait until the files are uploaded` if (items.some((item) => item.error)) return t`Remove the files that were not uploaded` if (field.required && !(Array.isArray(value) && value.length)) return t`This field is required` return true }, }, }) const { onChange } = input useEffect(() => onChange(items.flatMap((item) => (item.path ? [item.path] : []))), [items, onChange]) const update = (file: File, patch: { path?: string; error?: string } | null) => setItems((current) => patch ? current.map((item) => (item.file === file ? { ...item, ...patch } : item)) : current.filter((item) => item.file !== file), ) async function send(file: File) { const { data } = await upload({ variables: { id: formId, fieldId: field.id, fileName: file.name, contentType: file.type, size: file.size }, }) const result = data?.sendplaneUpload if (!result?.path || !result.uploadUrl) throw new Error(result?.error ?? t`Uploading failed, try again`) const response = await fetch(result.uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }) if (!response.ok) throw new Error(t`Uploading failed, try again`) return result.path } async function pick(files: File[]) { const kept = maxFiles === 1 ? [] : items let room = maxFiles - kept.filter((item) => !item.error).length const added = files.map((file) => { let error: string | undefined if (field.accept && !field.accept.includes(file.type)) error = t`This file type is not allowed` else if (field.maxSizeMb && file.size > field.maxSizeMb * 1024 * 1024) error = t`At most ${field.maxSizeMb} MB` else if (room <= 0) error = t`You can add at most ${maxFiles} files` else room -= 1 return { file, error } }) setItems([...kept, ...added]) for (const { file, error } of added) { if (error) continue try { update(file, { path: await send(file) }) } catch (e) { update(file, { error: (e as Error).message }) } } } const error = fieldState.error?.message return ( <FormControl required={field.required} error={Boolean(error)} disabled={disabled} fullWidth> <FormLabel>{field.label}</FormLabel> <Box sx={{ mt: 1 }}> <Button variant='outlined' component='label' disabled={disabled || uploading || (maxFiles > 1 && items.filter((item) => !item.error).length >= maxFiles)} > <Trans>Choose file</Trans> <input hidden type='file' multiple={maxFiles > 1} accept={field.accept?.join(',')} onChange={(event) => { const files = [...(event.target.files ?? [])] event.target.value = '' if (files.length) void pick(files) }} /> </Button> </Box> {items.map((item) => ( <Box key={`${item.file.name}-${item.file.lastModified}`} sx={{ mt: 1 }}> {item.file.name} {item.path || item.error ? ( <Button size='small' disabled={disabled} onClick={() => update(item.file, null)}> <Trans>Remove</Trans> </Button> ) : ( <LinearProgress sx={{ mt: 0.5 }} /> )} {item.error && <FormHelperText error>{item.error}</FormHelperText>} </Box> ))} <FormHelperText>{error ?? (field.maxSizeMb && <Trans>At most {field.maxSizeMb} MB per file</Trans>)}</FormHelperText> </FormControl> ) } - 8Add the form component. It renders each field with the matching element. A tel field uses
TextFieldElement, becauseTelephoneElementaccepts US formats only. The browser fetches the current fields after the page loads. When sendplane.app returns an error for a field that is not on the page, the component fetches the fields again and shows the error on that field.components/Sendplane/SendplaneForm.tsx
import { ApolloErrorSnackbar, CheckboxElement, EmailElement, NumberFieldElement, SelectElement, TextFieldElement, } from '@graphcommerce/ecommerce-ui' import { useQuery } from '@graphcommerce/graphql' import { Button, Form, FormRow, MessageSnackbar } from '@graphcommerce/next-ui' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' import { Typography } from '@mui/material' import { useEffect } from 'react' import { SendplaneFileElement } from './SendplaneFileElement' import { SendplaneFormDocument } from './SendplaneForm.gql' import { useSendplaneForm } from './useSendplaneForm' export function SendplaneForm({ formId }: { formId: string }) { // The static page holds the fields of its last build; the browser fetches the current fields. const { data, previousData, error: queryError, refetch } = useQuery(SendplaneFormDocument, { variables: { id: formId }, fetchPolicy: 'cache-and-network', }) const fields = (data ?? previousData)?.sendplaneForm?.fields ?? [] const { form, submit, submitted, disabled } = useSendplaneForm(formId) const { control, formState, error } = form // An error on a field that is not on the page: the merchant changed the form after the page loaded. const outdated = Object.keys(formState.errors.fields ?? {}).some((id) => !fields.some((f) => f.id === id)) useEffect(() => { if (outdated) refetch().catch(() => undefined) }, [outdated, refetch]) return ( <Form noValidate onSubmit={submit}> {fields.map((field) => { const { maxLength } = field const common = { control, name: `fields.${field.id}` as const, label: field.label, required: field.required, disabled } const text = { ...common, placeholder: field.placeholder ?? undefined, rules: maxLength ? { maxLength: { value: maxLength, message: t`At most ${maxLength} characters` } } : undefined, } const options = [ ...(field.placeholder || !field.required ? [{ id: '', label: field.placeholder ?? '\u00a0' }] : []), ...(field.options ?? []).map((o) => ({ id: o, label: o })), ] return ( <FormRow key={field.id}> {field.type === 'file' ? <SendplaneFileElement formId={formId} field={field} control={control} disabled={disabled} /> : field.type === 'checkbox' ? <CheckboxElement {...common} /> : field.type === 'select' ? ( <SelectElement {...text} options={options} SelectProps={{ displayEmpty: Boolean(field.placeholder) }} InputLabelProps={{ shrink: Boolean(field.placeholder) }} /> ) : field.type === 'email' ? <EmailElement {...text} /> : field.type === 'tel' ? <TextFieldElement {...text} type='tel' /> : field.type === 'number' ? <NumberFieldElement {...text} /> : field.type === 'textarea' ? <TextFieldElement {...text} multiline minRows={4} /> : <TextFieldElement {...text} />} </FormRow> ) })} {outdated && queryError && ( <Typography color='error'> <Trans>This form has changed. Reload the page and try again.</Trans> </Typography> )} <Button type='submit' variant='pill' color='primary' size='large' loading={formState.isSubmitting} disabled={submitted}> <Trans>Submit</Trans> </Button> <MessageSnackbar open={submitted} variant='pill' severity='success'> <Trans>Thanks, we will get back to you.</Trans> </MessageSnackbar> <ApolloErrorSnackbar error={error} /> </Form> ) } - 9Render
<SendplaneForm formId="…" />and prefetch the query ingetStaticProps, so the fields are in the static HTML.pages/contact.tsx (excerpt)
import { SendplaneForm } from '../components/Sendplane/SendplaneForm' import { SendplaneFormDocument } from '../components/Sendplane/SendplaneForm.gql' const FORM_ID = '1793cfac-5dda-498c-b0e9-72b0e4b194fb' function ContactPage() { return <SendplaneForm formId={FORM_ID} /> } export const getStaticProps: GetPageStaticProps = async (context) => { const client = graphqlSharedClient(context) // A Docker build has no secrets: the browser fetches the fields, the next revalidation puts them in the HTML. if (process.env.SENDPLANE_API_KEY) await client.query({ query: SendplaneFormDocument, variables: { id: FORM_ID } }) return { props: { apolloState: client.cache.extract() }, revalidate: 60 } }
yarn codegen and restart yarn dev.Fields in Code
For a custom layout, write the inputs in code and name each input fields.<field ID>. The hook sends them the same way. This form needs no prefetch. A required field that the form does not render returns a field error that the visitor cannot see.
components/Sendplane/ContactForm.tsx
import { EmailElement, TextFieldElement } from '@graphcommerce/ecommerce-ui'
import { Button, Form, FormRow } from '@graphcommerce/next-ui'
import { t } from '@lingui/core/macro'
import { Trans } from '@lingui/react/macro'
import { useSendplaneForm } from './useSendplaneForm'
export function ContactForm({ formId }: { formId: string }) {
const { form, submit, submitted, disabled } = useSendplaneForm(formId)
const { control, formState } = form
return (
<Form noValidate onSubmit={submit}>
<FormRow>
<TextFieldElement control={control} name='fields.first_name' label={t`First name`} required disabled={disabled} />
<TextFieldElement control={control} name='fields.last_name' label={t`Last name`} required disabled={disabled} />
</FormRow>
<FormRow>
<EmailElement control={control} name='fields.email' label={t`Email address`} required disabled={disabled} />
</FormRow>
<Button type='submit' variant='pill' color='primary' loading={formState.isSubmitting} disabled={submitted}>
<Trans>Submit</Trans>
</Button>
</Form>
)
}Nuxt
Three server routes add the key. The component gets the fields and posts the files and values through them.
- 1Declare the runtime config. Set
NUXT_SENDPLANE_API_KEYin the environment.nuxt.config.ts
export default defineNuxtConfig({ runtimeConfig: { sendplaneApiKey: "", // NUXT_SENDPLANE_API_KEY sendplaneApiUrl: "https://www.sendplane.app", // NUXT_SENDPLANE_API_URL }, }); - 2Add the server utility and the routes. The utility sends the request with the key and the IP address of the visitor.
server/utils/sendplane.ts and server/api/forms/
// server/utils/sendplane.ts import type { H3Event } from "h3"; export async function sendplane(event: H3Event, path = "") { const config = useRuntimeConfig(event); const response = await fetch(`${config.sendplaneApiUrl}/api/v1/forms/${getRouterParam(event, "id")}${path}`, { method: event.method, headers: { Authorization: `Bearer ${config.sendplaneApiKey}`, "Content-Type": "application/json", "X-Client-IP": getRequestIP(event, { xForwardedFor: true }) ?? "", }, body: event.method === "POST" ? JSON.stringify(await readBody(event)) : undefined, }); setResponseStatus(event, response.status); return response.json(); } // server/api/forms/[id].get.ts export default defineEventHandler((event) => sendplane(event)); // server/api/forms/[id]/entries.post.ts export default defineEventHandler((event) => sendplane(event, "/entries")); // server/api/forms/[id]/uploads.post.ts export default defineEventHandler((event) => sendplane(event, "/uploads")); - 3Render the fields, upload the files and post the values.
components/SendplaneForm.vue
<script setup lang="ts"> const props = defineProps<{ formId: string }>(); const { data: form } = await useFetch(`/api/forms/${props.formId}`); const values = reactive<Record<string, string | boolean | string[]>>({}); const files: Record<string, File[]> = {}; const errors = ref<Record<string, string>>({}); const status = ref<"idle" | "sending" | "sent" | "failed">("idle"); const loadedAt = Date.now(); type Result = { path?: string; uploadUrl?: string; error?: { fields?: Record<string, string> } }; function post(action: "entries" | "uploads", body: object) { return $fetch<Result>(`/api/forms/${props.formId}/${action}`, { method: "POST", body, ignoreResponseError: true }); } function fail(fieldErrors: Record<string, string> = {}) { errors.value = fieldErrors; status.value = "failed"; } async function submit() { status.value = "sending"; for (const [fieldId, list] of Object.entries(files)) { const paths: string[] = []; for (const file of list) { const upload = await post("uploads", { fieldId, fileName: file.name, contentType: file.type, size: file.size }); if (upload.error) return fail(upload.error.fields); const put = await fetch(upload.uploadUrl!, { method: "PUT", headers: { "Content-Type": file.type }, body: file }); if (!put.ok) return fail({ [fieldId]: "Uploading failed. Try again." }); paths.push(upload.path!); } values[fieldId] = paths; } const result = await post("entries", { values, loadedAt }); if (result.error) return fail(result.error.fields); status.value = "sent"; } </script> <template> <p v-if="status === 'sent'">Thanks, we will get back to you.</p> <form v-else novalidate @submit.prevent="submit"> <label v-for="field in form?.fields" :key="field.id"> {{ field.label }} <textarea v-if="field.type === 'textarea'" v-model="values[field.id]" :required="field.required" :maxlength="field.maxLength ?? undefined" /> <select v-else-if="field.type === 'select'" v-model="values[field.id]" :required="field.required"> <option value="">{{ field.placeholder ?? 'Choose…' }}</option> <option v-for="option in field.options" :key="option">{{ option }}</option> </select> <input v-else-if="field.type === 'checkbox'" v-model="values[field.id]" type="checkbox" /> <input v-else-if="field.type === 'file'" type="file" :accept="field.accept?.join(',')" :multiple="(field.maxFiles ?? 1) > 1" @change="files[field.id] = [...(($event.target as HTMLInputElement).files ?? [])]" /> <input v-else v-model="values[field.id]" :type="field.type" :required="field.required" :placeholder="field.placeholder ?? ''" :maxlength="field.maxLength ?? undefined" /> <small v-if="errors[field.id]">{{ errors[field.id] }}</small> </label> <button type="submit" :disabled="status === 'sending'">Send</button> <p v-if="status === 'failed' && !Object.keys(errors).length">Sending failed. Try again.</p> </form> </template>