SMTP / HTTP API

Docs

Quick start

  1. Create an account and verify your account email. Before verification, the account cannot send or create credentials. After verification, T0 can send only to your own account email for free using a verified sending domain. Sending to others requires approval and an active plan.
  2. Add your domain in the console. Publish the returned DKIM TXT and Return-Path CNAME records, and set a valid DMARC TXT policy. Use the exact selector, public key and Return-Path target returned by the API. Check SPF on the Return-Path target; do not add a second SPF record to an existing name. Domain verification returns dkim_ok, return_path_ok and dmarc_ok, not a separate SPF flag.
  3. Create an SMTP login or API key for either transactional or marketing mail. Save its secret immediately; it is only displayed once. Submit your sending use case for review. After approval, T1 starts at 1,000 emails/day and 2/second; limits double after 14 healthy days (bounces < 2%, complaints < 0.1%). Unhealthy sending days reset progress; days without mail do not add progress.

USDT top-ups are credited manually during early access. Contact postmaster@fays.si. Review has no promised turnaround time.

SMTP

Connect to smtp.fays.si:587 using STARTTLS, validate the server certificate, then authenticate with AUTH and your console credentials. Port 587 uses explicit STARTTLS. The From domain must be verified for your account. Each credential is bound to one stream. Marketing mail automatically receives List-Unsubscribe and List-Unsubscribe-Post headers for one-click unsubscribe.

# .env — STARTTLS required
SMTP_HOST=smtp.fays.si
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=your_smtp_username
SMTP_PASSWORD=your_smtp_password

POST /v1/emails

Base URL: https://mail.fays.si. All /v1/ endpoints require Authorization: Bearer <API key>. Send JSON with Content-Type: application/json. Never expose a key in browser code. Unknown JSON fields are ignored. Maximum request body: 32 MiB.

curl https://mail.fays.si/v1/emails \
  -H "Authorization: Bearer $FAYS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-48213" \
  -d '{
    "from": "Shop <hello@example.com>",
    "to": ["reader@example.net"],
    "subject": "Your order is confirmed",
    "html": "<p>Thank you!</p>",
    "stream": "transactional"
  }'
from
Required address, optionally with a display name; the domain must be verified and owned by your account.
to / cc / bcc / reply_to
Address arrays. to + cc + bcc must contain 1–50 original entries in total; recipients are normalized and deduplicated before sending. Bcc is excluded from message headers. reply_to sets reply addresses. Address mailbox parts must be ASCII; SMTPUTF8 is not supported.
subject / html / text
Subject: 1–998 characters with no CR/LF. At least one of html or text must be nonempty. Text + HTML + decoded attachments must total no more than 20 MiB.
attachments
Array of filename, standard base64 content and optional content_type. The default type is inferred from the extension, falling back to application/octet-stream.
headers
At most 20 string values. Names contain only ASCII letters, digits or hyphens. No CR/LF in values or address display names. Reserved headers below cannot be overridden.
stream
transactional or marketing. Optional, but must match the API key when supplied.
tags
At most 10 strings, each 1–64 characters from A–Z, a–z, 0–9, underscore and hyphen.
metadata
At most 20 string entries. Keys: 1–40 characters; values: at most 500 characters.
from to cc bcc reply-to sender subject date message-id mime-version
content-type content-transfer-encoding content-disposition return-path
received dkim-signature list-unsubscribe list-unsubscribe-post

Complete request

{
  "from": "Shop <hello@example.com>",
  "to": [
    "reader@example.net"
  ],
  "cc": [],
  "bcc": [],
  "reply_to": [
    "support@example.com"
  ],
  "subject": "Order confirmed",
  "text": "Thank you!",
  "html": "<p>Thank you!</p>",
  "headers": {
    "X-Order-ID": "48213"
  },
  "attachments": [
    {
      "filename": "note.txt",
      "content": "SGVsbG8=",
      "content_type": "text/plain"
    }
  ],
  "stream": "transactional",
  "tags": [
    "order"
  ],
  "metadata": {
    "order_id": "48213"
  }
}

