Your First Plugin — Mailchimp Sync
The third-party plugin developer program isn't open yet.
This page documents the plugin SDK and manifest format that power VeloCMS's own first-party integrations. VeloCMS is a hosted platform, so a plugin built from this page can't be installed on a VeloCMS site, and submitting or publishing one as an outside developer isn't something you can do today. There's no date for that to change. See Marketplace Policies for what's live now.
This tutorial walks through a complete example, shaped like VeloCMS's first-party integrations. By the end you'll have a plugin project that listens for the afterMemberSignup hook, reads a Mailchimp API key from the tenant's encrypted settings, and adds the new member to an audience, with unit tests passing on your machine. It won't be a toy, but it can't be installed on a VeloCMS site either.
What you'll build
A plugin called mailchimp-sync. When a reader signs up on a blog, your plugin fires, grabs their email address from the hook payload, and calls the Mailchimp Members API. The API key is entered by the blog owner in the admin settings panel — your plugin renders that panel using the admin.tsx entry point. The key itself is stored encrypted (AES-256-GCM) in VeloCMS, so it never appears in plaintext in the database.
Step 1 — Scaffold
npx create-velocms-plugin mailchimp-sync \
--category integration \
--type integrationStep 2 — Declare capabilities in the manifest
Open manifest.json and update capabilities and hooks. You need members:read (the hook payload carries member data), and network:fetch (to call api.mailchimp.com). Add the network access block too — it tells tenants exactly which domains you connect to.
{
"capabilities": ["members:read", "network:fetch"],
"hooks": ["afterMemberSignup"],
"network_access": {
"allowedDomains": ["api.mailchimp.com"],
"reasoning": "Sync new member emails to a Mailchimp audience"
}
}Step 3 — Implement the runtime handler
Replace the scaffold's placeholder in src/index.ts. The afterMemberSignup handler receives a MemberHookPayload and a PluginContext. You read the Mailchimp API key from ctx.settings — VeloCMS decrypts it before passing it in. If the key is missing, log a warning and return early rather than throw; throwing would mark the hook as failed and surface an error to the blog owner.
import type {
MemberHookPayload,
PluginContext,
} from "@velocms/plugin-types";
export async function afterMemberSignup(
payload: MemberHookPayload,
ctx: PluginContext
): Promise<MemberHookPayload> {
// ctx.settings is injected by VeloCMS from the plugin's saved config
const apiKey = (ctx as { settings?: { api_key?: string } }).settings?.api_key;
const listId = (ctx as { settings?: { list_id?: string } }).settings?.list_id;
if (!apiKey || !listId) {
ctx.warn("Mailchimp API key or list ID not configured — skipping sync");
return payload;
}
const dc = apiKey.split("-").pop() ?? "us1";
const url = `https://${dc}.api.mailchimp.com/3.0/lists/${listId}/members`;
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${btoa("anystring:" + apiKey)}`,
},
body: JSON.stringify({
email_address: payload.member.email,
status: "subscribed",
merge_fields: {
FNAME: payload.member.display_name ?? "",
},
}),
});
if (!res.ok && res.status !== 400) {
// 400 = already subscribed — not a real error
ctx.error("Mailchimp sync failed", { status: res.status });
} else {
ctx.log("Mailchimp sync successful", { email: payload.member.email });
}
} catch (err) {
ctx.error("Mailchimp fetch error", {
error: err instanceof Error ? err.message : String(err),
});
}
return payload;
}Step 4 — Build the admin settings panel
Rename src/admin.template to src/admin.tsx and add two form fields: one for the API key and one for the audience list ID. Use plain React — no Tailwind inside the plugin itself; VeloCMS wraps your component in the admin theme. The onSave callback receives the form values, which VeloCMS encrypts before storage.
// src/admin.tsx
"use client";
import { useState } from "react";
interface Props {
initialSettings?: { api_key?: string; list_id?: string };
onSave?: (s: { api_key: string; list_id: string }) => Promise<void>;
}
export default function MailchimpSyncSettings({ initialSettings = {}, onSave }: Props) {
const [apiKey, setApiKey] = useState(initialSettings.api_key ?? "");
const [listId, setListId] = useState(initialSettings.list_id ?? "");
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
await onSave?.({ api_key: apiKey, list_id: listId });
setSaving(false);
}
return (
<form onSubmit={handleSubmit}>
<label>
Mailchimp API Key
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="xxxx-us1"
required
/>
</label>
<label>
Audience List ID
<input
type="text"
value={listId}
onChange={(e) => setListId(e.target.value)}
placeholder="abc123def"
required
/>
</label>
<button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save settings"}
</button>
</form>
);
}Update manifest.json to declare the admin entry: "entry": { "runtime": "./dist/runtime.js", "admin": "./dist/admin.js" }.
Step 5 — Write tests
The scaffold's test file already checks that the manifest exports correctly. Add a test for the handler — use mockVelocms from the SDK to inject a fake settings object and intercept the fetch call. VeloCMS decrypts the API key before the handler sees it, so your test just passes a plaintext string.
import { mockVelocms } from "@velocms/plugin-sdk/test-helpers";
import { afterMemberSignup } from "../src/index.js";
test("syncs member to Mailchimp on signup", async () => {
const ctx = mockVelocms({
settings: { api_key: "testapikey-us1", list_id: "abc123" },
fetchResponse: { ok: true, status: 200, data: { id: "new_member" } },
});
const payload = {
member: {
id: "m1",
email: "[email protected]",
display_name: "New User",
tier: "free",
subscribed_at: null,
tenant_id: "t1",
},
};
const result = await afterMemberSignup(payload, ctx as never);
assert.equal(ctx._fetchCalls.length, 1);
assert.ok(ctx._fetchCalls[0]?.url.includes("mailchimp.com"));
assert.deepEqual(result, payload); // payload returned unchanged
});Step 6 — Build the release artifact
npm run build
velocms build # validates the manifest, packs mailchimp-sync-1.0.0.tgzThat tarball has the same structure as the first-party plugins the VeloCMS team ships. Third-party plugin development isn't open yet, though. VeloCMS is a hosted platform with no marketplace for outside developers, so there's nowhere to upload this and no way to install it on a site. If you think Mailchimp sync (or something like it) belongs in VeloCMS itself, email [email protected] — see Marketplace Policies for how third-party code fits into the platform.