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/jsonx-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
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | The email subject line. |
content | string | Yes | The newsletter body. Supports a small markdown subset (#, ##, ###, blank lines) which is converted to basic HTML before sending. |
recipientEmails | string[] | No* | Email addresses to send to. Must belong to actual subscribers of this project — see Recipient scoping below. Max 5,000 entries per request. |
segmentIds | string[] | 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>" } // 403Code 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.