Skip to main content

Keep your subscriber list in sync

Sync subscribers between your system and LaunchNotes

Written by Chelsea Davis

If your product already manages users — in a CRM, a database, or a homegrown auth system — you shouldn't have to ask those users to subscribe to LaunchNotes manually. This guide shows you how to programmatically push subscribers in from your system and pull the current list back out, so LaunchNotes always reflects your source of truth.

Two operations cover the whole workflow:

  • Import — send a batch of emails (with optional categories and region) and LaunchNotes will create or update the subscriptions

  • Export — query the current subscriber list so you can diff it against your own data

💡 Processing model: Imports are fire-and-forget. The API validates your data immediately and returns a status, then processes the actual subscriptions in the background. If any rows fail during processing, you'll get an email listing the addresses that didn't make it.


Authentication

Every request requires an API token passed as a Bearer token.

Authorization: Bearer YOUR_API_TOKEN

Generate tokens in the LaunchNotes app under Settings → API & RSS.


Use Case 1: Import Subscribers from Your System

"We just launched to a new customer segment. We have their emails in Salesforce — we want them subscribed to our Product Updates category without making them fill out a form."

Use the createBatchEmailSubscriptions mutation. Send an array of subscribers; LaunchNotes validates immediately and queues the rest.

The Mutation

mutation ImportSubscribers(
$projectId: ID!
$subscribers: [BatchSubscriber!]!
$skipOptIn: Boolean
) {
createBatchEmailSubscriptions(
input: {
projectId: $projectId
subscribers: $subscribers
skipOptIn: $skipOptIn
}
) {
status
errors {
path
message
}
}
}

Input

Field

Type

Required

Description

projectId

ID!

Yes

Your LaunchNotes project ID

subscribers

[BatchSubscriber!]!

Yes

Array of subscriber objects (see below)

skipOptIn

Boolean

No

When true, auto-confirms subscribers without sending a confirmation email. Default: true

Each subscriber object:

Field

Type

Required

Description

email

String!

Yes

Email address

categories

[String!]

No*

Category names to subscribe to. Case-insensitive. Comma-separated strings also accepted.

region

String

No

Geographic region label (e.g. "US", "EMEA")

*Required if your project has Require category on subscribe enabled.

Response: What the Status Means

Errors are returned as [UserError] objects.

Status

What happened

What to do

accepted

All rows passed validation and are queued

Nothing — you'll be emailed if background processing fails

partial

Some rows failed validation; valid rows are queued

Check errors[] — each entry names the row, email, and reason

rejected

Every row failed validation; nothing was queued

Check errors[] and fix your data before retrying

A Note on Categories

How categories are validated depends on your project settings:

  • Require category on subscribe = ON — categories are required and must match existing category names exactly (case-insensitive). Invalid names cause a row to fail.

  • Require category on subscribe = OFF — categories are optional. Unrecognized category names are auto-created.

Code Examples:

cURL

curl -X POST https://app.launchnotes.io/graphql \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation ImportSubscribers($projectId: ID!, $subscribers: [BatchSubscriber!]!, $skipOptIn: Boolean) { createBatchEmailSubscriptions(input: { projectId: $projectId, subscribers: $subscribers, skipOptIn: $skipOptIn }) { status errors { path message } } }",
"variables": {
"projectId": "YOUR_PROJECT_ID",
"skipOptIn": true,
"subscribers": [
{ "email": "[email protected]", "categories": ["Product Updates"], "region": "US" },
{ "email": "[email protected]", "categories": ["Engineering"] }
]
}
}'

JavaScript (Node.js)

const LAUNCHNOTES_API = 'https://app.launchnotes.io/graphql'; async function importSubscribers(apiToken, projectId, subscribers) { const query = ` mutation ImportSubscribers($projectId: ID!, $subscribers: [BatchSubscriber!]!, $skipOptIn: Boolean) { createBatchEmailSubscriptions( input: { projectId: $projectId, subscribers: $subscribers, skipOptIn: $skipOptIn } ) { status errors { path message } } } `; const res = await fetch(LAUNCHNOTES_API, { method: 'POST', headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables: { projectId, subscribers, skipOptIn: true } }), }); const { data, errors } = await res.json(); if (errors) throw new Error(errors.map(e => e.message).join(', ')); const result = data.createBatchEmailSubscriptions; if (result.status === 'rejected') { throw new Error(`Import rejected: ${result.errors.map(e => e.message).join('; ')}`); } if (result.status === 'partial') { console.warn('Some rows skipped:', result.errors.map(e => e.message)); } return result; } // Usage await importSubscribers('YOUR_API_TOKEN', 'YOUR_PROJECT_ID', [ { email: '[email protected]', categories: ['Product Updates'], region: 'US' }, { email: '[email protected]', categories: ['Engineering'] }, ]);

