penna
Integrations

Send Newsletter

Send a newsletter to your subscribers programmatically via the API.

Send Newsletter

Sends a newsletter to a list of recipients, or to the subscribers of one or more segments. This endpoint requires a Private Key.

Authentication

This endpoint uses both the Public Key and Private Key for authentication.

Endpoint: POST /api/v1/external/projects/newsletters/send

Headers:

  • Content-Type: application/json
  • x-penna-public-key: <YOUR_PUBLIC_KEY>
  • x-penna-private-key: <YOUR_PRIVATE_KEY>

[!WARNING] Never expose your Private Key on the client. This endpoint should only be called from your own backend.

Body

FieldTypeRequiredDescription
subjectstringYesThe email subject line.
contentstringYesThe newsletter body. Supports a small markdown subset (#, ##, ###, blank lines) which is converted to basic HTML before sending.
recipientEmailsstring[]No*Email addresses to send to. Must belong to actual subscribers of this project — see Recipient scoping below. Max 5,000 entries per request.
segmentIdsstring[]No*A list of segment IDs. Subscribers belonging to these segments are resolved and merged with recipientEmails.

* At least one of recipientEmails or segmentIds must resolve to at least one actual subscriber, or the request fails with 400.

{
  "subject": "Our August Update",
  "content": "# Hello!\n\nHere's what's new this month...",
  "recipientEmails": ["user@example.com"],
  "segmentIds": ["b1e2c3d4-5678-90ab-cdef-1234567890ab"]
}

Recipient scoping

recipientEmails is not an open list of arbitrary addresses — every address is checked against this project's subscribed subscribers (via POST /subscriber/new or the dashboard), and anything that doesn't match is silently dropped rather than erroring the whole request. The response's data.skippedNonSubscribers tells you how many were dropped. Combined with segmentIds, the total resolved recipient count (after merging, before scoping) is also capped at 5,000 per request — split larger sends across multiple calls.

Rate limits

Each project has a daily cap on newsletter sends, based on its plan (roughly: hobby 3/day, professional 20/day, business 100/day, enterprise unlimited — see your plan for the exact number). Exceeding it returns 429. This is separate from the general per-IP rate limit on the /external/projects API group.

Content moderation

Before sending, the subject and content are checked for spam, phishing, scam, hate, or adult content. Content that's clearly abusive is blocked outright (403); everything else — including ordinary promotional newsletters — sends normally. This is an anti-abuse check, not an editorial one: legitimate newsletters are not held back for tone, topic, or sales language.

Response

{
  "success": true,
  "message": "Newsletter sent successfully",
  "data": {
    "skippedNonSubscribers": 0
  }
}

Error responses:

// Missing/invalid private key
{ "success": false, "message": "Unauthorized: Private key required" } // 401

// No recipients resolved from recipientEmails/segmentIds after subscriber scoping
{ "success": false, "message": "No recipients specified" } // 400

// Resolved recipient count (recipientEmails + segments) exceeds the per-request cap
{ "success": false, "message": "This request resolved to 7000 recipients, which exceeds the 5000 limit per request. ..." } // 400

// Project is over its plan's daily newsletter-send cap
{ "success": false, "message": "<project> has reached its <plan> plan limit of N newsletter sends per day. Try again tomorrow or upgrade to send more." } // 429

// Content moderation blocked the send
{ "success": false, "message": "This newsletter was blocked by content moderation: <reason>" } // 403

Code Examples

cURL

curl -X POST https://api.penna.dev/api/v1/external/projects/newsletters/send \
  -H "Content-Type: application/json" \
  -H "x-penna-public-key: penn_your_public_key" \
  -H "x-penna-private-key: pk_your_private_key" \
  -d '{
    "subject": "Our August Update",
    "content": "# Hello!\n\nHere is what is new this month...",
    "recipientEmails": ["user@example.com"]
  }'

Node.js (Axios)

const axios = require("axios");

async function sendNewsletter() {
  try {
    const response = await axios.post(
      "https://api.penna.dev/api/v1/external/projects/newsletters/send",
      {
        subject: "Our August Update",
        content: "# Hello!\n\nHere is what is new this month...",
        segmentIds: ["b1e2c3d4-5678-90ab-cdef-1234567890ab"],
      },
      {
        headers: {
          "x-penna-public-key": "penn_your_public_key",
          "x-penna-private-key": "pk_your_private_key",
        },
      }
    );
    console.log(response.data);
  } catch (error) {
    console.error(error);
  }
}

sendNewsletter();

Python (Requests)

import requests

url = "https://api.penna.dev/api/v1/external/projects/newsletters/send"
headers = {
    "Content-Type": "application/json",
    "x-penna-public-key": "penn_your_public_key",
    "x-penna-private-key": "pk_your_private_key",
}
data = {
    "subject": "Our August Update",
    "content": "# Hello!\n\nHere is what is new this month...",
    "recipientEmails": ["user@example.com"],
}

response = requests.post(url, json=data, headers=headers)

if response.status_code == 200:
    print("Newsletter sent successfully!")
else:
    print(f"Failed: {response.text}")

[!NOTE] Markdown-to-HTML conversion is currently basic — it only recognizes #/##/### headings and blank lines as paragraph breaks. Rich formatting (lists, links, bold/italic, images) is not yet converted and will be sent as plain text.