Fanout API reference

Send personalized email from your own Gmail account with one HTTP request. Base URL: https://fanoutapi.oddete.com

Quick start

  1. Sign in to the console and open API and account.
  2. Create a key. It is shown once, so copy it. Keys look like fnk_....
  3. 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.

FieldTypeDescription
tostring or arrayOne address or a list. Use this or recipients.
recipientsarray of objectsEach object needs email. Other keys, such as name, become merge fields.
subjectstringRequired, up to 300 characters. Supports {{field}}.
bodystringRequired, up to 200,000 characters. Supports {{field}}.
is_htmlbooleanDefault false. When true, merge values are HTML-escaped and a plain-text part is added.
ccarray of stringsUp to 10 addresses added to every email. Each counts toward your quota.
dry_runbooleanValidate 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

EndpointDescription
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}/cancelStop a running job. Unsent emails are refunded.
GET /api/v1/usageYour allowance: limit, used, remaining, resets_at (epoch seconds when the oldest counted email ages out).
GET /api/v1/meThe signed-in account and its usage.

Limits and quota

Allowance500 emails per rolling 24 hours per account
Per requestUp to 500 recipients
CountingEach delivered email counts once, plus one for every CC address. Failed and unsent emails are refunded.
ConcurrencyOne running send per account
Request rates10 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

StatusMeaning
400Invalid request, no valid recipients, or more than 500 recipients.
401Missing or invalid key, or Google access was revoked. Sign in to the app again.
403The endpoint needs a browser session, not an API key.
404Job not found or expired.
409A send is already running for your account.
429Request rate limit, or "error": "quota_exceeded" with needed, remaining and retry_after.
503The 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.