Call this from the server side of your website to read mail that arrives on your OnionMail addresses. Each message tells you which address received it, who sent it, the subject, and the body.
ONIONMAIL_API_KEY. Leave it out of pages, apps, and public repositories.GET https://onionmail.su/v1/emails with the key in the Authorization header.id you have handled and send it as since_id on the next poll.curl -s \ -H "Authorization: Bearer YOUR_KEY" \ "https://onionmail.su/v1/emails?limit=20"
Every /v1/ request needs the key. Send it as a header.
Authorization: Bearer YOUR_KEY
X-API-Key: YOUR_KEY is the same thing. ?api_key=YOUR_KEY also works, and the key is then stored in access logs. Use the header.
The base URL is https://onionmail.su/v1. Responses are JSON, Cache-Control: no-store. A browser on another origin can call the API (Access-Control-Allow-Origin: *). Put the key on your server anyway, because anyone who sees it can read the inbox.
A valid key may make 120 requests a minute. Thirty failed checks from the same IP in a minute, including requests with no key, get 429 until the minute passes.
Save the largest id you have already processed. The next request is:
GET https://onionmail.su/v1/emails?since_id=1042&limit=100
since_id returns messages whose id is greater than that number. The list is newest first. After you handle the page, store the greatest id in it and use that value next time.
Use limit=100. If has_more is true, more than limit messages matched and this page is only the newest of them. Poll about once a second so a single response stays under 100 new messages and has_more stays false. One poll a second is 60 requests a minute, under the limit of 120.
Read text when you want the words (a code, a link). Read body when you want the message as it arrived. to is the address on your account that received it.
GET https://onionmail.su/v1/emails
| Query | Meaning |
|---|---|
limit | How many messages to return. Default 50. Minimum 1, maximum 100. Newest first. |
since_id | Only messages with an id greater than this integer. |
since | Only messages received after this Unix time. Compared with received_at. |
to | Only mail sent to this address. It must be one of yours, or the API returns address_not_owned. |
unread=1 | Only messages still unread in the inbox. true and yes work too. |
mark_read=1 | Mark the messages in this response as read. Leave it off to keep the inbox badges unchanged. |
{
"ok": true,
"addresses": ["you@your-domain"],
"count": 1,
"has_more": false,
"emails": [
{
"id": 1042,
"to": "you@your-domain",
"from": "noreply@example.com",
"subject": "Your verification code",
"body": "Your code is 482913",
"text": "Your code is 482913",
"is_html": false,
"body_truncated": false,
"received_at": 1710000000,
"received_at_iso": "2024-03-09T16:00:00Z",
"is_read": false,
"attachments": [
{"id": 9, "filename": "code.txt", "content_type": "text/plain", "size": 18}
]
}
]
}
addresses is the set that was searched. With to set, that array holds just that address. count is how many messages are in this response. has_more means another matched message did not fit in limit.
GET https://onionmail.su/v1/emails/<id>
Same key. Optional mark_read=1. The message is under email and uses the same fields as a list item. A missing id, or a message on someone else’s address, is 404 with not_found.
{
"ok": true,
"email": {
"id": 1042,
"to": "you@your-domain",
"from": "noreply@example.com",
"subject": "Your verification code",
"body": "Your code is 482913",
"text": "Your code is 482913",
"is_html": false,
"body_truncated": false,
"received_at": 1710000000,
"received_at_iso": "2024-03-09T16:00:00Z",
"is_read": false,
"attachments": []
}
}
GET https://onionmail.su/v1/emails/<id>/attachments/<attachment_id>
Same key. The response is the file itself, with that file’s Content-Type and a Content-Disposition filename. It is not JSON. Add ?inline=1 to display an image. A wrong id is 404 not_found.
curl -sL \ -H "Authorization: Bearer YOUR_KEY" \ -o code.txt \ "https://onionmail.su/v1/emails/1042/attachments/9"
GET https://onionmail.su/v1/addresses
The addresses this key can read: the account’s throwaway address, when it has one, and any custom addresses.
{
"ok": true,
"addresses": ["you@your-domain", "alias@your-domain"]
}
| Field | Meaning |
|---|---|
id | Stable integer. Increases as mail arrives. Use it as since_id. |
to | The address on your account that received the message. |
from | Sender. Empty string when the message had none. |
subject | Subject. Empty string when there was none. |
body | The message as it was stored, HTML included when the sender sent HTML. |
text | Plain text. For HTML mail, tags are removed and <script> and <style> are dropped. For a plain message, this matches body. |
is_html | true when body is HTML. |
body_truncated | true when body or text was cut at 200,000 characters. |
received_at | Unix time, seconds. |
received_at_iso | The same instant in UTC, 2024-03-09T16:00:00Z. |
is_read | Whether the inbox has marked it read. mark_read=1 makes this true for messages in that response. |
attachments | id, filename, content_type, and size in bytes. The bytes themselves are a separate request. |
Failures are JSON: {"ok": false, "error": "invalid_api_key"}. A missing message does not reveal whether the id exists on another account.
| Status | error | When |
|---|---|---|
| 401 | missing_api_key | No key was sent. |
| 401 | invalid_api_key | The key does not match an account. Replacing the key on the API tab makes the old one fail this way. |
| 400 | address_not_owned | to is not an address on this account. |
| 400 | invalid_limit | limit is not an integer from 1 to 100. |
| 400 | invalid_since | since is not an integer. |
| 400 | invalid_since_id | since_id is not an integer. |
| 404 | not_found | That message or attachment is not on this account. |
| 429 | rate_limited | Over 120 requests in a minute for this key, or 30 failed checks from this IP in a minute. |
Both snippets poll once, handle new mail oldest-first, and remember the latest id. Run them on a timer of about one second. Point ONIONMAIL_API_KEY at the key from the API tab.
<?php
$apiKey = getenv('ONIONMAIL_API_KEY');
$base = 'https://onionmail.su';
$state = __DIR__ . '/onionmail-since.txt';
$sinceId = is_file($state) ? (int) file_get_contents($state) : 0;
$ch = curl_init($base . '/v1/emails?since_id=' . $sinceId . '&limit=100');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($raw, true);
if ($code !== 200 || empty($data['ok'])) {
error_log('OnionMail ' . $code . ' ' . ($data['error'] ?? ''));
exit(1);
}
$maxId = $sinceId;
foreach (array_reverse($data['emails']) as $email) {
$maxId = max($maxId, (int) $email['id']);
// $email['to'], $email['from'], $email['subject'], $email['text']
// Download: GET /v1/emails/{id}/attachments/{attachment id}
}
file_put_contents($state, (string) $maxId);
import { readFile, writeFile } from "node:fs/promises";
const apiKey = process.env.ONIONMAIL_API_KEY;
const base = "https://onionmail.su";
const state = "onionmail-since.txt";
let sinceId = 0;
try { sinceId = Number(await readFile(state, "utf8")) || 0; } catch {}
const res = await fetch(`${base}/v1/emails?since_id=${sinceId}&limit=100`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(`OnionMail ${res.status} ${data.error || ""}`);
}
let maxId = sinceId;
for (const email of [...data.emails].reverse()) {
maxId = Math.max(maxId, email.id);
// email.to, email.from, email.subject, email.text
}
await writeFile(state, String(maxId));
On the API tab, New key replaces the key immediately. Update ONIONMAIL_API_KEY when you do that. Requests still using the previous key receive invalid_api_key.