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.bodycarries 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.