200 response

{
  "recipients": [
    {
      "email": "reader@example.net",
      "id": "0123456789abcdef0123456789abcdef",
      "status": "queued"
    }
  ]
}

Each recipient is queued, suppressed or failed. Suppressed recipients have no id and incur no charge or quota usage. At least one queued recipient gives HTTP 200. All suppressed gives 422; all injection failures gives 503. Injection failures are refunded; later bounces are not. A queued response means accepted by the platform, not delivered to an inbox.

Idempotency-Key

Both send POST endpoints accept an optional 1–255 character key, shared across the account. The identity includes HTTP method, path and exact raw body bytes, including JSON whitespace. Same key and request replays the saved status and response. A changed request returns 422 idempotency_key_reused; an unfinished request returns 409 idempotency_in_progress. Records are cleaned after 24 hours. Top-level 429/503 releases the key for retry; other results are saved. A crash may leave an unfinished key until cleanup. Avoid blindly retrying partially accepted sends.

POST /v1/emails/batch

Send 1–100 messages in emails. All fields and sending domains are validated first; any validation failure prevents the batch from sending. Once validated, each message is dispatched separately. HTTP 200 data follows input order and can contain per-message errors.

{
  "emails": [
    {
      "from": "Shop <hello@example.com>",
      "to": [
        "reader@example.net"
      ],
      "cc": [],
      "bcc": [],
      "reply_to": [
        "support@example.com"
      ],
      "subject": "Order confirmed",
      "text": "Thank you!",
      "html": "<p>Thank you!</p>",
      "headers": {
        "X-Order-ID": "48213"
      },
      "attachments": [
        {
          "filename": "note.txt",
          "content": "SGVsbG8=",
          "content_type": "text/plain"
        }
      ],
      "stream": "transactional",
      "tags": [
        "order"
      ],
      "metadata": {
        "order_id": "48213"
      }
    }
  ]
}
200
{
  "data": [
    {
      "recipients": [
        {
          "email": "reader@example.net",
          "id": "0123456789abcdef0123456789abcdef",
          "status": "queued"
        }
      ]
    }
  ]
}

A batch with overall HTTP 200 is cached even when some items failed with 429 or 503. Retry only failed items in a new request with a new Idempotency-Key (or without one). Reusing the original key only replays the original result; it does not retry failed items.

GET /v1/emails/{id}

Replace id with the recipient’s 32-character lowercase hexadecimal message id. Only your account’s messages are accessible. Messages and events are retained for 30 days; events are ordered by occurrence time.

GET /v1/emails/0123456789abcdef0123456789abcdef
200
{
  "id": "0123456789abcdef0123456789abcdef",
  "from_domain": "example.com",
  "to": "reader@example.net",
  "subject": "Order confirmed",
  "stream": "transactional",
  "status": "queued",
  "last_response": "",
  "tags": [
    "order"
  ],
  "metadata": {
    "order_id": "48213"
  },
  "created_at": "2026-10-27T12:00:00Z",
  "events": []
}

Domains

Add and verify domains, read required DNS records, or change per-domain tracking. Verification status reflects current DNS checks, so a verify request may still return pending. The following DNS values are placeholders; use the exact records from your response. PATCH only changes the tracking flags you provide.

GET /v1/domains

Request

GET /v1/domains

Response

200
{
  "data": [
    {
      "name": "example.com",
      "status": "pending",
      "dkim_ok": false,
      "return_path_ok": false,
      "dmarc_ok": false,
      "track_transactional": false,
      "track_marketing": true,
      "records": [
        {
          "type": "TXT",
          "name": "<selector>._domainkey.example.com",
          "value": "v=DKIM1; k=rsa; p=<public-key>"
        },
        {
          "type": "CNAME",
          "name": "fm.example.com",
          "value": "<return-path-host>"
        },
        {
          "type": "TXT",
          "name": "_dmarc.example.com",
          "value": "v=DMARC1; p=none;"
        }
      ]
    }
  ]
}

