Documentation
Maildoubleone gives you a hosted blog and form backend for any website. Consume it via the JSON API, or drop in the embed widget — no backend required.
Quick Start
1. Create an account
Go to /portal/register and sign up. You'll be asked for your organisation name and the name of your first site. Both are created automatically — no extra configuration needed.
After registration you land directly in your site dashboard.
2. Find your site slug and API key
In the portal, go to Integration & API. Your site slug appears in every endpoint URL:
https://yourdomain.com/api/v1/your-site-slug/posts
Your API key is shown on the same page (reveal and copy). Keep it secret — it's for future management endpoints.
Blog API
Read-only JSON endpoints. All responses are throttled to 60 requests per minute per IP.
Base URL: https://yourdomain.com/api/v1/{site-slug}
GET
/posts
Returns a paginated list of published posts.
| Parameter | Type | Description |
|---|---|---|
page | integer | Page number (default: 1) |
per_page | integer | Results per page, max 50 (default: 6) |
category | string | Filter by category slug |
tag | string | Filter by tag slug |
author | string | Filter by author slug |
featured | 1 | Return only featured posts |
search | string | Full-text search in title and excerpt |
{
"data": [
{
"id": 12,
"title": "Getting started with headless blogs",
"slug": "getting-started-with-headless-blogs",
"excerpt": "A short summary of the post...",
"cover_image": "https://yourdomain.com/storage/covers/my-image.jpg",
"published_at": "2025-05-01T10:00:00+00:00",
"reading_time_minutes": 4,
"is_featured": true,
"view_count": 142,
"author": { "name": "Jane Doe", "slug": "jane-doe", "avatar": null },
"categories": [{ "name": "Tutorials", "slug": "tutorials" }],
"tags": [{ "name": "API", "slug": "api" }]
}
],
"meta": {
"current_page": 1,
"last_page": 4,
"per_page": 6,
"total": 22
}
}
GET
/posts/{post-slug}
Returns a single published post including full HTML content. Also increments view_count.
{
"data": {
"id": 12,
"title": "Getting started with headless blogs",
"slug": "getting-started-with-headless-blogs",
"content": "<p>Full HTML content here...</p>",
"excerpt": "A short summary...",
"cover_image": "https://cdn.example.com/covers/my-image.jpg",
"published_at": "2025-05-01T10:00:00+00:00",
"reading_time_minutes": 4,
"is_featured": true,
"view_count": 143,
"author": { "name": "Jane Doe", "slug": "jane-doe", "avatar": null, "bio": "..." },
"categories": [{ "name": "Tutorials", "slug": "tutorials" }],
"tags": [{ "name": "API", "slug": "api" }],
"seo": {
"meta_title": "Custom SEO title (falls back to title)",
"meta_description": "Custom SEO description (falls back to excerpt)",
"og_image": "https://cdn.example.com/og/custom-og.jpg",
"canonical_url": "https://yourwebsite.com/blog/getting-started-with-headless-blogs",
"og_page_url": "https://yourdomain.com/blog/your-site-slug/getting-started-...",
"robots": "index, follow",
"focus_keyword": "headless blog",
"schema_type": "BlogPosting"
},
"json_ld": { "@context": "https://schema.org", "@type": "BlogPosting", "...": "..." }
}
}
GET
/categories
All categories ordered by sort_order then name. Includes published post count.
{
"data": [
{
"id": 3, "name": "Tutorials", "slug": "tutorials",
"description": null, "cover_image": null,
"parent_slug": null, "post_count": 8
}
]
}
GET
/config
Returns the site's branding tokens and embed settings. Use this to theme your own frontend to match the settings configured in the portal.
{
"data": {
"site": { "name": "My Blog", "slug": "my-blog", "description": null },
"branding": {
"logo_url": null,
"primary_color": "#1e3a5f",
"accent_color": "#3b82f6",
"text_color": "#111827",
"background_color": "#ffffff",
"font_heading": "Inter",
"font_body": "Inter",
"custom_css": null
},
"embed": {
"blog_theme": "modern",
"blog_layout": "grid",
"blog_posts_per_page": 6,
"blog_show_author": true,
"blog_show_date": true,
"blog_show_excerpt": true,
"blog_show_tags": false,
"blog_show_cover": true,
"form_theme": "modern",
"form_show_labels": true,
"form_show_placeholders": true,
"widget_language": "en",
"blog_url_pattern": "https://yourwebsite.com/blog/{slug}"
}
}
}
The embed.blog_url_pattern value is what the blog widget uses to build post card links.
Set it in Site Settings → Site Info → Blog post URL pattern.
Forms API
Each form you create gets a unique token. Point your HTML form's action
— or your JavaScript fetch — at the submit endpoint. Find your token in
Integration & API → Forms.
POST
/api/f/{form-token}
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | application/x-www-form-urlencoded or application/json |
Origin | Auto | Set by browser; must match your Allowed Domains |
Submit any field names — they're stored as-is. Two reserved names:
_honeypot— include a hidden empty field with this name to catch bots_redirect— URL to redirect to after successful submission (HTML forms only)
Success response:
{ "success": true, "message": "Submission received." }
HTML form example
<form action="https://yourdomain.com/api/f/YOUR_TOKEN" method="POST">
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Email address" required>
<textarea name="message" placeholder="Message"></textarea>
<!-- Honeypot: hidden from humans, filled in by bots -->
<input type="text" name="_honeypot"
style="display:none" tabindex="-1" autocomplete="off">
<!-- Optional redirect after submit -->
<input type="hidden" name="_redirect" value="https://yoursite.com/thank-you">
<button type="submit">Send Message</button>
</form>
JavaScript / fetch example
async function submitForm(event) {
event.preventDefault();
const res = await fetch('https://yourdomain.com/api/f/YOUR_TOKEN', {
method: 'POST',
body: new FormData(event.target),
});
const json = await res.json();
if (json.success) {
alert('Message sent!');
} else {
console.error(json.errors);
}
}
document.getElementById('contact-form')
.addEventListener('submit', submitForm);
Content-Type: application/json and pass a flat object:
body: JSON.stringify({ name: '...', email: '...' })
Blog Widget
The drop-in widget renders your blog directly on any page. It reads your embed settings from the portal automatically — no configuration in the script tag required.
Installation
Add a container element and the script tag anywhere in your HTML:
<!-- Container -->
<div id="e1-blog"></div>
<!-- Widget -->
<script
src="https://yourdomain.com/widget.js"
data-site="your-site-slug"
data-type="blog">
</script>
The widget fetches your posts, applies your branding, and renders with pagination inside #e1-blog.
Script attributes
| Attribute | Required | Default | Description |
|---|---|---|---|
data-site | Yes | — | Your site slug |
data-type | No | blog | Widget type |
data-target | No | e1-blog | ID of the container element |
Themes & layouts
Configure in Site Settings → Embed Options. Applied automatically to the widget.
| Setting | Options |
|---|---|
| Theme | modern · minimal · card · magazine |
| Layout | grid · list |
| Posts per page | 1 – 50 |
| Show cover / author / date / excerpt / tags | on / off per toggle |
Custom CSS
In Site Settings → Branding → Custom CSS, write CSS that gets injected into the widget automatically. Override CSS custom properties to change colours without touching anything else:
.e1-widget {
--e1-primary: #1e3a5f; /* primary colour */
--e1-accent: #3b82f6; /* links and highlights */
--e1-text: #111827; /* body text */
--e1-bg: #ffffff; /* card background */
--e1-font-head: 'Playfair Display', serif;
--e1-font-body: 'Inter', sans-serif;
}
All widget elements use the .e1- class prefix — they won't clash with your site's existing CSS.
SEO & Open Graph
Every published post exposes a rich seo object and structured data (JSON-LD) through the API.
Use these to populate <meta> tags so that when a post URL is shared on social media — Twitter/X,
LinkedIn, Facebook, WhatsApp, iMessage — the platform renders a proper link card with your title, excerpt and cover image.
SEO fields returned by /posts/{post-slug}
The single-post endpoint returns a seo object alongside the post data.
Fill in these values in the portal's Blog Posts → Edit → SEO tab.
All fields fall back gracefully when left blank.
{
"data": {
"id": 12,
"title": "Getting started with headless blogs",
"slug": "getting-started-with-headless-blogs",
"excerpt": "A short summary of the post...",
"cover_image": "https://cdn.example.com/covers/my-image.jpg",
"content": "<p>Full HTML content...</p>",
"published_at": "2025-05-01T10:00:00+00:00",
"reading_time_minutes": 4,
"is_featured": false,
"author": { "name": "Jane Doe", "slug": "jane-doe", "avatar": null },
"categories": [{ "name": "Tutorials", "slug": "tutorials" }],
"tags": [{ "name": "API", "slug": "api" }],
"seo": {
"meta_title": "Custom <title> tag (falls back to post title)",
"meta_description": "Custom meta description (falls back to excerpt)",
"og_image": "https://cdn.example.com/og/my-og.jpg",
"canonical_url": "https://yourwebsite.com/blog/getting-started-with-headless-blogs",
"og_page_url": "https://maildoubleone.com/blog/your-site-slug/getting-started-...",
"robots": "index, follow",
"focus_keyword": "headless blog API",
"schema_type": "BlogPosting"
},
"geo": {
"ai_summary": "One-paragraph AI summary for AI search engines",
"ai_key_takeaways": ["Takeaway one", "Takeaway two"],
"ai_target_questions": ["What is a headless blog?"],
"ai_citations": ["https://source.example.com"]
},
"json_ld": { "@context": "https://schema.org", "@type": "BlogPosting", "..." : "..." }
}
}
| Field | Falls back to | Notes |
|---|---|---|
seo.meta_title | title | Use as <title> and og:title |
seo.meta_description | excerpt | Use as <meta name="description"> and og:description |
seo.og_image | cover_image | Use as og:image and twitter:image. Recommended size: 1200 × 630 px. |
seo.canonical_url | blog_url_pattern + slug | Set this if the post lives at a specific URL on your site |
seo.og_page_url | — | The hosted OG fallback page on this platform (see below) |
seo.robots | index, follow | Pass directly to <meta name="robots"> |
json_ld | — | Ready-to-embed JSON-LD schema. Inline in a <script type="application/ld+json"> tag |
Blog URL pattern
Go to Settings → Site Settings → Site Info and set the Blog post URL pattern field:
https://yourwebsite.com/blog/{slug}
This single setting has three effects:
- Widget links — the embed widget reads this from
/config → embed.blog_url_patternand uses it to build post card links, so clicks land on your site instead of the OG fallback page. - Social share URLs — the
{url}placeholder in auto-share messages resolves tohttps://yourwebsite.com/blog/your-post-slug. - Canonical URL — when
seo.canonical_urlis not set on a post, the API derives it from this pattern and returns it in theseoobject for your SSR layer to use.
data-post-url script attribute:
data-post-url="https://yoursite.com/blog/{slug}".
This takes priority over the portal setting.
Hosted OG fallback page
Every post automatically gets a server-rendered page hosted on this platform at:
https://yourdomain.com/blog/{site-slug}/{post-slug}
This page outputs all Open Graph, Twitter Card and JSON-LD tags as static HTML, so social crawlers can read them without JavaScript. When a real user visits the URL, they are 301-redirected to your site's canonical URL (if set) — they never see the fallback page.
The widget uses this URL as the default post-card link when no Blog post URL pattern is configured. For best results — so that post links on your own site carry the right OG tags — configure the URL pattern and implement server-side meta tags on your own frontend (see below).
Next.js App Router
Next.js generateMetadata runs on the server, which means crawlers receive the full
<head> without executing JavaScript.
The key requirement is that your blog post page must be a server component (no "use client" at the top).
Step 1 — server-side fetch helper (in lib/blog-api.ts):
// Uses native fetch so Next.js can cache/revalidate the response
export async function fetchPostBySlugServer(slug: string): Promise<BlogPost | null> {
const apiBase = process.env.NEXT_PUBLIC_BLOG_API_URL;
const res = await fetch(`${apiBase}/your-site-slug/posts/${slug}`, {
next: { revalidate: 300 }, // cache for 5 minutes
});
if (!res.ok) return null;
const data = await res.json();
return mapPost(data?.data ?? data);
}
Step 2 — server component with generateMetadata (app/blog/[slug]/page.tsx):
// No "use client" here — this is a server component
import type { Metadata } from "next";
import { fetchPostBySlugServer } from "@/lib/blog-api";
import BlogPostClient from "./BlogPostClient"; // "use client" lives here
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPostBySlugServer(slug);
if (!post) return { title: "Post Not Found", robots: { index: false, follow: false } };
const title = post.seo?.meta_title || post.title;
const description = post.seo?.meta_description || post.excerpt;
const ogImage = post.seo?.og_image || post.coverImage;
const canonical = post.seo?.canonical_url || `https://yoursite.com/blog/${post.slug}`;
const robots = post.seo?.robots || "index, follow";
return {
title,
description,
robots,
alternates: { canonical },
openGraph: {
type: "article",
title,
description,
url: canonical,
siteName: "Your Site Name",
publishedTime: post.publishedAt ?? undefined,
authors: post.author?.name ? [post.author.name] : [],
images: ogImage
? [{ url: ogImage, width: 1200, height: 630, alt: title }]
: [{ url: "/images/og-default.png", width: 1200, height: 630, alt: title }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: ogImage ? [ogImage] : ["/images/og-default.png"],
},
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPostBySlugServer(slug);
return <BlogPostClient initialPost={post} />;
}
Step 3 — move UI to a client component (app/blog/[slug]/BlogPostClient.tsx):
"use client";
import { fetchPostBySlug, type BlogPost } from "@/lib/blog-api";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
export default function BlogPostClient({ initialPost }: { initialPost: BlogPost | null }) {
const { slug } = useParams<{ slug: string }>();
const [post, setPost] = useState(initialPost);
// Only fetches client-side if SSR didn't return the post (e.g. navigation without reload)
useEffect(() => {
if (initialPost || !slug) return;
fetchPostBySlug(slug).then(setPost).catch(() => setPost(null));
}, [slug, initialPost]);
if (!post) return <p>Post not found.</p>;
return (
<article>
<h1 className="post-title">{post.title}</h1>
<p className="post-excerpt">{post.excerpt}</p>
{/* render post.content safely */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
post-title, post-excerpt and
post-summary CSS classes are referenced by the JSON-LD speakable
property returned by the API, which helps Google and AI assistants identify the key readable parts of the page.
Set NEXT_PUBLIC_BLOG_API_URL in your .env.local to point at your E1 backend:
NEXT_PUBLIC_BLOG_API_URL=https://yourdomain.com/api/v1
Other frameworks
Nuxt 3 — use useHead() or useSeoMeta()
inside a page component. Fetch the post in useFetch() or useAsyncData()
so the data is available during SSR:
<script setup lang="ts">
const route = useRoute();
const { data: response } = await useFetch(
`${config.public.apiBase}/your-site-slug/posts/${route.params.slug}`
);
const post = response.value?.data;
useSeoMeta({
title: post?.seo?.meta_title ?? post?.title,
description: post?.seo?.meta_description ?? post?.excerpt,
ogImage: post?.seo?.og_image ?? post?.cover_image,
ogType: 'article',
twitterCard: 'summary_large_image',
});
</script>
SvelteKit — return data from the +page.server.ts
load function, then set meta tags in the <svelte:head> block:
// +page.server.ts
export async function load({ params, fetch }) {
const res = await fetch(`${API_BASE}/your-site-slug/posts/${params.slug}`);
const { data: post } = await res.json();
return { post };
}
// +page.svelte
<script>export let data;</script>
<svelte:head>
<title>{data.post.seo?.meta_title ?? data.post.title}</title>
<meta name="description" content={data.post.seo?.meta_description ?? data.post.excerpt}>
<meta property="og:image" content={data.post.seo?.og_image ?? data.post.cover_image}>
<meta name="twitter:card" content="summary_large_image">
</svelte:head>
Plain HTML / static sites — if your site has no server-side rendering,
use the hosted OG page as the shareable URL.
Widget card links point there by default when no blog_url_pattern is set.
Social crawlers will read the tags from the hosted page; humans are redirected to your canonical URL.
Inline JSON-LD — the API returns a ready-made schema in the json_ld
field. Embed it in your <head> on any framework:
<!-- In your <head> -->
<script type="application/ld+json">
<!-- paste the json_ld object from the API response verbatim -->
</script>
In Next.js you can pass it as a string directly:
// In generateMetadata or a <Head> component:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(post.json_ld) }}
/>
CORS & Allowed Domains
The form submit endpoint enforces CORS. By default (no domains configured) all origins are allowed. Once you add at least one domain in Integration & API → Allowed Domains, only those origins can submit.
https://mywebsite.com ← exact origin
*.mywebsite.com ← all subdomains
/api/v1/{slug}/...) is always public.
CORS restrictions apply only to form submissions.
Spam Protection
Add a hidden text field named _honeypot to your form.
Real users won't see or fill it in; bots will. Any submission with a non-empty honeypot
is stored as spam and won't trigger an email notification.
<input type="text" name="_honeypot"
style="display:none; position:absolute; left:-9999px"
tabindex="-1" autocomplete="off">
Spam submissions are still stored so you can review them. Filter to spam-only in Submissions → Filters → Spam: Spam only.
Error Reference
| Status | Meaning |
|---|---|
200 | Success |
404 | Site slug, post slug, or form token not found |
422 | Validation error — check errors in the response body |
429 | Rate limit exceeded — slow down requests |
403 | CORS blocked — add your domain in Site Settings |
// 404
{ "error": "Site not found" }
// 422
{
"message": "The given data was invalid.",
"errors": { "email": ["The email field is required."] }
}
Social Sharing
Automatically post to Twitter/X, LinkedIn and Facebook when you publish a blog post. Sharing is per-post and fully opt-in — you control which posts get shared and what the message says.
1. Connect your social accounts
In the portal, go to Settings → Social Accounts. You'll see a card for each platform — Twitter/X, LinkedIn and Facebook Page. Click Connect on any platform and you'll be taken through that platform's standard OAuth login flow.
Facebook is different: after you log in with your Facebook account, you'll be shown a list of all the Facebook Pages you manage. Select the Page you want posts shared to and click Connect Selected Page. Posts cannot be shared to a personal Facebook profile — a Page is required. You must be an admin of the Page.
The Recent Shares table at the bottom of the same page shows the status of every past share — published, failed, or skipped — along with any error details.
2. Set your blog post URL
Before enabling sharing, tell the platform where blog posts live on your website. Go to Settings → Site Settings → Site Info and fill in the Blog post URL pattern field:
Replace the path (
/blog/) with whatever path your website uses. The{slug}part is replaced automatically with each post's slug. This URL is what appears in every social share and is used as the{url}placeholder below./blog/{slug}. Setting it explicitly is recommended.3. Enable auto-share on a post
When editing or creating a blog post, open the Social Sharing section in the right sidebar. Toggle Auto-share when published on. That's it — when you change the post status to Published and save, the share is dispatched to all connected accounts automatically.
Sharing only happens once — on the first publish. Re-saving a published post will not re-share it. Scheduled posts (published automatically via the scheduler) also respect this toggle.
4. Customise the share message
Leave the Custom share message field blank to use the default:
Or write your own message using these placeholders:
{title}{url}{excerpt}Example custom message:
....Social Preview
Open the Social Preview section in the post editor sidebar to see a mock-up of how your post will appear as a link card on Twitter/X, LinkedIn and Facebook. The preview uses your post title and excerpt (or the SEO meta title / description if set) and your cover image. It updates as you edit — no need to save first.