← Blog

Blog

Automate Newsletters from JavaScript with a Broadcast API

Sahand Seifi10 min read
  • Engineering
  • Tutorial
  • Email
  • JavaScript
  • Broadcasts

Send product updates and newsletters from Node.js: sync users, personalize templates with properties, then create, schedule, and send email broadcasts through the Pingram API.

YouTube Walkthrough

Transactional email is one message to one user. A newsletter or product update is one campaign to many people: an audience, a draft you can review, a send or schedule step, and metrics afterward.

Pingram Broadcasts is that workflow as an API. This tutorial walks through the JavaScript / Node.js path end to end: a raw email list first, then syncing users so you can personalize templates with properties, create a draft, and send or schedule it.

The same calls exist in Python, Java, C#, Ruby, PHP, Go, the CLI, and MCP. Here we stay on Node.

Three ways to send a broadcast

Approach Example Go to
Dashboard Write one campaign by hand in the Broadcast tab Dashboard
MCP Ask Claude, ChatGPT, or Cursor to draft a product update MCP setup
API A weekly changelog job or GitHub Action assembles HTML and sends it This tutorial

If you only needed a one-off announcement, the dashboard is enough. Use the API when the send should happen from your server every week.

Setup

  1. Create a free Pingram account. Copy an API key (pingram_sk_...) from Settings → API Keys.
  2. Verify your sending domain under Settings → Domain Verification. Broadcasts from unverified domains, including the shared test sender, are heavily throttled. See Domain Verification.
  3. Install the SDK:
Terminal window
npm install pingram

Store the API key in a secure place, not inside your code.

import { Pingram } from 'pingram';
const pingram = new Pingram({ apiKey: process.env.PINGRAM_API_KEY });

Optional: npx skills add pingram-io/skills so a coding agent in the same repo knows the Broadcasts API. You do not need skills to run the samples below.

Send to a raw email list

pingram.broadcasts.create saves a draft. Nothing is sent until you call send or schedule.

The fastest path is a one-off list of at most 10,000 addresses. Each address creates a user if one does not exist.

const broadcast = await pingram.broadcasts.create({
name: 'One-off announcement',
type: 'important_announcement',
channel: 'email',
audience: {
emails: ['dana@acme.io', 'evan@acme.io']
},
fromName: 'Pingram',
fromAddress: 'updates@pingram.io',
subject: 'A quick note',
html: `<p>Hello from Pingram.</p>
<p><a href="https://www.pingram.io/blog">Read the blog</a></p>
<p><a href="{{ pingram.unsubscribe }}">Unsubscribe</a></p>`
});
console.log(broadcast.broadcastId);
  • name is internal. Recipients never see it.
  • type is the notification type key (product_updates, newsletter, …). Recipients unsubscribe from it independently of other types. Pingram creates the type if it does not exist yet.
  • fromAddress must be your verified domain (or the shared test sender, which is throttled and capped).

You can open that draft in the Broadcast tab, inspect HTML and the audience, then still send from code.

A raw emails audience only guarantees user.id (the lowercased email) and user.email. Property mergetags stay blank until you identify those people.

Create always starts a new draft. If you fix HTML and run create again, you get a second broadcast. Update the existing draft with pingram.broadcasts.update, or send the latest broadcastId.

Identify users (so filters and mergetags work)

A raw email list is fine for a test send. Production newsletters usually need who someone is: plan, first name, signup date. That data only exists if you identify the user first.

Call identify on signup and whenever those fields change. Later calls shallow-merge properties. Omitted keys stay as they are.

await pingram.user.identify('user-123', {
email: 'dana@acme.io',
timezone: 'America/New_York',
properties: {
firstname: 'Dana',
plan: 'pro',
signed_up: '2026-01-12'
}
});
await pingram.user.identify('user-456', {
email: 'evan@acme.io',
properties: {
firstname: 'Evan',
plan: 'free'
}
});

properties is a flat map of strings, numbers, and booleans (no nested objects). Limits: 25 keys, key length ≤ 64, string values ≤ 256 characters, whole map under 1 KB.

Those keys are what you filter on (properties.plan) and what templates read (user.properties.firstname).

Personalize templates with properties

Subject and HTML are LiquidJS templates, rendered once per recipient. Custom fields stay under user.properties. {{ user.firstname }} is empty; use {{ user.properties.firstname }}.

<p>Hi {{ user.properties.firstname | default: "there" }},</p>
{% if user.properties.plan == "pro" %}
<p>Thanks for being on Pro. Here is what we shipped this week.</p>
{% else %}
<p>Here is what we shipped this week. Pro unlocks the rest.</p>
{% endif %}
<p><a href="https://example.com/changelog">Read the changelog</a></p>
<p><a href="{{ pingram.unsubscribe }}">Unsubscribe</a></p>
Tag Source
{{ user.id }} Your user id. For a raw audience.emails list, this is the lowercased email.
{{ user.email }} Email address
{{ user.properties.<key> }} Custom properties from identify
{{ pingram.unsubscribe }} Per-recipient unsubscribe URL for this broadcast’s type

