# Email API Send transactional email — one-time codes, password resets, verification links, receipts — through the JigsawAcc mail server from your own application. **Base URL:** `https://jigsawme.site/api/v1` Every message is sent from a mailbox that **already exists on this mail server**. The `from` address you supply is verified against the server's live mailbox records on every request; an address that is not a real, active mailbox is rejected before anything is sent. Messages are DKIM-signed by the server and relayed through an established delivery provider, so they arrive authenticated rather than as unverified mail from an unknown host. ## Quick start ```bash curl -X POST https://jigsawme.site/api/v1/emails \ -H "Authorization: Bearer $EMAIL_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-otp-4821" \ -d '{ "from": "noreply@jigsawme.site", "from_name": "Acme", "to": "user@example.com", "template": "otp", "data": { "code": "482913", "expires_in_minutes": 10, "product": "Acme" } }' ``` ```json { "ok": true, "message": { "id": "msg_01m1txesnk1mdakth6wk3c92s5", "status": "accepted", "from": "noreply@jigsawme.site", "to": ["user@example.com"], "subject": "Your verification code", "template": "otp", "accepted_at": "2026-09-06T08:30:46+00:00" } } ``` That is the whole integration. Everything below is detail. ## Getting an API key Keys are issued per application by the JigsawAcc operator. Ask for one, and say: - **What the application is**, and what it will send (OTPs, password resets, receipts). - **Which From addresses it needs.** You will be granted a specific address, a whole domain, or nothing wider than you asked for. - **Rough volume**, so your per-minute and per-day quotas are set sensibly. You receive a token beginning `jsw_`. **It is shown once and stored only as a hash** — the operator cannot look it up again, so record it somewhere safe the moment you get it. If it is lost or leaked, ask for a replacement and the old one will be revoked. ### Which addresses you can send from Your key is scoped to a set of sender patterns — one exact address (`noreply@jigsawme.site`), a whole domain (`*@jigsawme.site`), or several. Two things must both be true for a send to succeed: 1. The address is inside your key's allowlist. 2. The address is a real, active mailbox on the server. The allowlist only ever narrows. It cannot bring a mailbox into existence, so ask the operator to create the mailbox first if it does not exist yet. Call `GET /senders` at any time for the definitive list of addresses your key can currently use. **Send from `jigsawme.site` or `nexosoft.online`.** Both are fully configured for authenticated delivery. If you are offered an address on any other domain, confirm with the operator that it is set up for external mail first. ## Authentication Send your token on every request, either way: ``` Authorization: Bearer jsw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx X-API-Key: jsw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` All authentication failures answer `401` with the same body — missing, wrong, revoked and expired keys are deliberately indistinguishable. This is a **server-to-server** API. Keep the token in your environment configuration; never commit it, and never ship it to a browser or mobile client. Anything holding the key can send mail as your organisation. Verify your key at deploy time rather than discovering it is wrong on a user's first password reset: ```bash curl https://jigsawme.site/api/v1/ping -H "Authorization: Bearer $EMAIL_API_KEY" ``` ```json { "ok": true, "client": "Acme Web App", "allowed_senders": ["noreply@jigsawme.site"], "limits": { "per_minute": 60, "per_day": 2000, "sent_today": 14, "max_recipients": 10 }, "expires_at": null } ``` ## Endpoints | Method | Path | Purpose | |---|---|---| | `POST` | `/api/v1/emails` | Send one message | | `GET` | `/api/v1/emails/{id}` | Status of one message | | `GET` | `/api/v1/emails?limit=25` | Your recent sends (max 100) | | `GET` | `/api/v1/senders` | Addresses your key may send as, right now | | `GET` | `/api/v1/ping` | Verify the key; see quotas and usage | `GET /senders` resolves against live mailboxes rather than echoing your allowlist back, so it is the authoritative answer to "why was my `from` rejected?". Every endpoint is scoped to your key. You can only ever see messages your own key sent. ## Sending a message `POST /api/v1/emails` | Field | Type | Required | Notes | |---|---|---|---| | `from` | string | **yes** | Must be an active mailbox on this server and allowed for your key. | | `from_name` | string | no | Display name, e.g. `Acme Security`. | | `to` | string or array | **yes** | One address or a list. | | `cc`, `bcc` | string or array | no | Same shape as `to`. | | `reply_to` | string | no | Where replies go, if not `from`. | | `subject` | string | see note | Required for raw bodies; optional with a template. Max 200 chars. | | `template` | string | see note | One of the built-ins below. | | `data` | object | with template | Template variables. | | `html` | string | see note | Raw HTML body. | | `text` | string | see note | Raw plain-text body. Send with `html` for a multipart message. | | `tag` | string | no | Free label for your own filtering, e.g. `login-otp`. | | `headers` | object | no | Custom headers. **`X-` names only** — everything else is dropped. | | `attachments` | array | no | `[{ "filename", "content" (base64), "content_type" }]` | Provide **either** `template` **or** `html`/`text` — never both. A request with neither, or with both, is rejected. ### Built-in templates Responsive, client-safe HTML with no external assets, so they render the same in Gmail, Outlook and the rest. Pass `data.product` to brand a message as your own application. | Template | Required `data` | Optional `data` | Default subject | |---|---|---|---| | `otp` | `code` | `expires_in_minutes`, `heading`, `product`, `support_email` | Your verification code | | `password_reset` | `action_url` | `expires_in_minutes`, `action_text`, `heading`, `product` | Reset your password | | `verify_email` | `action_url` | `expires_in_minutes`, `action_text`, `heading`, `product` | Confirm your email address | | `notice` | `body` | `heading`, `action_url`, `action_text`, `product` | Notification | Missing required data is a `422`, not a half-rendered email. A request for the `otp` template without `code` never reaches a recipient as "Your code is ". `notice.body` is plain text — it is HTML-escaped and its newlines become line breaks. To send your own markup, use `html` instead. ### Raw bodies ```json { "from": "billing@jigsawme.site", "to": "user@example.com", "subject": "Invoice #4821", "html": "
Total: $49.00
", "text": "Invoice #4821\nTotal: $49.00", "attachments": [ { "filename": "invoice-4821.pdf", "content": "JVBERi0xLjQK…", "content_type": "application/pdf" } ] } ``` Sending both `html` and `text` produces a `multipart/alternative` message, which is what you want — some clients and most spam filters prefer it. ### Limits | Limit | Default | |---|---| | Requests per minute | 60 | | Messages per day | 2000 | | Recipients per message | 10 (server ceiling 50) | | Body size | 512 KB | | Attachments | 5 files, 5 MB total | | Subject | 200 characters | The per-minute and per-day figures are set on your key; call `GET /ping` to read your own. Attachment types are limited to PDF, plain text, CSV, iCalendar, JSON, and PNG/JPEG/GIF/WebP images. This is a **transactional** API — one message, to the people it concerns. It has no unsubscribe handling and no list management, so it is the wrong tool for newsletters or marketing sends, and using it that way risks the sending domain's reputation for everyone on it. ## Idempotency Send an `Idempotency-Key` header on anything a user would be harmed by receiving twice: ``` Idempotency-Key: password-reset-user-8821-1788683446 ``` If a request with that key already succeeded for your API key, the original result is returned with `X-Idempotent-Replay: true` and **no second email is sent**. This is the difference between a timed-out HTTP call being retried harmlessly and a user receiving a second code that invalidates the one they are already typing. Keys are scoped to your API key, so they cannot collide with another application's. Make them unique per logical action — include the user id and the attempt, not just the operation name. ## Responses and error handling | Status | Meaning | Retry? | |---|---|---| | `202` | Accepted by the mail server | no | | `200` | Idempotent replay, or a `GET` | no | | `401` | Missing, invalid, revoked or expired key | no — fix the key | | `422` | Validation failed; see `errors` | no — fix the request | | `429` | Rate or daily limit hit; see `Retry-After` | yes, after the delay | | `502` | The mail server refused the message | **yes** — nothing was sent | | `503` | The API is temporarily switched off | yes, later | Every error uses the same envelope: ```json { "ok": false, "error": "validation_failed", "message": "The request was rejected. See \"errors\" for the fields at fault.", "errors": { "from": ["[ghost@jigsawme.site] is not an active mailbox on this server. The From address must be a mailbox that already exists."] } } ``` `429` responses carry `Retry-After`, and every successful response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. Back off on `429` — retrying immediately just burns the next window. Treat `502` as safe to retry and `422` as a bug in your request. Never retry a `401`. ### What "accepted" means `accepted` means the mail server took the message and owns it now. It does not mean the recipient received it. From that point delivery, retries and bounces are the mail server's job, and a message can still bounce or be filtered afterwards. There is no delivery-status webhook. `GET /emails/{id}` reports what this API knows — whether the message was accepted, and the error if it was not. ## Examples **PHP / Laravel** ```php use Illuminate\Support\Facades\Http; $response = Http::withToken(config('services.email_api.key')) ->withHeaders(['Idempotency-Key' => "otp-{$user->id}-{$attempt}"]) ->acceptJson() ->post('https://jigsawme.site/api/v1/emails', [ 'from' => 'noreply@jigsawme.site', 'from_name' => 'Acme', 'to' => $user->email, 'template' => 'otp', 'data' => ['code' => $code, 'expires_in_minutes' => 10, 'product' => 'Acme'], 'tag' => 'login-otp', ]); // 502 means nothing was sent and a retry is safe; 422 means fix the request. $response->throw(); ``` **Node** ```js const res = await fetch("https://jigsawme.site/api/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.EMAIL_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `reset-${userId}-${Date.now()}`, }, body: JSON.stringify({ from: "noreply@jigsawme.site", to: user.email, template: "password_reset", data: { action_url: resetUrl, expires_in_minutes: 30, product: "Acme" }, }), }); if (!res.ok) throw new Error(`Email API ${res.status}: ${await res.text()}`); ``` **Python** ```python import os, requests r = requests.post( "https://jigsawme.site/api/v1/emails", headers={ "Authorization": f"Bearer {os.environ['EMAIL_API_KEY']}", "Idempotency-Key": f"verify-{user_id}", }, json={ "from": "noreply@jigsawme.site", "to": email, "template": "verify_email", "data": {"action_url": verify_url, "product": "Acme"}, }, timeout=10, ) r.raise_for_status() ``` ## Getting mail delivered The server handles authentication (DKIM signing, SPF-aligned relaying) for you. What is left is on your side: - **Use a purpose-named sender** — `noreply@`, `support@`, `security@` — rather than a personal-looking mailbox. It reads as legitimate and keeps replies somewhere sensible. - **Set `reply_to`** if the `from` mailbox is unmonitored. - **Keep OTP messages short and free of links.** A code plus a link is the shape phishing takes, and filters score it accordingly. - **Send one message per event.** Retrying a failed send is fine; re-sending the same notification because a user refreshed a page is not. - **Use `Idempotency-Key` on anything containing a code or a one-time link.** ## Privacy and security - **No message bodies are stored.** The send log keeps sender, recipients, subject, template, tag and status — enough to answer "did it go out?" without retaining the codes and reset links themselves. There is no resend action, because there is nothing to resend from. - **Recipient addresses are pruned** from the log after 90 days. - **Custom headers are restricted to `X-` names** and stripped of newlines, so a caller cannot set `Bcc`, `From` or `Return-Path` and route around the sender check. - **Your key sees only your own messages.** One integration cannot enumerate another's recipients. - **Rate limits are per key, not per IP**, so a stolen key gains nothing by moving to another host. If a key leaks, tell the operator immediately: it can be revoked in seconds, and the send log shows exactly what went out while it was live.