POST /v1/domains

Request

{
  "name": "example.com"
}

Response

201
{
  "name": "example.com",
  "status": "pending",
  "dkim_ok": false,
  "return_path_ok": false,
  "dmarc_ok": false,
  "track_transactional": false,
  "track_marketing": true,
  "records": [
    {
      "type": "TXT",
      "name": "<selector>._domainkey.example.com",
      "value": "v=DKIM1; k=rsa; p=<public-key>"
    },
    {
      "type": "CNAME",
      "name": "fm.example.com",
      "value": "<return-path-host>"
    },
    {
      "type": "TXT",
      "name": "_dmarc.example.com",
      "value": "v=DMARC1; p=none;"
    }
  ]
}

GET /v1/domains/{name}

Request

GET /v1/domains/example.com

Response

200
{
  "name": "example.com",
  "status": "pending",
  "dkim_ok": false,
  "return_path_ok": false,
  "dmarc_ok": false,
  "track_transactional": false,
  "track_marketing": true,
  "records": [
    {
      "type": "TXT",
      "name": "<selector>._domainkey.example.com",
      "value": "v=DKIM1; k=rsa; p=<public-key>"
    },
    {
      "type": "CNAME",
      "name": "fm.example.com",
      "value": "<return-path-host>"
    },
    {
      "type": "TXT",
      "name": "_dmarc.example.com",
      "value": "v=DMARC1; p=none;"
    }
  ]
}

POST /v1/domains/{name}/verify

Request

POST /v1/domains/example.com/verify

Response

200
{
  "name": "example.com",
  "status": "verified",
  "dkim_ok": true,
  "return_path_ok": true,
  "dmarc_ok": true,
  "track_transactional": false,
  "track_marketing": true,
  "records": [
    {
      "type": "TXT",
      "name": "<selector>._domainkey.example.com",
      "value": "v=DKIM1; k=rsa; p=<public-key>"
    },
    {
      "type": "CNAME",
      "name": "fm.example.com",
      "value": "<return-path-host>"
    },
    {
      "type": "TXT",
      "name": "_dmarc.example.com",
      "value": "v=DMARC1; p=none;"
    }
  ]
}

PATCH /v1/domains/{name}

Request

{
  "track_transactional": true,
  "track_marketing": false
}

Response

200
{
  "name": "example.com",
  "status": "pending",
  "dkim_ok": false,
  "return_path_ok": false,
  "dmarc_ok": false,
  "track_transactional": true,
  "track_marketing": false,
  "records": [
    {
      "type": "TXT",
      "name": "<selector>._domainkey.example.com",
      "value": "v=DKIM1; k=rsa; p=<public-key>"
    },
    {
      "type": "CNAME",
      "name": "fm.example.com",
      "value": "<return-path-host>"
    },
    {
      "type": "TXT",
      "name": "_dmarc.example.com",
      "value": "v=DMARC1; p=none;"
    }
  ]
}

DELETE /v1/domains/{name}

Request

DELETE /v1/domains/example.com

Response

204

Suppressions

Hard bounces, complaints and unsubscribes are suppressed per account. GET returns email-ascending pages: limit defaults to 100 and is capped at 1,000; pass next as after. A full page returns its final email as next; otherwise next is null. Adding an existing address preserves its original reason. Deleting a missing address returns 404. A 204 response has no body.

GET /v1/suppressions

Request

GET /v1/suppressions?limit=100&after=

Response

200
{
  "data": [
    {
      "email": "reader@example.net",
      "reason": "manual",
      "created_at": "2026-10-27T12:00:00Z"
    }
  ],
  "next": null
}

POST /v1/suppressions

Request

{
  "email": "reader@example.net"
}

Response

201
{
  "email": "reader@example.net"
}

DELETE /v1/suppressions/{email}

Request

DELETE /v1/suppressions/reader%40example.net

