Brandfine Docs
SDK

Submissions

POST a contact-form submission to the workspace's Submissions inbox in one SDK call.

bf.submissions ingests contact-form submissions for the workspace. Every submission lands in the cms's Submissions inbox (Sidebar → Forms → Submissions) with full metadata — your consumer site shows the form, calls bf.submissions.create(), and renders its own thank-you state.

import { createBrandfineClient } from '@brandfine/client'

const bf = createBrandfineClient({
  baseUrl: 'https://api.brandfine.co',
  apiKey: process.env.BRANDFINE_API_KEY!,
})

await bf.submissions.create({
  name: 'Alex Liu',
  email: 'alex@example.com',
  message: "I'd love a demo of your product.",
})

Wraps the POST /external/submissions REST endpoint. Same shapes, same validation rules.

API

create(input)Promise<Submission>

type CreateSubmissionInput = {
  name: string                           // required, 1–200 chars
  email: string                          // required, validated server-side
  message: string                        // required, 1–10,000 chars
  phone?: string                         // optional, up to 40 chars
  subject?: string                       // optional, up to 200 chars
  source?: string                        // optional, e.g. window.location.pathname
  metadata?: Record<string, unknown>     // optional, free-form JSON
}

type Submission = {
  id: string          // submission id — surface in a thank-you page if useful
  createdAt: string   // ISO-8601 timestamp the cms saw the submission
}

Throws BrandfineApiError on non-2xx responses:

  • 400 — validation failure (missing required field, invalid email, oversize message). err.body carries the api's field-level error list.
  • 401 — missing or invalid X-Api-Key.
  • 5xx — server error; usually transient, safe to retry on a backoff.

Why server-side only

The workspace API key is broad-scope — it can read posts, navigations, appointments, analytics, and everything else /external/* exposes. Don't put it in the browser bundle even behind a NEXT_PUBLIC_* / PUBLIC_* / VITE_* prefix — it's the same access level as your secret key.

Every recipe below keeps the SDK call server-side. Your frontend posts to your own backend, your backend calls Brandfine. This is the v1 supported pattern.

Static-export site? If your site has no server runtime (Astro output: 'static', Hugo, plain HTML), see Static sites for your options. Scoped publishable keys for browser-safe direct calls are on the roadmap.

Framework recipes

Next.js (route handler + client form)

// app/api/contact/route.ts (SERVER — key stays here)
import { NextResponse } from 'next/server'
import { createBrandfineClient, BrandfineApiError } from '@brandfine/client'

const bf = createBrandfineClient({
  baseUrl: 'https://api.brandfine.co',
  apiKey: process.env.BRANDFINE_API_KEY!,
})

export async function POST(req: Request) {
  const body = await req.json()
  try {
    const created = await bf.submissions.create(body)
    return NextResponse.json(created)
  } catch (err) {
    if (err instanceof BrandfineApiError && err.status === 400) {
      return NextResponse.json({ error: err.body }, { status: 400 })
    }
    throw err
  }
}
// app/contact/page.tsx (CLIENT — no SDK, no key)
'use client'
import { useState } from 'react'

export default function ContactForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [message, setMessage] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [done, setDone] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError(null)
    setSubmitting(true)
    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name,
          email,
          message,
          source: window.location.pathname,
        }),
      })
      if (!res.ok) throw new Error('failed')
      setDone(true)
    } catch {
      setError('Something went wrong. Please try again in a moment.')
    } finally {
      setSubmitting(false)
    }
  }

  if (done) return <p>Thanks — we'll be in touch.</p>

  return (
    <form onSubmit={onSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} required />
      <input value={email} type="email" onChange={(e) => setEmail(e.target.value)} required />
      <textarea value={message} onChange={(e) => setMessage(e.target.value)} required />
      {error && <p>{error}</p>}
      <button type="submit" disabled={submitting}>
        {submitting ? 'Sending…' : 'Send'}
      </button>
    </form>
  )
}

Astro (form posts to an API route)

Keep the SDK call server-side so the API key never leaves the build server.

---
// src/pages/api/contact.ts
import type { APIRoute } from 'astro'
import { createBrandfineClient } from '@brandfine/client'

export const POST: APIRoute = async ({ request }) => {
  const form = await request.formData()
  const bf = createBrandfineClient({
    baseUrl: import.meta.env.BRANDFINE_API_URL,
    apiKey: import.meta.env.BRANDFINE_API_KEY,
  })
  try {
    await bf.submissions.create({
      name: String(form.get('name')),
      email: String(form.get('email')),
      message: String(form.get('message')),
      source: '/contact',
    })
    return new Response(null, { status: 303, headers: { Location: '/contact/thanks' } })
  } catch {
    return new Response(null, { status: 303, headers: { Location: '/contact?error=1' } })
  }
}
---

Then in your Astro page:

<form method="POST" action="/api/contact">
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

Server-side keeps the key out of the bundle entirely. Recommended for any site with an SSR / API-route capability.

Best practices

Set source to the page path or a campaign label — the cms surfaces it on each submission row so you can see where conversions come from without sifting metadata.
Use metadata for fields the SDK doesn't have (UTM tags, A/B test variants, dropdown selections, etc.). The cms preserves it verbatim in the submission detail view.
Validate on the client AND let the server validate. Client-side is for UX; the api enforces the real rules. Trust BrandfineApiError with status 400 as the authoritative signal of a bad payload.
Don't retry on 400. Validation failures don't get better with retries. Surface the error to the user.
Do retry on 5xx and network failures. Both are transient. Exponential backoff with a few attempts is fine.
Don't submit on input blur or autosave. Customers should see explicit submit; the inbox shouldn't fill with half-typed forms.

CORS

The endpoint inherits the permissive /external/* CORS — any origin can POST with Content-Type: application/json + X-Api-Key. No special CSP configuration needed on the customer side.

Where submissions show up

Cms sidebar → Forms → Submissions. The inbox shows every submission across every workspace in the active team, with a per-workspace badge for unread. Each row links to a detail view showing all submission fields, the source, and the metadata verbatim.

On this page