Python

import requests

LAUNCHNOTES_API = 'https://app.launchnotes.io/graphql'

def import_subscribers(api_token, project_id, subscribers, skip_opt_in=True):
query = """
mutation ImportSubscribers($projectId: ID!, $subscribers: [BatchSubscriber!]!, $skipOptIn: Boolean) {
createBatchEmailSubscriptions(
input: { projectId: $projectId, subscribers: $subscribers, skipOptIn: $skipOptIn }
) {
status
errors { path message }
}
}
"""
resp = requests.post(
LAUNCHNOTES_API,
headers={
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json',
},
json={
'query': query,
'variables': {
'projectId': project_id,
'subscribers': subscribers,
'skipOptIn': skip_opt_in,
},
},
)
resp.raise_for_status()
payload = resp.json()

if 'errors' in payload:
raise RuntimeError(payload['errors'])

result = payload['data']['createBatchEmailSubscriptions']

if result['status'] == 'rejected':
raise RuntimeError(f"Import rejected: {result['errors']}")
if result['status'] == 'partial':
print(f"Some rows skipped: {[e['message'] for e in result['errors']]}")

return result

# Usage
import_subscribers(
api_token='YOUR_API_TOKEN',
project_id='YOUR_PROJECT_ID',
subscribers=[
{'email': '[email protected]', 'categories': ['Product Updates'], 'region': 'US'},
{'email': '[email protected]', 'categories': ['Engineering']},
],
)

Ruby

require 'net/http'
require 'json'
require 'uri'

LAUNCHNOTES_API = 'https://app.launchnotes.io/graphql'

def import_subscribers(api_token, project_id, subscribers, skip_opt_in: true)
query = <<~GQL
mutation ImportSubscribers($projectId: ID!, $subscribers: [BatchSubscriber!]!, $skipOptIn: Boolean) {
createBatchEmailSubscriptions(
input: { projectId: $projectId, subscribers: $subscribers, skipOptIn: $skipOptIn }
) {
status
errors { path message }
}
}
GQL

uri = URI(LAUNCHNOTES_API)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{api_token}"
req['Content-Type'] = 'application/json'
req.body = JSON.generate({
query: query,
variables: { projectId: project_id, subscribers: subscribers, skipOptIn: skip_opt_in }
})

payload = JSON.parse(http.request(req).body)
raise payload['errors'].to_s if payload['errors']

result = payload.dig('data', 'createBatchEmailSubscriptions')
raise "Import rejected: #{result['errors']}" if result['status'] == 'rejected'
warn "Some rows skipped: #{result['errors']}" if result['status'] == 'partial'
result
end

# Usage
import_subscribers(
'YOUR_API_TOKEN',
'YOUR_PROJECT_ID',
[
{ email: '[email protected]', categories: ['Product Updates'], region: 'US' },
{ email: '[email protected]', categories: ['Engineering'] }
]
)


Use Case 2: Export Your Current Subscriber List

"Before we run our quarterly CRM sync, we want to pull down who's already in LaunchNotes so we don't re-import people who've unsubscribed."

Use the exportSubscribers query. It returns subscribers with their current status, categories, and confirmation state — everything you need to diff against your own list.

The Query

query ExportSubscribers(
$projectId: ID!
$status: [SubscriptionStatus!]
$categoryIds: [ID!]
$first: Int
) {
project(id: $projectId) {
exportSubscribers(
status: $status
categoryIds: $categoryIds
first: $first
) {
edges {
status
hasMailTransport
hasSlackTransport
categories {
id
name
}
node {
id
email
region
createdAt
confirmedAt
}
}
}
}
}

Filters

Argument

Type

Description

status

Filter by status: confirmed, unconfirmed, unsubscribed, blocked

categoryIds

[ID!]

Return only subscribers in all of the given category IDs (AND logic)

first

Int

Limit the number of results returned

What Each Record Contains

Field

Description

node.email

Email address

node.region

Region label, if set

node.confirmedAt

Timestamp of confirmation; null means unconfirmed

node.createdAt

When this subscription was created

status

categories

Categories this subscriber is enrolled in

hasMailTransport

Whether email notifications are active

hasSlackTransport

Whether Slack notifications are active

Code Examples:

cURL

