Skip to main content
Business & GrowthNewsletter Marketing Services354 lines

Buttondown

"Buttondown: minimal newsletter API, subscribers, emails/drafts, tags, automations, webhooks, Markdown-first, REST API"

Quick Summary18 lines
Buttondown is a minimalist, Markdown-first newsletter platform with a clean REST API at `https://api.buttondown.email/v1/`. It prioritizes simplicity and developer experience: flat resource endpoints, predictable pagination, and Markdown as the primary content format. Authentication uses a single API key via header. Buttondown is ideal for developers and writers who want programmatic newsletter management without the complexity of enterprise email platforms. Design integrations that embrace Markdown content, use tags for lightweight segmentation, and leverage webhooks for event-driven workflows.

## Key Points

- **Write content in Markdown**: Buttondown's core strength is Markdown rendering. Send Markdown in the `body` field and let the platform handle HTML conversion.
- **Use `draft` status for review workflows**: Create emails as drafts, review them in the Buttondown UI, then update status to send or schedule programmatically.
- **Leverage subscriber metadata**: The `metadata` field accepts arbitrary JSON. Use it for tracking custom attributes without creating separate database tables.
- **Paginate all list endpoints**: Buttondown returns paginated results with `next`/`previous` URLs. Always follow pagination for complete data retrieval.
- **Tag at creation time**: Assign tags when creating subscribers rather than making a separate update call. This reduces API calls and ensures immediate segmentation.
- **Use webhooks for automation triggers**: Subscribe to `subscriber.created` and `email.sent` events to trigger downstream workflows in real time.
- **Store subscriber IDs**: Buttondown uses UUIDs for subscribers. Cache these to avoid repeated lookups by email address.
- **Sending HTML in the body field**: Buttondown expects Markdown. Sending raw HTML will result in double-rendered or broken content. Convert HTML to Markdown before submission.
- **Polling for new subscribers**: Use the `subscriber.created` webhook instead of repeatedly fetching the subscriber list to detect new sign-ups.
- **Ignoring subscriber_type**: Subscribers can be `unactivated` (double opt-in pending) or `removed`. Always filter by `regular` or `premium` when building active subscriber counts.
- **Creating tags with duplicate names**: Buttondown allows duplicate tag names, which creates confusion. Always check existing tags before creating new ones.
- **Setting status to `about_to_send` without review**: Once set, the email sends immediately. Use `draft` or `scheduled` status for anything that needs review.
skilldb get newsletter-marketing-services-skills/ButtondownFull skill: 354 lines
Paste into your CLAUDE.md or agent config

Buttondown Newsletter Platform Integration

Core Philosophy

Buttondown is a minimalist, Markdown-first newsletter platform with a clean REST API at https://api.buttondown.email/v1/. It prioritizes simplicity and developer experience: flat resource endpoints, predictable pagination, and Markdown as the primary content format. Authentication uses a single API key via header. Buttondown is ideal for developers and writers who want programmatic newsletter management without the complexity of enterprise email platforms. Design integrations that embrace Markdown content, use tags for lightweight segmentation, and leverage webhooks for event-driven workflows.

Setup

Authentication and Client Configuration

interface ButtondownConfig {
  apiKey: string;
  baseUrl?: string;
}

class ButtondownClient {
  private readonly baseUrl: string;
  private readonly headers: Record<string, string>;

  constructor(config: ButtondownConfig) {
    this.baseUrl = config.baseUrl ?? "https://api.buttondown.email/v1";
    this.headers = {
      Authorization: `Token ${config.apiKey}`,
      "Content-Type": "application/json",
    };
  }

