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/newsletters/send
Headers:
Content-Type: application/jsonx-penna-public-key: <YOUR_PUBLIC_KEY>x-penna-private-key: <YOUR_PRIVATE_KEY>
Important: 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 newsletter — 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, merged, and deduplicated with recipientEmails. See Segmentations & External API for details. |
* 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 & Per-Request Limit
recipientEmails is not an open list of arbitrary addresses — every address is checked against this newsletter's active subscribers (added via POST /subscriber/new or the dashboard). Any address that is not an active subscriber is silently skipped rather than failing the whole request. The response field data.skippedNonSubscribers reports how many were dropped.
5,000 Recipient Per-Request Cap (MAX_RECIPIENTS_PER_SEND)
A single API call to POST /send can target a maximum of 5,000 unique resolved recipients (MAX_RECIPIENTS_PER_SEND = 5000):
- Combined Resolution: This cap applies to the total count of valid unique recipient emails after merging
recipientEmailsand all subscribers fromsegmentIds. - Exceeding the Limit: If the total resolved recipient count exceeds 5,000, the API returns a
400 Bad Requeststatus ("This request resolved to X recipients, which exceeds the 5000 limit per request...") and no emails are sent. - Handling Large Sends: To send to lists larger than 5,000, split your target audience across multiple API requests (e.g. by using smaller segments or batching email arrays of up to 5,000 recipients each).
For an in-depth breakdown of segment targeting, deduplication, and resolution, see Segmentations & External API.
Rate Limits
Each newsletter 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/newsletters 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
// Newsletter is over its plan's daily newsletter-send cap
{ "success": false, "message": "<newsletter> 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/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/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/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.