Response

204

Webhooks

At most 5 endpoints per account. A nonempty events array chooses from delivered, bounced, deferred, complained, unsubscribed, opened and clicked. URLs must use HTTPS, have no embedded credentials and resolve to a public address; redirects are not followed. The secret is returned only when created; save the entire whsec_ string.

GET /v1/webhooks

Request

GET /v1/webhooks

Response

200
{
  "data": [
    {
      "id": 1,
      "url": "https://example.com/hooks/fays",
      "events": [
        "delivered",
        "bounced"
      ],
      "created_at": "2026-10-27T12:00:00Z"
    }
  ]
}

POST /v1/webhooks

Request

{
  "url": "https://example.com/hooks/fays",
  "events": [
    "delivered",
    "bounced"
  ]
}

Response

201
{
  "id": 1,
  "url": "https://example.com/hooks/fays",
  "events": [
    "delivered",
    "bounced"
  ],
  "created_at": "2026-10-27T12:00:00Z",
  "secret": "whsec_<save-this-secret>"
}

DELETE /v1/webhooks/{id}

Request

DELETE /v1/webhooks/1

Response

204

Event payload

{
  "id": "4619066a-1a93-470e-9033-206197c6f07e",
  "type": "delivered",
  "created_at": "2026-10-27T12:00:00Z",
  "data": {
    "occurred_at": "2026-10-27T12:00:00Z",
    "email_id": "0123456789abcdef0123456789abcdef",
    "to": "reader@example.net",
    "from_domain": "example.com",
    "subject": "Order confirmed",
    "stream": "transactional",
    "tags": [
      "order"
    ],
    "metadata": {
      "order_id": "48213"
    },
    "response": "250 OK"
  }
}

created_at is the queue time; data.occurred_at is the event time, both UTC. Bounces add bounce_classification; clicks add url. Open events are recorded once, and clicks once per target URL. Use the event id to deduplicate deliveries. Deleting a webhook also deletes its delivery records.

Fays-Signature

Format: t=<unix-seconds>,v1=<lowercase-hex>. HMAC-SHA256 signs the ASCII timestamp, a dot, then the unchanged raw HTTP body bytes, using the entire secret string. Do not parse and reserialize JSON before verification. Check a five-minute clock window, use constant-time comparison, then parse and process the event idempotently. Keep your clock synchronized.

Node.js / TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySignature(
  secret: string,
  signature: string,
  rawBody: Uint8Array,
  now = Math.floor(Date.now() / 1000),
): boolean {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signature);
  if (!match || Math.abs(now - Number(match[1])) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(match[1] + ".")
    .update(rawBody)
    .digest();
  return timingSafeEqual(expected, Buffer.from(match[2], "hex"));
}

Go

package webhook

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "regexp"
  "strconv"
  "time"
)

var signaturePattern = regexp.MustCompile(`^t=([0-9]+),v1=([a-f0-9]{64})$`)

func VerifySignature(secret, signature string, rawBody []byte, now time.Time) bool {
  parts := signaturePattern.FindStringSubmatch(signature)
  if parts == nil { return false }
  stamp, err := strconv.ParseInt(parts[1], 10, 64)
  if err != nil { return false }
  signedAt := time.Unix(stamp, 0)
  if signedAt.Before(now.Add(-5*time.Minute)) || signedAt.After(now.Add(5*time.Minute)) { return false }
  got, err := hex.DecodeString(parts[2])
  if err != nil { return false }
  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write([]byte(parts[1] + "."))
  mac.Write(rawBody)
  return hmac.Equal(mac.Sum(nil), got)
}

Delivery and retries

Requests use Content-Type: application/json and User-Agent: FaysMail-Webhooks/1. Any 2xx is success. Other statuses, redirects and network errors retry with a 10-second request timeout. After failure k, delay is 30 seconds × 2^(k−1); a next attempt beyond 24 hours after creation marks the delivery failed. The worker leases up to 20 entries every 2 seconds. Nonpending delivery records are cleaned after 7 days; manual replay is not available.

