Content Types API
Content types define custom schemas for your content. Each type has a set of fields that determine what metadata a content item can have. You can create types from predefined templates or build your own from scratch.
GET/api/content-type-templatesInternal
List available predefined templates (Blog Post, Devlog, Changelog, etc.).
GET/api/sites/:siteId/content-typesInternal
List all content types for a site, including their fields.
POST/api/sites/:siteId/content-typesInternal
Create a content type. Either from a template or from scratch.
From template
cURLbash
curl -X POST multi-site-manager.vercel.app/api/sites/SITE_ID/content-types \
-H "Content-Type: application/json" \
-d '{ "template": "blog-post" }'Available templates: blog-post, devlog, changelog, page, newsletter, recipe, product-review.
Custom
cURLbash
curl -X POST multi-site-manager.vercel.app/api/sites/SITE_ID/content-types \
-H "Content-Type: application/json" \
-d '{ "name": "Game Review", "slug": "game-review", "description": "Reviews for games" }'
POST/api/sites/:siteId/content-types/:id/fieldsInternal
Add a field to a content type.
Request Body
| Param | Type | Required | Description |
|---|
| name | string | Yes | Display name |
| slug | string | Yes | Identifier used in metadata |
| field_type | string | Yes | text, textarea, richtext, number, url, image, date, boolean, select, multiselect, tags, color, json |
| description | string | No | Help text |
| required | boolean | No | Default: false |
| options | object | No | { "choices": ["a", "b"] } for select/multiselect |
| position | number | No | Display order (0-based) |
cURLbash
curl -X POST multi-site-manager.vercel.app/api/sites/SITE_ID/content-types/TYPE_ID/fields \
-H "Content-Type: application/json" \
-d '{
"name": "Rating",
"slug": "rating",
"field_type": "number",
"required": true
}'Content API
Content items have three fixed fields: title, body, and status. All other fields are stored in metadata(JSON), shaped by the content type's custom fields.
GET/api/content?site_id=SITE_IDInternal
List content items. Supports filtering, pagination, and dynamic metadata queries.
Query Parameters
| Param | Type | Required | Description |
|---|
| site_id | string | Yes | The site UUID |
| content_type_id | string | No | Filter by content type |
| status | string | No | "published" or "draft" |
| limit | number | No | Max results (default 50, max 100) |
| offset | number | No | Skip N results (for pagination) |
| meta.* | string | No | Filter by any metadata field (see below) |
Dynamic Metadata Filters
Use meta.FIELD_SLUG=value to filter by any custom field in metadata. Works for both array fields (tags, multiselect) and scalar fields (text, select).
Examplesbash
# Blog posts with tag "devlog"
curl "multi-site-manager.vercel.app/api/content?site_id=SITE_ID&meta.tags=devlog"
# Posts by a specific author
curl "multi-site-manager.vercel.app/api/content?site_id=SITE_ID&meta.author=JhonaMath"
# Easy recipes, published only, limit 5
curl "multi-site-manager.vercel.app/api/content?site_id=SITE_ID&meta.difficulty=easy&status=published&limit=5"
# Combine: blog posts with tag "gamejam" by "JhonaMath"
curl "multi-site-manager.vercel.app/api/content?site_id=SITE_ID&content_type_id=TYPE_ID&meta.tags=gamejam&meta.author=JhonaMath"
POST/api/contentInternal
Create a content item. Custom field values go in metadata.
Request Body
| Param | Type | Required | Description |
|---|
| site_id | string | Yes | The site UUID |
| content_type_id | string | No | Content type UUID |
| title | string | Yes | Content title |
| slug | string | No | URL-friendly identifier |
| body | string | No | Content body (markdown) |
| metadata | object | No | Custom field values (e.g. { "tags": ["devlog"], "author": "JhonaMath" }) |
| status | string | No | "draft" (default) or "published" |
cURLbash
curl -X POST multi-site-manager.vercel.app/api/content \
-H "Content-Type: application/json" \
-d '{
"site_id": "SITE_ID",
"content_type_id": "BLOG_TYPE_ID",
"title": "My First Post",
"slug": "my-first-post",
"body": "# Welcome\nThis is my first post.",
"metadata": {
"excerpt": "A short intro to my blog",
"cover-image": "https://example.com/img.jpg",
"author": "JhonaMath",
"tags": ["devlog", "gamejam"],
"read-time": "5 min"
},
"status": "published"
}'
GET/api/content/:contentIdInternal
Get a single content item with its content type and metadata.
PATCH/api/content/:contentIdInternal
Update a content item. Only send fields you want to change.
Request Body
| Param | Type | Required | Description |
|---|
| title | string | No | Updated title |
| slug | string | No | Updated slug |
| body | string | No | Updated body |
| metadata | object | No | Updated custom fields |
| status | string | No | "draft" or "published" |
| content_type_id | string | No | Change content type |
DELETE/api/content/:contentIdInternal
Delete a content item.
Forms API
Build custom forms for your websites. Each form defines fields, and submissions can automatically create contacts, subscribe them, assign tags, and create inbox messages.
GET/api/public/forms/:formIdPublic
Get the public schema of an active form. Use this to dynamically render the form on your website.
Responsejson
{
"id": "form-uuid",
"name": "Contact Form",
"description": "Get in touch with us",
"success_message": "Thank you for your message!",
"fields": [
{ "slug": "name", "name": "Full Name", "field_type": "text", "placeholder": "John Doe", "required": true, "options": {} },
{ "slug": "email", "name": "Email", "field_type": "email", "placeholder": "you@example.com", "required": true, "options": {} },
{ "slug": "message", "name": "Message", "field_type": "textarea", "placeholder": "Tell us more...", "required": true, "options": {} }
]
}
POST/api/public/forms/:formIdPublic
Submit a form. The body should be a JSON object where keys match the field slugs. Rate limited to 10 submissions per minute per IP.
cURLbash
curl -X POST multi-site-manager.vercel.app/api/public/forms/FORM_ID \
-H "Authorization: Bearer YOUR_SITE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I have a question about..."
}'201 Createdjson
{
"message": "Thank you for your message!",
"submission_id": "submission-uuid"
}On-submit actions are configured in the dashboard per form: auto-create contacts, subscribe to site, assign tags, and/or create an inbox message. These run server-side — no extra API calls needed from your frontend.
GET/api/sites/:siteId/formsInternal
List all forms for a site.
POST/api/sites/:siteId/formsInternal
Create a new form.
Request Body
| Param | Type | Required | Description |
|---|
| name | string | Yes | Form display name |
| slug | string | Yes | URL-safe identifier (unique per site) |
| description | string | No | Short description |
| success_message | string | No | Message shown after submission |
| create_contact | boolean | No | Auto-create contact from email field |
| subscribe_contact | boolean | No | Auto-subscribe contact to site |
| assign_tags | string[] | No | Tag UUIDs to assign on subscribe |
| create_message | boolean | No | Create an inbox message from submission |
GET/api/sites/:siteId/forms/:formIdInternal
Get a form with all its fields.
PATCH/api/sites/:siteId/forms/:formIdInternal
Update form settings or status. Set status to active to start accepting submissions.
POST/api/sites/:siteId/forms/:formId/fieldsInternal
Add a field to a form.
Request Body
| Param | Type | Required | Description |
|---|
| name | string | Yes | Field label |
| slug | string | Yes | Identifier (used as key in submissions) |
| field_type | string | No | text, email, textarea, number, select, checkbox, url, phone, date, hidden |
| placeholder | string | No | Input placeholder text |
| required | boolean | No | Default: false |
| options | object | No | { "choices": ["a", "b"] } for select fields |
| position | number | No | Display order (0-based) |
GET/api/sites/:siteId/forms/:formId/submissionsInternal
List all submissions for a form, ordered by most recent first.
How to use Forms on your website
This replaces services like FormSubmit, Formspree, etc. You own the data and control what happens on submit (create contacts, subscribe, tag, send to inbox).
Step 1: Create a form in the dashboard, add fields, configure on-submit actions, and set it to active.
Step 2: Copy the Form ID from the editor.
Step 3: Use one of the examples below to integrate it on your site.
Plain HTML + JavaScript
The simplest way. The form submits to your own backend proxy (to keep the API token secret), which forwards to the Multi-Site API.
Frontend — contact-form.htmlhtml
<form id="contact-form">
<input type="text" name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="you@example.com" required />
<textarea name="message" placeholder="Your message..." required></textarea>
<button type="submit">Send</button>
</form>
<div id="form-status"></div>
<script>
document.getElementById('contact-form')
.addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('form-status');
const form = new FormData(e.target);
status.textContent = 'Sending...';
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.get('name'),
email: form.get('email'),
message: form.get('message'),
}),
});
const data = await res.json();
if (res.ok) {
status.textContent = data.message; // success_message from the form
e.target.reset();
} else {
status.textContent = data.error || 'Something went wrong';
}
});
</script>Your backend proxy — keeps the API token secretjavascript
// POST /api/contact
app.post('/api/contact', async (req, res) => {
const response = await fetch(
'multi-site-manager.vercel.app/api/public/forms/YOUR_FORM_ID',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body), // { name, email, message }
}
);
const data = await response.json();
res.status(response.status).json(data);
});React / Next.js Server Action
app/actions.tstypescript
'use server'
export async function submitContactForm(formData: FormData) {
const res = await fetch(
process.env.MULTI_SITE_URL + '/api/public/forms/' + process.env.FORM_ID,
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
}),
}
);
if (!res.ok) {
const err = await res.json();
return { error: err.error || 'Failed to submit' };
}
const data = await res.json();
return { success: true, message: data.message };
}app/contact/page.tsxtypescript
'use client'
import { submitContactForm } from './actions';
import { useState } from 'react';
export default function ContactPage() {
const [status, setStatus] = useState('');
async function handleSubmit(formData: FormData) {
setStatus('Sending...');
const result = await submitContactForm(formData);
setStatus(result.error || result.message || 'Done');
}
return (
<form action={handleSubmit}>
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
{status && <p>{status}</p>}
</form>
);
}Dynamic form rendering
Fetch the form schema from the API and render fields dynamically. Useful if you have multiple forms or change fields frequently.
app/api/form-schema/route.ts — backend proxytypescript
export async function GET() {
const res = await fetch(
process.env.MULTI_SITE_URL + '/api/public/forms/' + process.env.FORM_ID,
{ headers: { 'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN } }
);
return Response.json(await res.json());
}components/dynamic-form.tsxtypescript
'use client'
import { useEffect, useState } from 'react';
interface Field {
slug: string; name: string; field_type: string;
placeholder: string; required: boolean; options: { choices?: string[] };
}
export function DynamicForm() {
const [fields, setFields] = useState<Field[]>([]);
const [successMsg, setSuccessMsg] = useState('');
const [status, setStatus] = useState('');
useEffect(() => {
fetch('/api/form-schema')
.then(r => r.json())
.then(data => {
setFields(data.fields);
setSuccessMsg(data.success_message);
});
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const body: Record<string, string> = {};
fields.forEach(f => { body[f.slug] = form.get(f.slug) as string; });
setStatus('Sending...');
const res = await fetch('/api/form-submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setStatus(res.ok ? successMsg : 'Error');
if (res.ok) e.currentTarget.reset();
}
return (
<form onSubmit={handleSubmit}>
{fields.map(f => (
<div key={f.slug}>
<label>{f.name}{f.required && ' *'}</label>
{f.field_type === 'textarea' ? (
<textarea name={f.slug} placeholder={f.placeholder} required={f.required} />
) : f.field_type === 'select' ? (
<select name={f.slug} required={f.required}>
<option value="">Select...</option>
{f.options.choices?.map(c => <option key={c} value={c}>{c}</option>)}
</select>
) : f.field_type === 'checkbox' ? (
<input type="checkbox" name={f.slug} />
) : (
<input type={f.field_type} name={f.slug}
placeholder={f.placeholder} required={f.required} />
)}
</div>
))}
<button type="submit">Submit</button>
{status && <p>{status}</p>}
</form>
);
}Never expose your API token in client-side code. Always proxy through your backend. The examples above use /api/contact or/api/form-submit as backend routes that add the token server-side.
Integration Examples
HTML Form + JavaScript
Add a subscribe form to any website. The API call should go through your backend to keep the token secret.
Frontend (subscribe form)html
<form id="subscribe-form">
<input type="email" name="email" placeholder="your@email.com" required />
<input type="text" name="name" placeholder="Your name" />
<button type="submit">Subscribe</button>
</form>
<script>
document.getElementById('subscribe-form')
.addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(e.target);
// Call YOUR backend, not the API directly
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.get('email'),
name: form.get('name'),
tags: ['website'], // track source
}),
});
if (res.ok) alert('Subscribed!');
});
</script>Your backend proxy (Node.js example)javascript
// server.js — keeps your API token secret
app.post('/api/subscribe', async (req, res) => {
const response = await fetch(
'multi-site-manager.vercel.app/api/public/subscribe',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: req.body.email,
name: req.body.name,
tags: req.body.tags, // optional: pass tags from the client
}),
}
);
const data = await response.json();
res.status(response.status).json(data);
});
React / Next.js Server Action
app/actions.tstypescript
'use server'
export async function subscribe(formData: FormData) {
const res = await fetch(
process.env.MULTI_SITE_URL + '/api/public/subscribe',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.get('email'),
name: formData.get('name'),
}),
}
);
if (!res.ok) throw new Error('Subscription failed');
return { success: true };
}
Contact Form (using Forms API)
If you've created a form in the dashboard, you can fetch its schema and render it dynamically, or just hardcode the fields and submit directly.
Dynamic form rendering (React)typescript
// 1. Fetch form schema from your backend
const res = await fetch('/api/my-form-schema');
const form = await res.json();
// 2. Render fields dynamically
form.fields.map(field => (
<input
key={field.slug}
name={field.slug}
type={field.field_type === 'textarea' ? undefined : field.field_type}
placeholder={field.placeholder}
required={field.required}
/>
));
// 3. Submit
const submitRes = await fetch('/api/my-form-submit', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(new FormData(formEl))),
});
const { message } = await submitRes.json(); // success_messageYour backend proxyjavascript
// GET /api/my-form-schema
app.get('/api/my-form-schema', async (req, res) => {
const response = await fetch(
'multi-site-manager.vercel.app/api/public/forms/FORM_ID',
{ headers: { 'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN } }
);
res.json(await response.json());
});
// POST /api/my-form-submit
app.post('/api/my-form-submit', async (req, res) => {
const response = await fetch(
'multi-site-manager.vercel.app/api/public/forms/FORM_ID',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
}
);
res.status(response.status).json(await response.json());
});
Contact Messages
Send a message directly to the site's inbox without using Forms.
Node.jsjavascript
const response = await fetch(
'multi-site-manager.vercel.app/api/public/messages',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: req.body.email,
name: req.body.name,
subject: 'Contact from website',
body: req.body.message,
}),
}
);
Newsletter integration (subscribe + unsubscribe)
Complete example: subscribe form on your site + unsubscribe page that processes signed links from campaign emails.
app/api/subscribe/route.ts — backend proxytypescript
// Keeps SITE_API_TOKEN secret — called by your frontend
export async function POST(req: Request) {
const { email, name } = await req.json();
const res = await fetch(
process.env.MULTI_SITE_URL + '/api/public/subscribe',
{
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.SITE_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, name }),
}
);
return Response.json(await res.json(), { status: res.status });
}app/unsubscribe/page.tsx — required on your sitetypescript
// Your site must have this page at /unsubscribe.
// The manager redirects here after processing the unsubscription.
const MANAGER_URL = process.env.MULTI_SITE_URL ?? 'multi-site-manager.vercel.app';
export default async function UnsubscribePage({
searchParams,
}: {
searchParams: Promise<{ done?: string; s?: string; c?: string; e?: string; t?: string }>;
}) {
const params = await searchParams;
if (params.done === '1') {
return (
<main style={{ textAlign: 'center', padding: '4rem' }}>
<h1>You've been unsubscribed</h1>
<p>You won't receive any more emails from us.</p>
</main>
);
}
const { s, c, e, t } = params;
if (!s || !c || !t) {
return <main><p>Invalid unsubscribe link.</p></main>;
}
const apiUrl =
MANAGER_URL + '/api/public/unsubscribe' +
`?s=${s}&c=${c}&e=${encodeURIComponent(e ?? '')}&t=${t}`;
return (
<main style={{ textAlign: 'center', padding: '4rem' }}>
<h1>Unsubscribe from emails?</h1>
{e && <p>We'll stop sending to <strong>{decodeURIComponent(e)}</strong>.</p>}
<a href={apiUrl}
style={{ display: 'inline-block', marginTop: '1rem',
padding: '0.75rem 2rem', background: '#dc2626',
color: '#fff', borderRadius: '8px', textDecoration: 'none' }}>
Yes, unsubscribe me
</a>
</main>
);
}Set your site's website_url in Settings → General so that the unsubscribe link in every campaign email points to your own domain, not the manager app.
Python
subscribe.pypython
import requests
response = requests.post(
"multi-site-manager.vercel.app/api/public/subscribe",
headers={
"Authorization": "Bearer YOUR_SITE_API_TOKEN",
"Content-Type": "application/json",
},
json={
"email": "user@example.com",
"name": "John Doe",
},
)
print(response.json())
# {'message': 'Subscribed successfully'}