  async request<T>(
    method: string,
    path: string,
    body?: unknown,
    params?: Record<string, string>
  ): Promise<T> {
    const query = params ? `?${new URLSearchParams(params)}` : "";
    const url = `${this.baseUrl}${path}${query}`;
    const res = await fetch(url, {
      method,
      headers: this.headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!res.ok) {
      const errorBody = await res.text();
      throw new Error(`Buttondown ${method} ${path} ${res.status}: ${errorBody}`);
    }

    if (res.status === 204) return undefined as T;
    return res.json() as Promise<T>;
  }
}

const bd = new ButtondownClient({
  apiKey: process.env.BUTTONDOWN_API_KEY!,
});

Verify Connection

async function verifyConnection(client: ButtondownClient): Promise<void> {
  const res = await client.request<{ username: string; email: string }>(
    "GET",
    "/newsletters"
  );
  // v1 returns newsletter metadata; confirms API key is valid
  console.log("Buttondown API connection verified");
}

Key Techniques

Subscriber Management

interface BDSubscriber {
  id: string;
  email: string;
  notes: string;
  creation_date: string;
  tags: string[];
  metadata: Record<string, unknown>;
  subscriber_type: "regular" | "premium" | "gift" | "unactivated" | "removed";
  source: string;
  utm_source?: string;
  utm_medium?: string;
  utm_campaign?: string;
  referrer_url?: string;
}

interface PaginatedResponse<T> {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
}

async function createSubscriber(
  client: ButtondownClient,
  email: string,
  opts?: {
    notes?: string;
    tags?: string[];
    metadata?: Record<string, unknown>;
    referrerUrl?: string;
    utmSource?: string;
  }
): Promise<BDSubscriber> {
  return client.request<BDSubscriber>("POST", "/subscribers", {
    email,
    notes: opts?.notes ?? "",
    tags: opts?.tags ?? [],
    metadata: opts?.metadata ?? {},
    referrer_url: opts?.referrerUrl,
    utm_source: opts?.utmSource,
  });
}

async function listSubscribers(
  client: ButtondownClient,
  opts?: { page?: number; type?: string; tag?: string }
): Promise<PaginatedResponse<BDSubscriber>> {
  const params: Record<string, string> = {};
  if (opts?.page) params.page = String(opts.page);
  if (opts?.type) params.subscriber_type = opts.type;
  if (opts?.tag) params.tag = opts.tag;

  return client.request("GET", "/subscribers", undefined, params);
}

async function updateSubscriber(
  client: ButtondownClient,
  subscriberId: string,
  updates: Partial<{
    email: string;
    notes: string;
    tags: string[];
    metadata: Record<string, unknown>;
    subscriber_type: string;
  }>
): Promise<BDSubscriber> {
  return client.request<BDSubscriber>(
    "PATCH",
    `/subscribers/${subscriberId}`,
    updates
  );
}

async function deleteSubscriber(
  client: ButtondownClient,
  subscriberId: string
): Promise<void> {
  await client.request("DELETE", `/subscribers/${subscriberId}`);
}

async function* getAllSubscribers(
  client: ButtondownClient
): AsyncGenerator<BDSubscriber> {
  let page = 1;
  while (true) {
    const res = await listSubscribers(client, { page });
    for (const sub of res.results) yield sub;
    if (!res.next) break;
    page++;
  }
}

Emails and Drafts

interface BDEmail {
  id: string;
  subject: string;
  body: string; // Markdown
  creation_date: string;
  modification_date: string;
  publish_date: string | null;
  status: "draft" | "scheduled" | "sent" | "imported";
  email_type: "public" | "private" | "premium";
  secondary_id: number;
  slug: string;
  external_url: string;
}

async function createDraft(
  client: ButtondownClient,
  subject: string,
  body: string, // Markdown content
  opts?: { emailType?: "public" | "private" | "premium"; slug?: string }
): Promise<BDEmail> {
  return client.request<BDEmail>("POST", "/emails", {
    subject,
    body,
    status: "draft",
    email_type: opts?.emailType ?? "public",
    slug: opts?.slug,
  });
}

async function sendEmail(
  client: ButtondownClient,
  subject: string,
  markdownBody: string,
  opts?: { emailType?: "public" | "private" | "premium" }
): Promise<BDEmail> {
  return client.request<BDEmail>("POST", "/emails", {
    subject,
    body: markdownBody,
    status: "about_to_send",
    email_type: opts?.emailType ?? "public",
  });
}

async function scheduleEmail(
  client: ButtondownClient,
  emailId: string,
  publishDate: Date
): Promise<BDEmail> {
  return client.request<BDEmail>("PATCH", `/emails/${emailId}`, {
    status: "scheduled",
    publish_date: publishDate.toISOString(),
  });
}

async function listEmails(
  client: ButtondownClient,
  status?: string
): Promise<PaginatedResponse<BDEmail>> {
  const params: Record<string, string> = {};
  if (status) params.status = status;
  return client.request("GET", "/emails", undefined, params);
}

Tags

interface BDTag {
  id: string;
  name: string;
  creation_date: string;
  description: string;
  color: string;
}

async function createTag(
  client: ButtondownClient,
  name: string,
  opts?: { description?: string; color?: string }
): Promise<BDTag> {
  return client.request<BDTag>("POST", "/tags", {
    name,
    description: opts?.description ?? "",
    color: opts?.color ?? "#000000",
  });
}

async function listTags(client: ButtondownClient): Promise<BDTag[]> {
  const res = await client.request<PaginatedResponse<BDTag>>("GET", "/tags");
  return res.results;
}

async function tagSubscribers(
  client: ButtondownClient,
  tagName: string,
  emails: string[]
): Promise<void> {
  // Find or create the tag
  const tags = await listTags(client);
  let tag = tags.find((t) => t.name === tagName);
  if (!tag) {
    tag = await createTag(client, tagName);
  }

  // Update each subscriber to include the tag
  for (const email of emails) {
    const subs = await listSubscribers(client, { page: 1 });
    const sub = subs.results.find((s) => s.email === email);
    if (sub && !sub.tags.includes(tag.id)) {
      await updateSubscriber(client, sub.id, {
        tags: [...sub.tags, tag.id],
      });
    }
  }
}

Webhooks

interface BDWebhook {
  id: string;
  url: string;
  event_type: string;
  creation_date: string;
}

type BDWebhookEvent =
  | "subscriber.created"
  | "subscriber.updated"
  | "subscriber.deleted"
  | "subscriber.confirmed"
  | "subscriber.unsubscribed"
  | "email.created"
  | "email.sent"
  | "email.drafted";

async function createWebhook(
  client: ButtondownClient,
  url: string,
  eventType: BDWebhookEvent
): Promise<BDWebhook> {
  return client.request<BDWebhook>("POST", "/webhooks", {
    url,
    event_type: eventType,
  });
}

async function listWebhooks(
  client: ButtondownClient
): Promise<BDWebhook[]> {
  const res = await client.request<PaginatedResponse<BDWebhook>>(
    "GET",
    "/webhooks"
  );
  return res.results;
}

Best Practices

