Smart Services

by wizewerx

Docs
PricingConsole

Email

Transactional email as a single POST, with per-message delivery tracking. Send through us or bring your own SMTP, and file every message to your Sent folder over IMAP.

What it is

Email-as-a-service over HTTPS: one POST sends transactional or campaign mail, with open and click tracking, campaign tagging and per-message logs. You can send through the platform mailserver, or bring your own mail server (BYOMS) and have Smart Services authenticate to it as you — optionally filing a copy into that mailbox's Sent folder over IMAP.

Before you start

You need your app API key from the Smart Services console → your app → Settings, and the email service enabled on that app. Without the service enabled, every email endpoint returns 403 Email service not enabled for this app.

Base URL: https://smart-services.io

Both header forms are accepted:

Authorization: Bearer <app key>
x-api-key: <app key>

The key is server-side only. It authenticates every call your app makes to Smart Services — jobs, storage, auth, email — so it never belongs in a browser.

Your first call

curl -X POST "https://smart-services.io/api/v1/email/send" \
  -H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["user@example.com"],
    "subject": "Welcome!",
    "html": "<h1>Welcome to our platform!</h1>",
    "trackingEnabled": true
  }'
const res = await fetch('https://smart-services.io/api/v1/email/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: ['user@example.com'],
    subject: 'Welcome!',
    html: '<h1>Welcome to our platform!</h1>',
    trackingEnabled: true,
  }),
});
const result = await res.json();

Response:

{
  "id": "<mfa2k1x8.n4p7q2ws8kd3mz1ry6hv0jb5tc9xa@wizewerx.tech>",
  "messageId": "<mfa2k1x8.n4p7q2ws8kd3mz1ry6hv0jb5tc9xa@wizewerx.tech>",
  "emailId": "clx8h2k9p0001qw3r5t7y9u1i",
  "status": "sent"
}

messageId is the RFC Message-ID we mint — its domain comes from the From address — and it is the same value in the SMTP send, the IMAP filed copy and this response. Store it to join a later reply to your own record. emailId is the log row id; pass it to /api/v1/email/tracking/:emailId.

Fields on POST /api/v1/email/send

FieldTypeNotes
tostring or string[]Required. A comma-separated string is split. Max 50 recipients.
subjectstringRequired.
htmlstringRequired unless text is given. If you send html alone, the plain-text part is derived from it.
textstringRequired unless html is given. Sending text alone stays text-only — no HTML part is invented.
replyTostringOverrides the sender account's own reply-to.
senderIdstringWhich sender account to send as. Omit for the app default.
saveToSentbooleanFile a copy in the sender's mailbox over IMAP.
trackingEnabledbooleanOpen pixel and click rewriting. tracking is accepted as an alias.
campaignstringGroups messages for stats and unsubscribe. Defaults to "default".
tagsstring[]Free-form labels stored on the log.
metadataobjectArbitrary JSON stored on the log.
attachmentsarray{ filename, contentBase64, contentType } per item.

The endpoint also accepts multipart/form-data, in which case file parts become attachments and tags is a comma-separated string.

The rest of the endpoints

MethodPathWhat it does
POST/api/v1/email/sendSend one message to up to 50 recipients.
GET/api/v1/email/sendersList this app's sender accounts.
POST/api/v1/email/sendersCreate a sender account, with optional BYOMS SMTP credentials.
GET/api/v1/email/senders/:idFetch one sender account.
PATCH/api/v1/email/senders/:idUpdate a sender account.
DELETE/api/v1/email/senders/:idDelete a sender account.
POST/api/v1/email/senders/:id/set-defaultMake it the app's default sender.
GET/api/v1/email/logsFilter and page through sent mail.
GET/api/v1/email/campaignsPer-campaign totals and open/click rates.
GET/api/v1/email/tracking/:emailIdFull event timeline for one message.
POST/api/v1/email/compose-tokenMint a token for the embedded composer widget.
GET, POST/api/v1/email/composeThe composer widget's own endpoint. Compose token, not API key.

Three more endpoints exist for recipients, not for you. They are unauthenticated, and trackingEnabled: true writes them into the message for you:

MethodPathWhat it does
GET/track/:trackingId/openedThe 1×1 pixel. Records an open, returns a transparent GIF.
GET/track/:trackingId/clicked?url=…Records a click, then 302s to the original URL.
GET/unsubscribe/:trackingIdHosted unsubscribe page for that campaign.

Equivalents also live under /api/v1/email/track/open/:trackingId and /api/v1/email/track/click/:trackingId. The short paths are what gets injected — a recipient should not be shown an /api/v1 URL.

/api/v1/email/unsubscribe/:trackingId is not an equivalent of /unsubscribe/:trackingId, despite the matching name. The short path unsubscribes on the GET and shows a confirmation; the /api/v1 path shows a confirmation page on GET and only unsubscribes on a subsequent POST. If you link a recipient to the /api/v1 path expecting one click to be enough, they will not be unsubscribed.

/api/v1/email/logs takes campaign, senderId, status, to, startDate, endDate, limit (default 50, max 100) and offset, and returns { data, pagination: { total, limit, offset, hasMore } }.

Senders and BYOMS

A sender account is an identity — name, email, fromName, optional replyTo. Give it nothing more and mail goes out through the platform mailserver wearing that From address.

Add smtpHost, smtpPort, smtpSecure, smtpUser and smtpPass and it becomes BYOMS: we connect to your mail server and send as you, so SPF, DKIM and your domain's reputation are yours.