Inbound mail

Verify ownership of a sending domain first, then add it or a subdomain as an inbound domain. Publish MX priority 10 to mx.fays.si. Using the root domain takes over all incoming mail for that domain. The lowest-priority-number MX records must all point to mx.fays.si. Verification activates reception; administrators can pre-activate before switching DNS. A later MX mismatch does not deactivate an already active domain.

Inbound SMTP uses port 25, at most 20 MiB and 50 recipients. The account must be active with an active or grace plan. Daily inbound limits use the subscribed version, with account overrides, resetting at 00:00 UTC; inbound does not consume sending allowance or balance. No plan returns 550 5.7.1; exhausted daily quota returns 452 4.2.2. Exact enabled routes win over the * catch-all. Local parts match case-insensitively; +tag is removed for matching and preserved in the payload. Routes can deliver to a Webhook or a verified forwarding address.

GET /v1/inbound/forward-targets

List forwarding addresses and availability

200 {data: [{id,email,status,created_at}],available}

POST /v1/inbound/forward-targets

Add address and send a confirmation link valid for 72 hours; at most 3 per address per day

{"email":"you@example.net"} → 201

POST /v1/inbound/forward-targets/{id}/resend

Resend confirmation; resend_limit returns Retry-After

200

DELETE /v1/inbound/forward-targets/{id}

Delete address and disable routes referencing it

204

GET /v1/inbound/domains

List inbound domains

200 {data: [{id,name,status,mx_ok,activated_by,mx_record,created_at}]}

POST /v1/inbound/domains

Add an owned domain or subdomain

{"name":"in.example.com"} → 201

DELETE /v1/inbound/domains/{id}

Delete domain and its routes

204

POST /v1/inbound/domains/{id}/verify

Check MX and activate

200 {id,name,status,mx_ok,mx_record,…}

GET /v1/inbound/routes

List routes; never includes secret

200 {data: [{id,inbound_domain_id,domain,local_part,action,webhook_url,include_raw,enabled,failing,…}]}

POST /v1/inbound/routes

Create route; save secret once

{"inbound_domain_id":1,"local_part":"support","action":"webhook","webhook_url":"https://example.net/inbound","include_raw":false} → 201 {…,secret}

PATCH /v1/inbound/routes/{id}

Update webhook_url, enabled, include_raw or forward_target_id; action is immutable

{"enabled":false} → 200

DELETE /v1/inbound/routes/{id}

Delete route

204

GET /v1/inbound/messages

50 per page, created_at DESC then id; metadata and summarized auth, no envelope

?after=&route_id=&status= → 200 {data: […],next: string|null}

GET /v1/inbound/messages/{id}

Message metadata including envelope

200 {id,created_at,route_id,action,rcpt,mail_from,subject,status,spam_score,auth,envelope,…}

Forwarding

A dedicated active forwarding source is required (except for the platform account). Confirm the destination using the email link and its confirmation button; opening the link alone does not confirm. Pending addresses cannot be used by routes, and active inbound domains cannot be forwarding destinations.

{"inbound_domain_id":1,"local_part":"*","action":"forward","forward_target_id":1}

Forward routes have no Webhook secret. Each route allows 200 messages per day, also subject to the account forwarding allowance. Spam score ≥ 6 or DMARC failure with reject policy is filtered. Forwarding uses SRS and ARC, with retries for up to 3 days. Five consecutive hard bounces disable the route and notify the owner; successful delivery resets the counter. Logs include forward_to and filtered / bounced states.

Signed inbound payload

