Fanout API reference
Send personalized email from your own Gmail account with one HTTP request. Base URL: https://fanoutapi.oddete.com
Quick start
- Sign in to the console and open API and account.
- Create a key. It is shown once, so copy it. Keys look like
fnk_.... - Send a request:
curl -X POST 'https://fanoutapi.oddete.com/api/v1/send?wait=25' \
-H 'Authorization: Bearer fnk_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"recipients": [
{"email": "alice@example.com", "name": "Alice", "pin": "4821"},
{"email": "bob@example.com", "name": "Bob", "pin": "9034"}
],
"subject": "Your PIN, {{name}}",
"body": "Hi {{name}}, your PIN is {{pin}}."
}'
Authentication
Send your key in the Authorization header as Bearer fnk_.... Keys act as your Google account for sending and for reading your own usage. Each account can hold up to five keys, and you can revoke any of them in the app. Treat a key like a password. Keys cannot create other keys.
POST /api/v1/send
Sends one personalized email per recipient, as a background job.
| Field | Type | Description |
|---|---|---|
to | string or array | One address or a list. Use this or recipients. |
recipients | array of objects | Each object needs email. Other keys, such as name, become merge fields. |
subject | string | Required, up to 300 characters. Supports {{field}}. |
body | string | Required, up to 200,000 characters. Supports {{field}}. |
is_html | boolean | Default false. When true, merge values are HTML-escaped and a plain-text part is added. |
cc | array of strings | Up to 10 addresses added to every email. Each counts toward your quota. |
dry_run | boolean | Validate and preview without sending or using quota. |
Query parameter wait (0 to 25 seconds): hold the request until the job finishes. A finished job returns 200 with results. A job still running returns 202 and you poll for it.
Every email includes a List-Unsubscribe header that points to your address. Duplicate and invalid addresses are skipped and listed in rejected.
Response
{
"job_id": "9c1f0e7a2b3d4e5f",
"status": "done", // running | done | cancelled | failed
"total": 2, "sent": 2, "failed": 0, "skipped": 0,
"error": "",
"rejected": [],
"results": [ // included when the job has finished
{"email": "alice@example.com", "name": "Alice", "status": "sent"},
{"email": "bob@example.com", "name": "Bob", "status": "sent"}
]
}
Dry run
With "dry_run": true the response reports what would happen: accepted, rejected, units (emails counted, including CC), allowed, retry_after, usage, unknown_variables and a rendered sample of the first email.
Other endpoints
| Endpoint | Description |
|---|---|
GET /api/v1/jobs/{id} | Job status and counts. Add ?results=1 for per-recipient results. Jobs are kept for one hour. |
POST /api/v1/jobs/{id}/cancel | Stop a running job. Unsent emails are refunded. |
GET /api/v1/usage | Your allowance: limit, used, remaining, resets_at (epoch seconds when the oldest counted email ages out). |
GET /api/v1/me | The signed-in account and its usage. |
Limits and quota
| Allowance | 500 emails per rolling 24 hours per account |
|---|---|
| Per request | Up to 500 recipients |
| Counting | Each delivered email counts once, plus one for every CC address. Failed and unsent emails are refunded. |
| Concurrency | One running send per account |
| Request rates | 10 sends per minute, 30 dry runs per minute per account, 120 requests per minute per IP |
Responses include X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset headers. When you exceed a limit you get a 429 with a Retry-After header in seconds.
Errors
| Status | Meaning |
|---|---|
| 400 | Invalid request, no valid recipients, or more than 500 recipients. |
| 401 | Missing or invalid key, or Google access was revoked. Sign in to the app again. |
| 403 | The endpoint needs a browser session, not an API key. |
| 404 | Job not found or expired. |
| 409 | A send is already running for your account. |
| 429 | Request rate limit, or "error": "quota_exceeded" with needed, remaining and retry_after. |
| 503 | The server is busy. Retry after the Retry-After seconds. |
Error bodies look like {"detail": "message"}.
Examples
Python
import requests
r = requests.post(
"https://fanoutapi.oddete.com/api/v1/send?wait=25",
headers={"Authorization": "Bearer fnk_YOUR_KEY"},
json={"to": "alice@example.com", "subject": "Hello", "body": "Hi there"},
timeout=60,
)
print(r.status_code, r.json())
Node.js
const res = await fetch("https://fanoutapi.oddete.com/api/v1/send?wait=25", {
method: "POST",
headers: { "Authorization": "Bearer fnk_YOUR_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ to: "alice@example.com", subject: "Hello", body: "Hi there" }),
});
console.log(res.status, await res.json());
Polling a long send
JOB=$(curl -s -X POST 'https://fanoutapi.oddete.com/api/v1/send' -H 'Authorization: Bearer fnk_YOUR_KEY' \
-H 'Content-Type: application/json' -d @payload.json | jq -r .job_id)
curl -s 'https://fanoutapi.oddete.com/api/v1/jobs/'$JOB'?results=1' -H 'Authorization: Bearer fnk_YOUR_KEY'
Frequently asked questions
Can I call the API from a browser?
The API is meant for servers and scripts. Browser requests are only allowed from the Fanout app itself, so keep your key out of public web pages.
Does the API use the same quota as the app?
Yes. The app and the API share one allowance of 500 emails per rolling 24 hours per account.
How do I revoke an API key?
Open the API and account tab in the app and press Revoke next to the key. It stops working immediately.