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 |
|
| Yes | Your LaunchNotes project ID |
|
| Yes | Array of subscriber objects (see below) |
|
| No | When |
Each subscriber object:
Field | Type | Required | Description |
|
| Yes | Email address |
|
| No* | Category names to subscribe to. Case-insensitive. Comma-separated strings also accepted. |
|
| No | Geographic region label (e.g. |
*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 |
| All rows passed validation and are queued | Nothing — you'll be emailed if background processing fails |
| Some rows failed validation; valid rows are queued | Check |
| Every row failed validation; nothing was queued | Check |
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
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)
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
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
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
region
createdAt
confirmedAt
}
}
}
}
}
Filters
Argument | Type | Description |
| Filter by status: | |
|
| Return only subscribers in all of the given category IDs (AND logic) |
|
| Limit the number of results returned |
What Each Record Contains
Field | Description |
| Email address |
| Region label, if set |
| Timestamp of confirmation; |
| When this subscription was created |
| Current SubscriptionStatus |
| Categories this subscriber is enrolled in |
| Whether email notifications are active |
| Whether Slack notifications are active |
Code Examples:
cURL
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)
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
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
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
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)
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
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
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
