Docs
Quick start
- 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.
- 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.
- 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_passwordPOST /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-postComplete 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/0123456789abcdef0123456789abcdef200
{
"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/domainsResponse
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.comResponse
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/verifyResponse
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.comResponse
204Suppressions
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.netResponse
204Webhooks
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/webhooksResponse
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/1Response
204Event 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"} → 201POST /v1/inbound/forward-targets/{id}/resend
Resend confirmation; resend_limit returns Retry-After
200DELETE /v1/inbound/forward-targets/{id}
Delete address and disable routes referencing it
204GET /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"} → 201DELETE /v1/inbound/domains/{id}
Delete domain and its routes
204POST /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} → 200DELETE /v1/inbound/routes/{id}
Delete route
204GET /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
| HTTP | Code | Meaning |
|---|---|---|
| 409 | inbound_domain_exists | Inbound domain already exists |
| 422 | inbound_domain_not_owned | Verify the owning parent domain first |
| 422 | inbound_domain_reserved | Domain reserved by the platform |
| 409 | route_exists | Route already exists |
| 422 | forward_not_available | Forwarding unavailable |
| 409 | forward_target_exists | Forwarding target already exists |
| 422 | forward_target_unverified | Forwarding target not verified |
| 422 | forward_target_loop | Forwarding target uses an inbound domain |
| 422 | confirm_token_invalid | Confirmation token invalid or expired |
| 429 | resend_limit | Confirmation 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"}}| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_json | Invalid JSON |
| 401 | invalid_api_key | Missing or invalid key, or closed account |
| 401 | unauthorized | Account authentication required |
| 402 | plan_required | An active plan is required |
| 402 | insufficient_balance | Insufficient prepaid balance |
| 403 | account_inactive | Account cannot send |
| 404 | not_found | Resource missing or owned by another account |
| 409 | domain_exists | Domain already registered |
| 409 | idempotency_in_progress | Same key is still processing |
| 422 | validation_error | Invalid fields or size limit exceeded |
| 422 | domain_not_verified | From domain is not verified for this account |
| 422 | stream_mismatch | Stream differs from the API key |
| 422 | tier_restricted | T0 can only send to its account email |
| 422 | all_recipients_suppressed | All recipients are suppressed |
| 422 | free_mail_domain | Free mailbox domains cannot be added |
| 422 | webhook_limit | Maximum five webhooks per account |
| 422 | idempotency_key_reused | Key was used with a different request |
| 429 | rate_limited | Per-second limit; Retry-After: 1 |
| 429 | daily_limit_reached | Daily limit; Retry-After until 00:00 UTC |
| 503 | temporary_error | Temporary server or dependency failure |