{
  "type": "inbound.received",
  "id": "019a2ba0-0000-7000-8000-000000000001",
  "received_at": "2026-10-28T12:00:00Z",
  "route": {
    "id": 1,
    "local_part": "support",
    "tag": "Order-9"
  },
  "envelope": {
    "mail_from": "sender@example.net",
    "rcpt_to": "support+Order-9@in.example.com",
    "remote_ip": "203.0.113.10",
    "helo": "mail.example.net",
    "tls": true
  },
  "from": {
    "name": "Sender",
    "address": "sender@example.net"
  },
  "to": [
    {
      "name": "",
      "address": "support+Order-9@in.example.com"
    }
  ],
  "cc": [],
  "reply_to": [],
  "subject": "Hello",
  "message_id": "example@example.net",
  "in_reply_to": "",
  "references": [],
  "date": "2026-10-28T11:59:00Z",
  "text": "Hello",
  "html": "",
  "headers": [
    [
      "Subject",
      "Hello"
    ]
  ],
  "attachments": [],
  "auth": {
    "spf": "pass",
    "dkim": [
      {
        "domain": "example.net",
        "result": "pass"
      }
    ],
    "dmarc": "pass",
    "arc": "none"
  },
  "spam": {
    "score": 0.1,
    "action": "no action",
    "symbols": []
  }
}

POST uses Content-Type: application/json, Fays-Inbound-Id and Fays-Signature. Verify the unchanged HTTP body with the same HMAC-SHA256 verifier above, using the route's entire whsec_ secret. Deduplicate by Fays-Inbound-Id; retries can repeat a valid delivery. Headers retain order and duplicates. Attachments contain filename, content_type, size, content_id, disposition and content_base64. include_raw adds raw_base64; broken MIME forces raw_base64 and an empty text field. Message bodies remain in the mail queue, not the metadata API.

2xx succeeds; 406 or 410 fails permanently; all other responses and network errors retry for up to 24 hours, starting at 1 minute with a maximum interval of 1 hour. Requests time out after 30 seconds and never follow redirects. URLs must be public HTTPS, with the same SSRF checks as event Webhooks. Deleted or disabled routes fail with route_disabled. Spam score ≥ 15 or a reject action rejects inbound mail; an unavailable scanner temporarily rejects with 451. In mixed bounce/inbound transactions, only the spam inbound copy is dropped.

Inbound error codes

HTTPCodeMeaning
409inbound_domain_existsInbound domain already exists
422inbound_domain_not_ownedVerify the owning parent domain first
422inbound_domain_reservedDomain reserved by the platform
409route_existsRoute already exists
422forward_not_availableForwarding unavailable
409forward_target_existsForwarding target already exists
422forward_target_unverifiedForwarding target not verified
422forward_target_loopForwarding target uses an inbound domain
422confirm_token_invalidConfirmation token invalid or expired
429resend_limitConfirmation resend limit reached

Limits and errors

HTTP 429 includes Retry-After in seconds: 1 for the rate limit, or rounded-up seconds until 00:00 UTC for the daily limit. SMTP returns 451 4.7.1 for rate or daily limits; defer and retry. T1 begins at 1,000/day and 2/second, with account-specific overrides possible. Inspect each batch result even when HTTP is 200.

{"error":{"code":"rate_limited","message":"Rate limit exceeded"}}
HTTPCodeMeaning
400invalid_jsonInvalid JSON
401invalid_api_keyMissing or invalid key, or closed account
401unauthorizedAccount authentication required
402plan_requiredAn active plan is required
402insufficient_balanceInsufficient prepaid balance
403account_inactiveAccount cannot send
404not_foundResource missing or owned by another account
409domain_existsDomain already registered
409idempotency_in_progressSame key is still processing
422validation_errorInvalid fields or size limit exceeded
422domain_not_verifiedFrom domain is not verified for this account
422stream_mismatchStream differs from the API key
422tier_restrictedT0 can only send to its account email
422all_recipients_suppressedAll recipients are suppressed
422free_mail_domainFree mailbox domains cannot be added
422webhook_limitMaximum five webhooks per account
422idempotency_key_reusedKey was used with a different request
429rate_limitedPer-second limit; Retry-After: 1
429daily_limit_reachedDaily limit; Retry-After until 00:00 UTC
503temporary_errorTemporary server or dependency failure