Missing properties render as empty; they do not fail the send. Use | default: when you need a fallback. Always include {{ pingram.unsubscribe }}. Recipients who click it are skipped on later broadcasts of the same type.

If you personalize a list you never synced, firstname comes out blank.

Include at least one real link besides unsubscribe (changelog, blog post, feature page). Opens are noisy; clicks are the engagement signal worth watching.

Target a filtered audience

Best for segments and large lists. The filter is a Mongo-style query over identified users. Allowed fields: email and properties.<key>. Allowed operators: $and, $or, $exists, $eq, $ne, $gt, $gte, $lt, $lte, $in. Nesting depth at most 3, at most 20 clauses.

const broadcast = await pingram.broadcasts.create({
name: 'August product update',
type: 'product_updates',
channel: 'email',
audience: {
filter: {
$and: [
{ email: { $exists: true } },
{ 'properties.plan': { $eq: 'pro' } }
]
}
},
fromName: 'Acme',
fromAddress: 'updates@acme.com',
replyToAddress: 'hello@acme.com',
subject: 'What we shipped, {{ user.properties.firstname }}',
html: `<p>Hi {{ user.properties.firstname | default: "there" }},</p>
<p>Here is what we shipped this week.</p>
<p><a href="https://example.com/changelog">Read the changelog</a></p>
<p><a href="{{ pingram.unsubscribe }}">Unsubscribe</a></p>`
});

For lists larger than 10,000, identify users and use filter instead of audience.emails.

Send or schedule

await pingram.broadcasts.send(broadcast.broadcastId);
await pingram.broadcasts.schedule(broadcast.broadcastId, {
sendAt: '2026-10-01T15:00:00.000Z'
});

Send requires a non-empty subject and a type. Cancel a scheduled broadcast to return it to draft:

await pingram.broadcasts.cancel(broadcast.broadcastId);

A typical weekend job: build HTML from this week’s changelog or sale items, create the draft, then send (or schedule for Tuesday 15:00 UTC). No dashboard click in the loop.

Metrics (trust clicks, not opens)

const metrics = await pingram.broadcasts.getMetrics(broadcast.broadcastId);
// status, total, sent, delivered, opened, clicked,
// bounced, complained, unsubscribed, skipped, failed

Status moves draft → sending → sent (or scheduled, paused, canceled). If a send pauses itself, pausedReason explains why.

Opened counts include privacy scanners and antivirus that fetch the tracking pixel. Treat clicked as the real engagement metric. Put a changelog or feature link in the body so there is something to click besides unsubscribe.

The dashboard lists per-recipient delivery (delivered, bounced, complained). Broadcasts scale to hundreds of thousands of recipients; start from a verified domain so delivery is not throttled.

Full reference: Broadcasts.

Recap

  1. Dashboard for a manual newsletter, Pingram MCP for sending broadcasts from agents, API and code for full automation.
  2. You can send to raw email lists, or
  3. Identify users with custom properties to target your audience with custom filters.
  4. APIs can also send, schedule, and measure broadcasts.

Pingram is the email API for product teams that want newsletters in the same stack as the rest of the product: dashboard when you write one by hand, MCP when an agent drafts copy, and this Node API when the send is just another job in your codebase.

Sec. 02FAQ

Common questions

01What is a newsletter API?

A newsletter API (or email broadcast API) lets you create a campaign, choose an audience, and send or schedule it from code instead of clicking around a dashboard. Pingram Broadcasts is that API: create a draft with pingram.broadcasts.create, then send or schedule it. Recipients can be a raw email list or a filter over users you already synced.

02How do I send a newsletter from JavaScript or Node.js?

Install the pingram package, create a draft with pingram.broadcasts.create (raw emails or a filtered audience), then pingram.broadcasts.send or schedule. Create only saves a draft; nothing goes out until send or schedule. To personalize, identify users with custom properties first.

03Can I personalize bulk email with user properties?

Yes. Subject and HTML are rendered once per recipient. Custom fields live under user.properties, for example {{ user.properties.firstname }}, and you can use filters like default. Always include {{ pingram.unsubscribe }} so each person can opt out of that notification type.

04Raw emails vs a filtered audience?

audience.emails is a one-off list (max 10,000 addresses). For anything larger, or for segments like “Pro users with an email”, sync users with pingram.user.identify and target them with audience.filter. Filters only see properties you have identified.

Ready when you are

Launch today.

Free tier with 3,000 emails, plus 100 SMS and calls per month. No credit card required. Paid plans start at $20/month.