For developers & agents

EMAIL API

Send transactional email — one-time codes, password resets, verification links — through the JigsawAcc mail server from your own application. Every message goes out from a mailbox that already exists here, DKIM-signed.

POST https://jigsawme.site/api/v1/emails

Pointing an AI agent at this? Give it https://jigsawme.site/docs/email-api.md — the same document as plain markdown, which is far easier for a model to work from than this page.

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:

  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:

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_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.

Need a key, or a mailbox created? orders@jigsawme.site

Updated 6 September 2026