Sendar API reference
Send and manage application email with predictable HTTP requests. Explore endpoints, choose your language and copy an example.
Start with the setup guide to verify your domain and create an API key. These examples use your private server-side key.
Authentication
Authenticate your API requests by providing your API key in the Authorization header as a Bearer token. You can manage your API keys in the dashboard — sign up free to create one.
All endpoints below accept API-key authentication. The base URL for every request is https://sendar.app/api.
Sending Email API
Everything you need to send and manage emails programmatically. Pick your language — every example updates to match.
Send Email
Send a single email. The domain in the from address must be added and verified in your account first.
- •Provide html, text, or both — at least one is required (or pass templateId instead, see Send with a Template).
- •Add scheduledAt (ISO 8601, up to 30 days ahead) to schedule the send instead of delivering immediately. Scheduled sending is available on the Growth plan and above.
- •Attach files with attachments: up to 5 files, 1.3MB total, content base64-encoded.
- •Set trackOpens / trackClicks (booleans) to record opens and link clicks for this email — omit them to use your account defaults from Settings. Tracking only works on HTML bodies: opens use an invisible pixel, clicks rewrite links through a redirect.
- •Every plan includes a monthly sending quota (3,000 emails per month on Free — see the Pricing page); requests over the limit return a 429 error. Canceled emails don't count toward the quota.
const response = await fetch("https://sendar.app/api/emails", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"from": "Acme <hello@acme.com>",
"to": [
"delivered@example.com"
],
"subject": "Hello World",
"html": "<p>It works!</p>"
}),
});
const data = await response.json();
console.log(data);{
"id": 42,
"providerEmailId": "0100019a7f3d2c8e-…",
"fromAddress": "Acme <hello@acme.com>",
"toAddresses": [
"delivered@example.com"
],
"subject": "Hello World",
"status": "sent",
"errorMessage": null,
"scheduledAt": null,
"trackOpens": false,
"trackClicks": false,
"openedAt": null,
"clickedAt": null,
"openCount": 0,
"clickCount": 0,
"createdAt": "2026-07-15T16:00:00.000Z",
"updatedAt": "2026-07-15T16:00:01.000Z"
}Send Batch Emails
Send up to 100 emails in one request. The batch is validated as a whole — if any item is invalid, nothing is sent. Accepted emails are queued and delivered asynchronously within seconds.
- •Batch items don't support attachments or scheduledAt — use the single send endpoint for those.
- •The response returns each email with status "queued"; poll Retrieve Email to see the final status.
- •Each batch item counts toward your plan's monthly quota; a batch that would exceed it is rejected with a 429 error.
const response = await fetch("https://sendar.app/api/emails/batch", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify([
{
"from": "Acme <hello@acme.com>",
"to": [
"customer-one@example.com"
],
"subject": "Your receipt",
"html": "<p>Thanks for your order!</p>"
},
{
"from": "Acme <hello@acme.com>",
"to": [
"customer-two@example.com"
],
"subject": "Your receipt",
"text": "Thanks for your order!"
}
]),
});
const data = await response.json();
console.log(data);{
"data": [
{
"id": 43,
"status": "queued",
"subject": "Your receipt"
},
{
"id": 44,
"status": "queued",
"subject": "Your receipt"
}
]
}Retrieve Email
Fetch a single email by id, including its current delivery status.
const response = await fetch("https://sendar.app/api/emails/42", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);{
"id": 42,
"providerEmailId": "0100019a7f3d2c8e-…",
"fromAddress": "Acme <hello@acme.com>",
"toAddresses": [
"delivered@example.com"
],
"subject": "Hello World",
"status": "sent",
"errorMessage": null,
"scheduledAt": null,
"trackOpens": true,
"trackClicks": true,
"openedAt": "2026-07-15T16:03:40.000Z",
"clickedAt": "2026-07-15T16:04:12.000Z",
"openCount": 2,
"clickCount": 1,
"createdAt": "2026-07-15T16:00:00.000Z",
"updatedAt": "2026-07-15T16:00:01.000Z"
}List Email Events
List the open and click events recorded for an email, newest first. Events only exist for emails sent with trackOpens or trackClicks enabled.
- •Enable tracking per send with the trackOpens / trackClicks flags, or account-wide in Settings. Tracking only works on HTML emails.
- •Some inboxes preload images and scan links automatically, so counts can include automated opens and clicks — treat them as engagement signals, not exact reads.
- •The email row itself carries the rollup: openCount, clickCount, and the first openedAt / clickedAt timestamps.
const response = await fetch("https://sendar.app/api/emails/42/events", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);[
{
"id": 2,
"type": "click",
"url": "https://acme.com/pricing",
"occurredAt": "2026-07-15T16:04:12.000Z"
},
{
"id": 1,
"type": "open",
"url": null,
"occurredAt": "2026-07-15T16:03:40.000Z"
}
]Update Email
Reschedule a scheduled email. Only emails created with scheduledAt that haven't been sent or canceled yet can be updated.
- •The new scheduledAt must be in the future and at most 30 days ahead. Requires a plan with scheduled sending (Growth and above).
- •Returns the updated email object.
const response = await fetch("https://sendar.app/api/emails/42", {
method: "PATCH",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"scheduledAt": "2026-08-01T09:00:00Z"
}),
});
const data = await response.json();
console.log(data);Cancel Email
Cancel a scheduled email before it is sent. Only scheduled emails that are still queued can be canceled.
- •Returns the email object with status "canceled".
const response = await fetch("https://sendar.app/api/emails/42/cancel", {
method: "POST",
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);List Emails
List your most recent emails, newest first. Use the limit query parameter (1–100, default 50).
- •Returns an array of email objects.
const response = await fetch("https://sendar.app/api/emails?limit=50", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);List Attachments
List the attachments of an email. Returns metadata only — use Retrieve Attachment to get the file content.
const response = await fetch("https://sendar.app/api/emails/42/attachments", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);[
{
"id": 7,
"emailId": 42,
"filename": "receipt.pdf",
"contentType": "application/pdf",
"sizeBytes": 24576,
"createdAt": "2026-07-15T16:00:00.000Z"
}
]Retrieve Attachment
Fetch a single attachment including its content. The content field is base64-encoded — decode it to get the original file bytes.
const response = await fetch("https://sendar.app/api/emails/42/attachments/7", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);{
"id": 7,
"emailId": 42,
"filename": "receipt.pdf",
"contentType": "application/pdf",
"sizeBytes": 24576,
"content": "JVBERi0xLjQKJcTl…",
"createdAt": "2026-07-15T16:00:00.000Z"
}List Suppressions
List the addresses on your suppression list — recipients that hard-bounced or reported an email as spam. Sends to these addresses are rejected with a 400 error so they never count against your quota or reputation.
- •reason is one of: bounce (delivery hard-bounced), complaint (recipient reported spam), other (blocked at the delivery provider for another reason).
- •Suppressions are detected automatically within a few minutes of the bounce or complaint. The triggering email's status changes to bounced or complained, and email.bounced / email.complained webhook events fire.
const response = await fetch("https://sendar.app/api/suppressions", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);[
{
"id": 3,
"email": "bounced@example.com",
"reason": "bounce",
"suppressedAt": "2026-07-20T10:12:00.000Z",
"createdAt": "2026-07-20T10:14:02.000Z"
}
]Remove Suppression
Remove an address from your suppression list so future sends can reach it again.
- •Only remove an address when you're confident the mailbox exists and wants your mail — repeated bounces and complaints damage your sender reputation.
- •Returns 204 No Content on success.
const response = await fetch("https://sendar.app/api/suppressions/3", {
method: "DELETE",
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);Send with a Template
Send a saved template by id instead of passing inline content. The template's subject and HTML are resolved with your variables at send time, and the sent email stores the resolved result — editing or deleting the template later never changes what was already sent.
- •When templateId is set, subject, html, and text must be omitted — the template supplies them.
- •variables is checked strictly: every {{placeholder}} in the template needs a value, and unknown keys are rejected. Values are HTML-escaped automatically.
- •Batch sends support templateId and variables per item, so one request can send the same template to many recipients with different values.
- •Works with scheduledAt and attachments exactly like a normal send.
const response = await fetch("https://sendar.app/api/emails", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"from": "Acme <hello@acme.com>",
"to": [
"delivered@example.com"
],
"templateId": 5,
"variables": {
"first_name": "Ada",
"workspace": "acme-inc"
}
}),
});
const data = await response.json();
console.log(data);{
"id": 43,
"from": "Acme <hello@acme.com>",
"to": [
"delivered@example.com"
],
"subject": "Welcome to Acme, Ada!",
"status": "sent",
"createdAt": "2026-07-15T12:01:30.000Z"
}Create Template
Save a reusable email template. Use {{variable}} placeholders anywhere in the subject or HTML and supply per-recipient values when sending.
- •Placeholder names use letters, digits, and underscores, and must not start with a digit — {{first_name}}, {{code2}}.
- •The response includes variables: every placeholder detected in the subject and body.
- •Each plan includes a number of saved templates (3 on Free — see the Pricing page).
const response = await fetch("https://sendar.app/api/templates", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "Welcome",
"subject": "Welcome to Acme, {{first_name}}!",
"html": "<h1>Hi {{first_name}}</h1><p>Your workspace {{workspace}} is ready.</p>"
}),
});
const data = await response.json();
console.log(data);{
"id": 5,
"name": "Welcome",
"subject": "Welcome to Acme, {{first_name}}!",
"html": "<h1>Hi {{first_name}}</h1><p>Your workspace {{workspace}} is ready.</p>",
"variables": [
"first_name",
"workspace"
],
"createdAt": "2026-07-15T12:00:00.000Z",
"updatedAt": "2026-07-15T12:00:00.000Z"
}List Templates
List your saved templates, most recently updated first.
const response = await fetch("https://sendar.app/api/templates", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);[
{
"id": 5,
"name": "Welcome",
"subject": "Welcome to Acme, {{first_name}}!",
"html": "<h1>Hi {{first_name}}</h1><p>Your workspace {{workspace}} is ready.</p>",
"variables": [
"first_name",
"workspace"
],
"createdAt": "2026-07-15T12:00:00.000Z",
"updatedAt": "2026-07-15T12:00:00.000Z"
}
]Retrieve Template
Fetch a single template, including the variables it uses.
const response = await fetch("https://sendar.app/api/templates/5", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);{
"id": 5,
"name": "Welcome",
"subject": "Welcome to Acme, {{first_name}}!",
"html": "<h1>Hi {{first_name}}</h1><p>Your workspace {{workspace}} is ready.</p>",
"variables": [
"first_name",
"workspace"
],
"createdAt": "2026-07-15T12:00:00.000Z",
"updatedAt": "2026-07-15T12:00:00.000Z"
}Update Template
Update a template's name, subject, or HTML. Only the fields you pass are changed.
- •Future sends use the new content; emails already sent keep the content they were sent with.
const response = await fetch("https://sendar.app/api/templates/5", {
method: "PATCH",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"subject": "You're in, {{first_name}} 🎉"
}),
});
const data = await response.json();
console.log(data);{
"id": 5,
"name": "Welcome",
"subject": "You're in, {{first_name}} 🎉",
"html": "<h1>Hi {{first_name}}</h1><p>Your workspace {{workspace}} is ready.</p>",
"variables": [
"first_name",
"workspace"
],
"createdAt": "2026-07-15T12:00:00.000Z",
"updatedAt": "2026-07-16T09:30:00.000Z"
}Delete Template
Delete a template. Sends that reference the deleted id will fail with a clear error; already-sent emails are unaffected.
- •Returns 204 No Content on success.
const response = await fetch("https://sendar.app/api/templates/5", {
method: "DELETE",
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);Create Audience
Create a contact list for newsletters. Contacts count toward your plan's total across all audiences (250 on Free — see the Pricing page).
const response = await fetch("https://sendar.app/api/audiences", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"name": "Product updates"
}),
});
const data = await response.json();
console.log(data);{
"id": 3,
"name": "Product updates",
"contactCount": 0,
"unsubscribedCount": 0,
"createdAt": "2026-07-20T10:00:00.000Z",
"updatedAt": "2026-07-20T10:00:00.000Z"
}Add Contact
Add one contact to an audience. Addresses are stored lowercase; adding the same address to the same audience twice returns a 409 error.
- •name is optional and fills the {{name}} personalization in broadcasts (falls back to the part before the @).
- •If the address previously unsubscribed from your emails, it is added with unsubscribed: true and broadcasts will skip it.
- •Returns 403 when your plan's total contact limit is reached.
const response = await fetch("https://sendar.app/api/audiences/3/contacts", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": "ada@example.com",
"name": "Ada Lovelace"
}),
});
const data = await response.json();
console.log(data);{
"id": 71,
"email": "ada@example.com",
"name": "Ada Lovelace",
"unsubscribed": false,
"createdAt": "2026-07-20T10:05:00.000Z"
}Import Contacts (CSV)
Bulk-add contacts from CSV text — either bare email,name lines or any column order with a header row naming an email (and optionally name) column.
- •Up to 10,000 rows per request. Invalid rows and duplicates are skipped and counted, never fatal.
- •If the file holds more contacts than your plan allows, the import fills the remaining space and reports the rest as skippedOverLimit — re-importing the same file after upgrading picks up where it left off.
const response = await fetch("https://sendar.app/api/audiences/3/contacts/import", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"csv": "email,name\nada@example.com,Ada Lovelace\ngrace@example.com,Grace Hopper"
}),
});
const data = await response.json();
console.log(data);{
"added": 2,
"skippedInvalid": 0,
"skippedDuplicates": 0,
"skippedOverLimit": 0,
"totalRows": 2,
"invalidSamples": []
}List Contacts
List an audience's contacts, newest first, with their unsubscribe status.
- •Filter with ?search= (matches email and name). Page with limit (up to 200) and offset.
const response = await fetch("https://sendar.app/api/audiences/3/contacts?limit=50&offset=0", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);{
"data": [
{
"id": 71,
"email": "ada@example.com",
"name": "Ada Lovelace",
"unsubscribed": false,
"createdAt": "2026-07-20T10:05:00.000Z"
}
],
"total": 1
}Send Broadcast
Send a newsletter to every eligible contact in an audience. Unsubscribed and suppressed addresses are skipped automatically, every email gets a personal unsubscribe link, and delivery drains gradually at a provider-safe pace.
- •Use {{name}} and {{email}} in subject or html for per-recipient personalization ({{name}} falls back to the address's local part).
- •Or pass templateId + variables instead of subject/html — name and email are then filled per recipient automatically and must not appear in variables.
- •Every recipient counts toward your sending quota, checked up front — the broadcast is all-or-nothing.
- •Recipients' mail apps also get a one-click unsubscribe button (RFC 8058 headers).
const response = await fetch("https://sendar.app/api/broadcasts", {
method: "POST",
headers: {
Authorization: "Bearer sndr_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
"audienceId": 3,
"from": "Acme <news@acme.com>",
"subject": "Our July update, {{name}}",
"html": "<h1>Hi {{name}},</h1><p>Here's what's new...</p>"
}),
});
const data = await response.json();
console.log(data);{
"id": 9,
"audienceId": 3,
"audienceName": "Product updates",
"fromAddress": "Acme <news@acme.com>",
"subject": "Our July update, {{name}}",
"status": "sending",
"totalRecipients": 184,
"skippedUnsubscribed": 12,
"skippedSuppressed": 4,
"counts": {
"queued": 184,
"sending": 0,
"sent": 0,
"failed": 0,
"bounced": 0,
"complained": 0
},
"createdAt": "2026-07-20T11:00:00.000Z"
}Retrieve Broadcast
Fetch a broadcast with live delivery progress. Status is derived from the per-recipient emails: sending while any are queued, then sent (or failed if nothing was delivered).
const response = await fetch("https://sendar.app/api/broadcasts/9", {
headers: { Authorization: "Bearer sndr_xxxxxxxxxxxx" },
});
const data = await response.json();
console.log(data);{
"id": 9,
"audienceId": 3,
"audienceName": "Product updates",
"fromAddress": "Acme <news@acme.com>",
"subject": "Our July update, {{name}}",
"status": "sent",
"totalRecipients": 184,
"skippedUnsubscribed": 12,
"skippedSuppressed": 4,
"counts": {
"queued": 0,
"sending": 0,
"sent": 181,
"failed": 1,
"bounced": 2,
"complained": 0
},
"createdAt": "2026-07-20T11:00:00.000Z"
}Send Email — Request Body
| Property | Type | Description |
|---|---|---|
| from* | string | Sender email address. Must use a domain added and verified in your account. Supports "Name <email@domain.com>" format. |
| to* | array of strings | 1–50 recipients, with one mailbox per array entry. Display names are supported. Each recipient counts toward your sending allowance. |
| subject | string | Email subject line. Required unless templateId is set. |
| html | string | HTML body. At least one of html or text is required (unless templateId is set). |
| text | string | Plain-text body. At least one of html or text is required (unless templateId is set). |
| templateId | integer | Send using a saved template. When set, subject, html, and text must be omitted — the template supplies them. Create templates on the Templates page or via the API. |
| variables | object | String values for the template's {{placeholders}}. Checked strictly: every placeholder needs a value, and unknown keys are rejected. Only valid together with templateId. |
| scheduledAt | string (ISO 8601) | Schedule delivery up to 30 days in the future. Scheduled emails can be rescheduled or canceled until they are sent. |
| attachments | array of objects | Up to 5 files, 1.3MB total. Each item: filename, content (base64), and optional contentType. |
Batch items accept the same fields except scheduledAt and attachments.
Webhooks
Configure a webhook to receive real-time updates about email events. Add an endpoint on the Webhooks page — sign up free to get started and Sendar will send it a signed POST request every time a subscribed event happens.
| Event | Sent when |
|---|---|
| email.sent | An email was accepted by the sending provider. |
| email.failed | A send attempt failed. The payload includes the error message. |
| email.scheduled | An email was scheduled for later delivery. |
| email.canceled | A scheduled email was canceled before sending. |
| email.bounced | Delivery hard-bounced and the address was added to your suppression list. |
| email.complained | The recipient marked the email as spam and the address was added to your suppression list. |
| email.opened | A recipient opened a tracked email. Only fires for emails sent with open tracking on; the payload includes the running openCount. |
| email.clicked | A recipient clicked a tracked link. Only fires for emails sent with click tracking on; the payload includes the clicked url and clickCount. |
| email.test | A sample event sent from the Test button in the dashboard. |
{
"type": "email.sent",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": {
"id": 42,
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"status": "sent",
"scheduledAt": null,
"errorMessage": null,
"createdAt": "2026-07-15T11:59:58.412Z"
}
}Verifying signatures
Every delivery includes an X-Sendar-Signature header: an HMAC-SHA256 of <timestamp>.<raw body> using your endpoint's signing secret (shown under Manage on the Webhooks page). The timestamp is in the X-Sendar-Timestamp header (Unix seconds). Always verify before trusting a payload.
import crypto from "node:crypto";
// rawBody must be the exact request body bytes, before any JSON parsing.
function verifySendarSignature(headers, rawBody, secret) {
const timestamp = headers["x-sendar-timestamp"];
const received = headers["x-sendar-signature"]; // "v1=<hex>"
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(received ?? "");
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid webhook signature");
}
// Reject stale events to prevent replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("Webhook timestamp too old");
}
}Deliveries time out after 5 seconds and are retried once after 30 seconds if your endpoint fails or is unreachable. The last 50 delivery attempts per endpoint are visible in the dashboard. Any 2xx response counts as delivered; redirects are not followed.