REST API v1

Form API docs

sendplane.app stores the fields of a form and the responses to it. Your server gets the fields from the API and sends the responses and files back. Your site renders the form. The API key stays on your server. A site without a server uses the iframe embed.

The complete API

https://www.sendplane.app/api/v1

  • GET/forms/{id}Returns the form and its fields.
  • POST/forms/{id}/entriesStores a response.
  • POST/forms/{id}/uploadsReturns an upload URL for one file.
On this page
Start

Setup

  1. 1
    Open the dashboard and create a form. The form ID is under the form name in the form editor and in the address bar.
  2. 2
    Under Settings, click Generate API key. The key starts with sk_live_. Show key displays it again. Regenerate stops the old key immediately.
  3. 3
    Store 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.
  4. 4
    Test 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. 5
    Build the form with the guide for your stack: Next.js, GraphCommerce or Nuxt.
Reference

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

typein the form editorvaluerenders as
textTextstring, at most 500 characters<input type="text">
textareaText area (multiple lines)string, at most 5000 characters<textarea>
emailEmail addressstring, a valid address of at most 500 characters<input type="email">
telPhone numberstring, at most 500 characters<input type="tel">
numberNumbernumber, or a numeric string<input type="number">
selectDropdownone of options<select>
checkboxCheckboxboolean, or "true", "on", "1" and their opposites<input type="checkbox">
fileFile uploadarray 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" }
  }
}
Send the IP address of the visitor in 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.

  1. 1
    Send 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=…"
    }
  2. 2
    In the browser, send the file to uploadUrl within two hours, with the same Content-Type.

    Browser

    await fetch(uploadUrl, {
      method: "PUT",
      headers: { "Content-Type": file.type },
      body: file,
    });
  3. 3
    Send 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.

statuscodemeaning
400validation_failedOne or more values or files are invalid. error.fields maps field IDs to messages.
400invalid_bodyThe body is not JSON, is larger than 64 KB, or does not have the documented shape.
400too_fastThe submit came within two seconds of loadedAt.
401invalid_api_keyThe Bearer key is missing or wrong.
403form_lockedThe 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.
404form_not_foundThe form ID is unknown or belongs to another account.
429rate_limitedToo many requests for this key or this visitor. Retry after a minute.
500internal_errorStoring failed. Retry.

Limits

limitvalue
Requests per API key600 per minute, all endpoints
Requests per visitor10 per minute, responses and uploads together, counted by X-Client-IP
Response body64 KB
Fields per form30
File size10 MB per file; the merchant sets 1 to 100 MB in maxSizeMb
Files per field1; the merchant sets up to 10 in maxFiles
Upload URLvalid for 2 hours
Uploaded file without a responsedeleted after 1 day
Forms per accountFree 2, Basic 5, Pro unlimited
File storage per accountFree 500 MB, Basic 5 GB, Pro 20 GB
Notification mails500 per account per day
Custom CSS20,000 characters per form
Guides

Iframe embed

sendplane.app renders the form in an iframe. The embed works on any site, also a static one.

  1. 1
    In 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.
  2. 2
    Copy 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>
  3. 3
    Write 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:

classelement
.sp-formThe <form>
.sp-fieldOne field: the label, the input and the error. It has data-type with the field type and data-field with the field ID.
.sp-labelThe label above the input
.sp-requiredThe * 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-buttonThe choose button of a file input
.sp-checkboxThe label around a checkbox and its text
.sp-hintThe size limit under a file input
.sp-errorThe error under a field or under the button
.sp-buttonThe submit button
.sp-successThe 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.

  1. 1
    Put SENDPLANE_API_KEY in .env.local and in the environment of your deployment.
  2. 2
    Add the route handler. It accepts entries and uploads and 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" },
      });
    }
  3. 3
    Get 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} />;
    }
  4. 4
    Render 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.

  1. 1
    Put SENDPLANE_API_KEY in .env.local and in the environment of your deployment.
  2. 2
    Add 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!
    }
  3. 3
    Register the resolvers.

    .meshrc.yml

    additionalResolvers:
      - components/Sendplane/resolvers.ts

    components/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)
        },
      },
    }
  4. 4
    Add the documents. yarn codegen generates the typed .gql.ts files 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
      }
    }
  5. 5
    Add the type policy. A field ID is unique only within its form, so Apollo must store each field inside its form.yarn codegen loads 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] }
    }
  6. 6
    Add 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 }
    }
  7. 7
    Add 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>
      )
    }
  8. 8
    Add the form component. It renders each field with the matching element. A tel field uses TextFieldElement, because TelephoneElement accepts 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>
      )
    }
  9. 9
    Render <SendplaneForm formId="…" /> and prefetch the query in getStaticProps, 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 }
    }
After a change to the schema or the resolvers, run 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.

  1. 1
    Declare the runtime config. Set NUXT_SENDPLANE_API_KEY in the environment.

    nuxt.config.ts

    export default defineNuxtConfig({
      runtimeConfig: {
        sendplaneApiKey: "",                          // NUXT_SENDPLANE_API_KEY
        sendplaneApiUrl: "https://www.sendplane.app", // NUXT_SENDPLANE_API_URL
      },
    });
  2. 2
    Add 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"));
  3. 3
    Render 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>

Start with your first form

Log in with your email address, no password needed. Create a form and generate the API key.

Start for free