curl -X POST https://app.launchnotes.io/graphql \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "query ExportSubscribers($projectId: ID!, $status: [SubscriptionStatus!], $categoryIds: [ID!], $first: Int) { project(id: $projectId) { exportSubscribers(status: $status, categoryIds: $categoryIds, first: $first) { edges { status hasMailTransport hasSlackTransport categories { id name } node { id email region createdAt confirmedAt } } } } }",
"variables": {
"projectId": "YOUR_PROJECT_ID",
"status": ["confirmed"]
}
}'

JavaScript (Node.js)

async function exportSubscribers(apiToken, projectId, { status, categoryIds, first } = {}) {
const query = `
query ExportSubscribers($projectId: ID!, $status: [SubscriptionStatus!], $categoryIds: [ID!], $first: Int) {
project(id: $projectId) {
exportSubscribers(status: $status, categoryIds: $categoryIds, first: $first) {
edges {
status
hasMailTransport
hasSlackTransport
categories { id name }
node { id email region createdAt confirmedAt }
}
}
}
}
`;

const res = await fetch('https://app.launchnotes.io/graphql', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { projectId, status, categoryIds, first } }),
});

const { data, errors } = await res.json();
if (errors) throw new Error(errors.map(e => e.message).join(', '));
return data.project.exportSubscribers.edges;
}

// Export only confirmed subscribers
const subscribers = await exportSubscribers('YOUR_API_TOKEN', 'YOUR_PROJECT_ID', {
status: ['confirmed'],
});

Python

import requests

LAUNCHNOTES_API = 'https://app.launchnotes.io/graphql'

def export_subscribers(api_token, project_id, status=None, category_ids=None, first=None):
query = """
query ExportSubscribers($projectId: ID!, $status: [SubscriptionStatus!], $categoryIds: [ID!], $first: Int) {
project(id: $projectId) {
exportSubscribers(status: $status, categoryIds: $categoryIds, first: $first) {
edges {
status
hasMailTransport
hasSlackTransport
categories { id name }
node { id email region createdAt confirmedAt }
}
}
}
}
"""
resp = requests.post(
LAUNCHNOTES_API,
headers={
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json',
},
json={
'query': query,
'variables': {
'projectId': project_id,
'status': status,
'categoryIds': category_ids,
'first': first,
},
},
)
resp.raise_for_status()
payload = resp.json()

if 'errors' in payload:
raise RuntimeError(payload['errors'])

return payload['data']['project']['exportSubscribers']['edges']

# Export only confirmed subscribers
subscribers = export_subscribers(
api_token='YOUR_API_TOKEN',
project_id='YOUR_PROJECT_ID',
status=['confirmed'],
)

Ruby

require 'net/http'
require 'json'
require 'uri'

LAUNCHNOTES_API = 'https://app.launchnotes.io/graphql'

def export_subscribers(api_token, project_id, status: nil, category_ids: nil, first: nil)
query = <<~GQL
query ExportSubscribers($projectId: ID!, $status: [SubscriptionStatus!], $categoryIds: [ID!], $first: Int) {
project(id: $projectId) {
exportSubscribers(status: $status, categoryIds: $categoryIds, first: $first) {
edges {
status
hasMailTransport
hasSlackTransport
categories { id name }
node { id email region createdAt confirmedAt }
}
}
}
}
GQL

uri = URI(LAUNCHNOTES_API)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{api_token}"
req['Content-Type'] = 'application/json'
req.body = JSON.generate({
query: query,
variables: { projectId: project_id, status: status, categoryIds: category_ids, first: first }
})

payload = JSON.parse(http.request(req).body)
raise payload['errors'].to_s if payload['errors']

payload.dig('data', 'project', 'exportSubscribers', 'edges')
end

# Export only confirmed subscribers
subscribers = export_subscribers(
'YOUR_API_TOKEN',
'YOUR_PROJECT_ID',
status: ['confirmed']
)


Use Case 3: Full Sync (CRM → LaunchNotes)

"We run a nightly job. We want LaunchNotes to always match our CRM — add new subscribers, and skip anyone who's already there or has previously unsubscribed."

Combine export and import: pull the current state, diff against your source of truth, then import only the delta.

Code Examples:

cURL

The full sync pattern requires two requests (export then import). Run the export first, diff locally, then call the import — see the import and export cURL examples above and chain them in your shell script.

