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 and publish

npm run build
velocms login   # first time only
velocms build   # produces mailchimp-sync-1.0.0.tgz
velocms publish # uploads as draft, prints review URL

The review pipeline runs automated checks (manifest schema, capability declarations, basic security scan) in under 10 minutes. Human review follows within 1-3 business days. You'll get an email at your developer account address when the decision is ready.