curl -X POST "https://smart-services.io/api/v1/email/senders" \
  -H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Support",
    "email": "support@yourdomain.com",
    "fromName": "Your Company Support",
    "replyTo": "support@yourdomain.com",
    "smtpHost": "smtp.yourdomain.com",
    "smtpPort": 587,
    "smtpSecure": false,
    "smtpUser": "support@yourdomain.com",
    "smtpPass": "<mailbox password>",
    "isDefault": true
  }'

smtpPass is sealed at rest and is never returned by any read endpoint. Sender responses carry smtpHost, smtpPort, smtpSecure and smtpUser, plus the counters totalSent, totalDelivered, totalOpened, totalClicked and totalBounced — never the password.

IMAP sync

Mail sent through an API is invisible to the mailbox it claims to come from: the operator's mail client never saw it, so a reply threads against nothing and quotes no history.

IMAP filing fixes that. With an IMAP host configured on a sender and saveToSent: true on the send, we append the exact same message — same Message-ID — to that mailbox's Sent folder. The operator's existing mail client becomes the thread view; the conversation just continues there.

IMAP uses the same mailbox login as SMTP: there is no second credential, smtpUser and smtpPass are what we authenticate with. The IMAP host defaults to a convention (smtp.example.comimap.example.com; mail.example.com is left alone), and can be edited. The Sent folder is chosen from Sent, INBOX.Sent, Sent Items, Sent Messages and [Gmail]/Sent Mail unless you name one.

IMAP settings are configured in the console, not through /api/v1/email/senders — that route reads and writes the SMTP fields only. The console also has a verify action that actually connects: until it succeeds a sender's IMAP capability reads as unverified, meaning configured but never proven.

Filing happens strictly after the send, and a filing failure never fails the send. Three outcomes, so you can be honest about what happened:

{
  "id": "<...>",
  "messageId": "<...>",
  "emailId": "clx8...",
  "status": "sent",
  "savedToSent": false,
  "saveError": "No sender account on this send — nothing to file into"
}

status: "sent" with savedToSent: false means the mail is delivered. Do not re-send.

Limits and errors

Errors are {"error": "<message>"} with the status in the HTTP code.

StatusWhen
400Missing recipient, subject or body; more than 50 recipients; a senderId that does not exist, is inactive, or belongs to another app.
401Missing or invalid app API key.
403Email service not enabled for this app.
404No such sender, or no such email id.
409A sender with that address already exists; or a delete blocked because other apps borrow this mailbox's credentials.
413Body plus attachments over the size cap. The message names the limit in MB.
502The mail server rejected the send. A failed log row is written with the reason.

A 502 returns {"status": "failed", "error": "Failed to send email"}. The underlying SMTP error is recorded on the log row rather than returned, so a misconfigured relay does not leak its internals to a caller; read it back from /api/v1/email/logs.

Email is metered as sends per month, and the meter resets monthly. Which tier your app is on, and the current allowance for it, are shown in the console on your app's page. Some older apps sit on a legacy tier that is an exact alias of the included one — the console shows the real number either way.

/api/v1/email/logs is capped at 100 rows per request; page with offset.

Gotchas

A named senderId is honoured or refused — never silently swapped. A stale or foreign id is a 400, not a quiet fall back to the app default. Mail going out wearing the wrong From address is worse than a fixable error.

Without a senderId, resolution walks a ladder: the app's default sender, then app-level settings on the platform mailserver. In the last case the From display name becomes <Your App> via Wizewerx Smart Services from a platform address — not your domain.

savedToSent: false does not mean the send failed. The mail is delivered. Re-sending on that condition mails your recipient twice. Check status.

saveToSent needs a real sender account. A send resolved to the platform mailserver has no mailbox to file into, and says so in saveError.

The plain-text part is generated before tracking is applied. If you send html only, the text alternative is derived from your clean HTML, so the copy people read with images off does not fill with redirect URLs. If you supply both parts yourself, only the HTML gets rewritten.

Open tracking is a pixel, so it undercounts. A recipient with images disabled reads the message and never registers an open. Click tracking is a redirect and is far more reliable. Treat open rates as a floor, not a measurement.

trackingEnabled: true also appends an unsubscribe link. Tracking does three things to your HTML: rewrites links, injects the pixel, and adds a "Don't contact me about this again" link at the end. If you have your own footer, expect ours below it. Tracking is applied to the HTML part only — a text-only message is left alone and gets no tracking at all.

Unsubscribe needs a campaign. The hosted unsubscribe page is per (app, email, campaign). A message whose log row carries no campaign shows the recipient that the email does not support unsubscribe.

Campaign stats count differently from the send log. /api/v1/email/campaigns derives open and click rates from delivered messages, and only counts a campaign's unsubscribes against campaigns that also have sent mail.

Deleting a sender other apps borrow is refused with a 409. The response lists the borrowers. Point them elsewhere first, or deactivate the sender instead of deleting it.

Compose tokens do not expire. Their safety is the pinned recipient baked into the token, not a lifetime: a stolen token can only mail the person it was minted for, and the recipient always comes from the token, never from the request body. Mint them server-side with your API key — that is the point of the widget.

The composer sanitises the HTML it is given. Operator-authored HTML from a browser goes through an allowlist before it is sent or stored. POST /api/v1/email/send does not do this; what you pass is what goes out.

A sender's IMAP state can read unverified forever. That only means the configuration was saved, not that we ever connected. Run the verify action in the console before you rely on filing.