Your First Plugin — Mailchimp Sync

This tutorial builds something real. By the end you'll have a plugin that listens for the afterMemberSignuphook, reads a Mailchimp API key from the tenant's encrypted settings, and adds the new member to an audience. It won't be a toy.

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 integration

Step 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.tgz

That tarball is the same artifact VeloCMS would load in the sandbox — it's a real, finished plugin, not a toy. What happens to it next depends on where you're running it: VeloCMS doesn't operate an open marketplace where outside developers submit and sell plugins to other tenants, so there's no queue to upload this into. If you're self-hosting, wire it into your own instance directly. 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.