  • Write content in Markdown: Buttondown's core strength is Markdown rendering. Send Markdown in the body field and let the platform handle HTML conversion.
  • Use draft status for review workflows: Create emails as drafts, review them in the Buttondown UI, then update status to send or schedule programmatically.
  • Leverage subscriber metadata: The metadata field accepts arbitrary JSON. Use it for tracking custom attributes without creating separate database tables.
  • Paginate all list endpoints: Buttondown returns paginated results with next/previous URLs. Always follow pagination for complete data retrieval.
  • Tag at creation time: Assign tags when creating subscribers rather than making a separate update call. This reduces API calls and ensures immediate segmentation.
  • Use webhooks for automation triggers: Subscribe to subscriber.created and email.sent events to trigger downstream workflows in real time.
  • Store subscriber IDs: Buttondown uses UUIDs for subscribers. Cache these to avoid repeated lookups by email address.

Anti-Patterns

  • Sending HTML in the body field: Buttondown expects Markdown. Sending raw HTML will result in double-rendered or broken content. Convert HTML to Markdown before submission.
  • Polling for new subscribers: Use the subscriber.created webhook instead of repeatedly fetching the subscriber list to detect new sign-ups.
  • Ignoring subscriber_type: Subscribers can be unactivated (double opt-in pending) or removed. Always filter by regular or premium when building active subscriber counts.
  • Creating tags with duplicate names: Buttondown allows duplicate tag names, which creates confusion. Always check existing tags before creating new ones.
  • Setting status to about_to_send without review: Once set, the email sends immediately. Use draft or scheduled status for anything that needs review.
  • Neglecting error response bodies: Buttondown returns detailed validation errors in the response body. Always parse and log these for debugging.

Install this skill directly: skilldb add newsletter-marketing-services-skills

Get CLI access →