Transactional email sending.
| POST | /api/v1/emails | Invia un'email transazionale. `idempotencyKey` nel body obbligatoria (min 8 char); `from`/`replyTo` accettano 'Nome <a@b.com>'; `replyTo` anche piu' indirizzi (virgola o array, max 10); solo `text` senza `html` = email di solo testo.(scope: email:send) |
| GET | /api/v1/emails | Elenca gli invii col riassunto di engagement (aperture umane e da proxy, click, click sul contenuto); filtri campaignId, contactId, status, engagement, q, since.(scope: emails:read|email:send) |
| GET | /api/v1/emails/:id | Stato di un invio: metadati, engagement, link cliccati col ruolo e timeline eventi (sui click: linkUrl, linkId, linkRole). |
| POST | /api/v1/messages | Invio transazionale unificato email/WhatsApp/Telegram.(scope: messages:send) |
POST /api/v1/emails
Body parameters (JSON):
| Field | Type | Required | Notes |
|---|---|---|---|
to | string or array | yes | One or more recipients: a@b.com, Name <a@b.com>, several addresses comma separated in the same string, or an array. |
from | string | yes | a@b.com or Name <a@b.com>. The domain must be verified. |
subject | string | yes* | Min 1 char. Optional if the template supplies the subject. |
html | string | yes* | HTML body. Use templateId instead if you prefer, or text alone for a text-only email. |
text | string | no | Plain text version (multipart part). Sent alone, without html or templateId, it makes a text-only email: no open tracking, links left as written. |
replyTo | string | string[] | no | Reply-To: one or more addresses (a@b.com, Name <a@b.com>, comma-separated or an array, at most 10). A plain "Reply" reaches all of them. |
cc | string or array | no | Visible copies: they appear in the Cc header. Same forms as to. |
bcc | string or array | no | Blind copies: they appear in no header of the message. Same forms as to. |
templateId | string | no | Id (uuid) or name of a tenant template; alternative to html. |
variables | object | no | Interpolation variables for the template. |
attachments | array | no | Attachments: { id }, { filename, content } (base64) or { filename, url }. See Attachments. |
idempotencyKey | string | yes | Min 8 characters. Prevents double sends on retries. Goes in the body, not in a header. |
disableTracking | boolean | no | Turns off pixel and click redirects for this send (default false). |
disableUtm | boolean | no | Skips UTM auto-tagging of links (default false). |
subject is required unless a templateId supplies it. The body is html, a templateId, or text alone (text-only email).Max 50 recipients per message (to plus cc plus bcc), an SES limit: past that the
response is 422 too_many_recipients. Every recipient consumes one unit of the plan's
monthly quota. Copies in the suppression list (bounce, complaint, unsubscribe) are removed
from the send and reported back in droppedRecipients; the request is a 409 only when no
to recipient is left to send to. Opens and clicks are per message, not per recipient: they
cannot tell which of the copies opened.
Every address field accepts both a@b.com and Name <a@b.com>, and to, cc and bcc
also take several addresses at once, as an array or comma separated inside one string
("sara@acme.io, boss@acme.io"). A comma inside a quoted display name does not split the
list: "Neri, Sara" <sara@acme.io> is one recipient. Multiple to addresses land in the To
header, not in Cc, and GET /api/v1/emails/{id} returns them in toAddresses.
Responses: 202 ({ id, status: "queued" }), 200 ({ status: "duplicate", idempotencyKey, id }),
422 (validation), 402 (plan quota), 403 (domain_not_verified), 409 (suppressed).
Example
import { Sending } from "sending-sdk";
const sending = new Sending({ apiKey: process.env.SENDING_API_KEY! });
const { id } = await sending.emails.send({
from: "Acme <hello@yourdomain.dev>",
to: "sara@acme.io",
subject: "Hello",
html: "<p>Hey</p>",
replyTo: "Support <support@yourdomain.dev>",
idempotencyKey: "hello-sara-001",
});POST /api/v1/emails/batch
Up to 100 emails in one request. The body is { "emails": [...] } and each item is
exactly the body of POST /api/v1/emails, idempotencyKey included.
The outcome is partial, not all-or-nothing: the response is 207 and results is aligned
by index with the array you sent, so one invalid address out of 60 does not stop the other 59.
| Result field | Notes |
|---|---|
index | Position in the emails array you sent. |
status | accepted or failed. |
id | Message id, present on accepted items (duplicates included). |
duplicate | true if that idempotencyKey had already been accepted: this is not an error. |
droppedRecipients | Copies removed because they are suppressed. |
error | { code, message } on failures: validation_error, domain_not_verified, suppressed, template_not_found, quota_exceeded, too_many_recipients. |
Idempotency applies per item: a retried batch produces no double sends, and items already
accepted come back accepted with duplicate: true. For rate limiting
purposes the call counts as one request.
const outcome = await sending.emails.sendBatch([
{ from: "Acme <hello@yourdomain.dev>", to: "sara@acme.io", subject: "Hello", html: "<p>hey</p>", idempotencyKey: "b-001" },
{ from: "Acme <hello@yourdomain.dev>", to: "boss@acme.io", subject: "Hello", html: "<p>hey</p>", idempotencyKey: "b-002" },
]);
for (const r of outcome.results) {
if (r.status === "failed") console.error(`item ${r.index}: ${r.error?.code}`);
}Response:
{
"accepted": 2,
"failed": 1,
"results": [
{ "index": 0, "status": "accepted", "id": "8f1c…" },
{ "index": 1, "status": "accepted", "id": "0b22…", "duplicate": true },
{ "index": 2, "status": "failed", "error": { "code": "suppressed", "message": "complaint" } }
]
}GET /api/v1/emails/:id
State of one send: use the id returned when you sent it.
const status = await sending.emails.get(id);
console.log(status.status, status.events);It returns status, metadata (to, from, subject, provider, createdAt, kind:
transactional, campaign or automation), the attachments of the send (id, filename,
contentType, size), an engagement summary (opensHuman, opensMachine, clicks,
contentClicks, lastLinkUrl, bounced, unsubscribed), the links clicked grouped by
canonical URL (with role and isContent) and the events timeline (sent, delivered, open,
click, bounce, complaint). Every click event carries linkUrl, linkId and linkRole
(logo, image, cta, text, social, video, chrome): three clicks on the logo are not
interest in the content. Opens count as human only when classification is human: Apple Mail
Privacy Protection and Gmail's proxy open every email on their own.
GET /api/v1/emails
The sends of the workspace, newest first, each with the same engagement summary. Same filters as
the dashboard list: campaignId, contactId, status (queued, sent, delivered, failed,
bounced), engagement (clicked, opened_not_clicked, not_opened among the delivered,
bounced, unsubscribed), q on recipient and subject, since (ISO 8601), limit (max 100) and
offset. Requires the emails:read scope (or email:send).
// Who clicked in this campaign?
const page = await sending.emails.list({ campaignId, engagement: "clicked", limit: 100 });
console.log(page.total, page.data.map((m) => m.to));