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
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" }
}'
{
"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:
- The address is inside your key's allowlist.
- 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:
curl https://jigsawme.site/api/v1/ping -H "Authorization: Bearer $EMAIL_API_KEY"
{
"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
{
"from": "billing@jigsawme.site",
"to": "user@example.com",
"subject": "Invoice #4821",
"html": "<h1>Invoice #4821</h1><p>Total: $49.00</p>",
"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:
{
"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
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
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
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_toif thefrommailbox 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-Keyon 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 setBcc,FromorReturn-Pathand 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.
Need a key, or a mailbox created? orders@jigsawme.site
Updated 6 September 2026