# Step 1: export confirmed + unsubscribed
EXISTING=$(curl -s -X POST https://app.launchnotes.io/graphql \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"query { project(id: \"YOUR_PROJECT_ID\") { exportSubscribers(status: [confirmed, unsubscribed]) { edges { status node { email } } } } }"}')

# Step 2: diff and build your import payload, then call the import mutation
# (see import cURL example above)


Javascript (node.js)

async function syncSubscribers(apiToken, projectId, crmSubscribers) {
// 1. Fetch who's already in LaunchNotes (confirmed only)
const existing = await exportSubscribers(apiToken, projectId, { status: ['confirmed'] });
const existingEmails = new Set(existing.map(e => e.node.email.toLowerCase()));

// 2. Also fetch unsubscribed so we don't re-add people who opted out
const unsubscribed = await exportSubscribers(apiToken, projectId, { status: ['unsubscribed'] });
const unsubscribedEmails = new Set(unsubscribed.map(e => e.node.email.toLowerCase()));

// 3. Import only net-new subscribers who haven't previously opted out
const toAdd = crmSubscribers.filter(s => {
const email = s.email.toLowerCase();
return !existingEmails.has(email) && !unsubscribedEmails.has(email);
});

if (toAdd.length === 0) {
console.log('Nothing to sync — LaunchNotes is up to date.');
return;
}

console.log(`Importing ${toAdd.length} new subscribers...`);
const result = await importSubscribers(apiToken, projectId, toAdd);
console.log(`Done. Status: ${result.status}`);
}

// Example CRM data shape
const crmSubscribers = [
{ email: '[email protected]', categories: ['Product Updates'], region: 'US' },
{ email: '[email protected]', categories: ['Engineering'] },
];

await syncSubscribers('YOUR_API_TOKEN', 'YOUR_PROJECT_ID', crmSubscribers);

Python

def sync_subscribers(api_token, project_id, crm_subscribers):
# 1. Fetch confirmed subscribers
existing = export_subscribers(api_token, project_id, status=['confirmed'])
existing_emails = {e['node']['email'].lower() for e in existing}

# 2. Fetch unsubscribed so we don't re-add people who opted out
unsubscribed = export_subscribers(api_token, project_id, status=['unsubscribed'])
unsubscribed_emails = {e['node']['email'].lower() for e in unsubscribed}

# 3. Import only net-new subscribers who haven't opted out
to_add = [
s for s in crm_subscribers
if s['email'].lower() not in existing_emails
and s['email'].lower() not in unsubscribed_emails
]

if not to_add:
print('Nothing to sync — LaunchNotes is up to date.')
return

print(f'Importing {len(to_add)} new subscribers...')
result = import_subscribers(api_token, project_id, to_add)
print(f"Done. Status: {result['status']}")

# Example CRM data shape
crm_subscribers = [
{'email': '[email protected]', 'categories': ['Product Updates'], 'region': 'US'},
{'email': '[email protected]', 'categories': ['Engineering']},
]

sync_subscribers('YOUR_API_TOKEN', 'YOUR_PROJECT_ID', crm_subscribers)

Ruby

def sync_subscribers(api_token, project_id, crm_subscribers)
# 1. Fetch confirmed subscribers
existing = export_subscribers(api_token, project_id, status: ['confirmed'])
existing_emails = existing.map { |e| e.dig('node', 'email').downcase }.to_set

# 2. Fetch unsubscribed so we don't re-add people who opted out
unsubscribed = export_subscribers(api_token, project_id, status: ['unsubscribed'])
unsubscribed_emails = unsubscribed.map { |e| e.dig('node', 'email').downcase }.to_set

# 3. Import only net-new subscribers who haven't opted out
to_add = crm_subscribers.reject do |s|
email = s[:email].downcase
existing_emails.include?(email) || unsubscribed_emails.include?(email)
end

if to_add.empty?
puts 'Nothing to sync — LaunchNotes is up to date.'
return
end

puts "Importing #{to_add.size} new subscribers..."
result = import_subscribers(api_token, project_id, to_add)
puts "Done. Status: #{result['status']}"
end

# Example CRM data shape
crm_subscribers = [
{ email: '[email protected]', categories: ['Product Updates'], region: 'US' },
{ email: '[email protected]', categories: ['Engineering'] },
]

sync_subscribers('YOUR_API_TOKEN', 'YOUR_PROJECT_ID', crm_subscribers)

⚠️ Respect unsubscribes. Always export unsubscribed subscribers before importing and exclude them. Re-importing someone who opted out will not override their unsubscribed status — the API will silently skip them — but it's cleaner to filter them on your side.


Limitations & Edge Cases

  • Trial organizations — batch import is not available during trial

  • No de-duplication needed — if a subscriber already exists, the API updates their subscription rather than creating a duplicate

  • Processing time — large batches are chunked into groups of 100 and processed in parallel; allow a few minutes for very large imports

  • Failure notification — if background processing fails for any addresses, you'll receive an email listing them; the API response only reflects validation, not background success

Did this answer your question?