Developer API
Create disposable addresses and read incoming mail and verification codes from your own code.
Getting started
The API speaks JSON over HTTPS. Every response has a success flag and either data or an error object with a machine-readable code.
Base URL: https://edumail.to/api/v1
curl -X POST https://edumail.to/api/v1/inbox \
-H "X-API-Key: $POSTINBOX_KEY"
# wait for mail, then:
curl "https://edumail.to/api/v1/messages?email=ADDRESS" \
-H "X-API-Key: $POSTINBOX_KEY"Authentication
Create a key in your dashboard under API, then send it with every request in the X-API-Key header. Bearer authentication works too. Treat the key like a password; you can replace or revoke it at any time.
X-API-Key: pim_live_3f9c…
# or
Authorization: Bearer pim_live_3f9c…Access
The API unlocks with your first wallet top-up of at least $2.00. Until then every endpoint answers 403:
{
"success": false,
"error": {
"code": "API_LOCKED",
"message": "First deposit of minimum $2.00 required to activate API access"
}
}Billing
Creating an address on a free domain costs nothing. On a premium domain, such as diplo.edu.pl at $0.20, the price is deducted from your wallet in the same step that creates the address. Responses include the charge and your new balance. Without enough balance the request fails with 402 and nothing is charged.
Rate limits
Each account has a bucket of 120 requests that refills at 60 requests per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. When the bucket is empty you get 429 with a Retry-After header.
Errors
Errors use standard HTTP status codes plus an error.code you can branch on.
| Parameter | Type | Description |
|---|---|---|
| 401 MISSING_API_KEY / INVALID_API_KEY | auth | The key is missing, wrong or revoked. |
| 402 INSUFFICIENT_FUNDS | billing | Your balance doesn't cover the domain price. |
| 403 API_LOCKED | access | The API is still locked; make your first top-up. |
| 403 ACCOUNT_SUSPENDED | access | The account is suspended or banned. |
| 404 *_NOT_FOUND | lookup | The inbox, message or file doesn't exist, has expired or isn't yours. |
| 409 ADDRESS_TAKEN | create | The address is already in use. |
| 422 INVALID_ALIAS | create | The address name breaks the domain's rules. |
| 429 RATE_LIMITED | limits | Too many requests; wait for Retry-After seconds. |
Inboxes
POST/inbox
Creates an address. Without a domain, the highest-priority free domain is used, so a missing parameter never costs money.
| Parameter | Type | Description |
|---|---|---|
| domain | string | Domain name to use, from GET /domains. |
| domain_id | integer | Alternative to domain. |
| alias | string | Address name. Omit it for a random one. |
curl -X POST https://edumail.to/api/v1/inbox \
-H "X-API-Key: $POSTINBOX_KEY" \
-H "Content-Type: application/json" \
-d '{"domain":"diplo.edu.pl","alias":"signup.test"}'{
"success": true,
"data": {
"id": 1842,
"email": "signup.test@diplo.edu.pl",
"domain": "diplo.edu.pl",
"pool": "pro",
"is_custom": true,
"source": "api",
"message_count": 0,
"last_message_at": null,
"created_at": "2026-10-21T09:12:44Z",
"expires_at": "2026-10-28T09:12:44Z",
"billing": {
"charged": true,
"price": "0.20",
"balance_before": "2.00",
"balance_after": "1.80"
},
"balance": "1.80"
}
}GET/inbox?email=…
Returns one of your active inboxes by email (or id).
DELETE/inbox?email=…
Deletes the inbox with all its messages and attachments.
GET/inboxes
Lists your active inboxes, newest first.
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number, starting at 1. |
| per_page | integer | Items per page, up to 100. |
GET/domains
Lists the domains you can use, with their price per address and name rules. The meta object includes your balance.
{
"success": true,
"data": [
{
"id": 2,
"domain": "diplo.edu.pl",
"pool": "pro",
"price": "0.20",
"tag": "pro",
"custom_alias": true,
"alias_min_length": 4,
"alias_max_length": 32
},
{
"id": 1,
"domain": "mailfus.de",
"pool": "free",
"price": "0.00",
"tag": null,
"custom_alias": true,
"alias_min_length": 4,
"alias_max_length": 32
}
],
"meta": {
"balance": "1.80"
}
}GET/me
Returns your balance, total top-ups and spending, the number of addresses you've created and your rate limit.
Messages
GET/messages?email=…
Lists messages in an inbox, newest first. Poll with since_id set to the latest id you've seen to receive only new mail.
| Parameter | Type | Description |
|---|---|---|
| string | The inbox address. | |
| since_id | integer | Only return messages with a higher id. |
| limit | integer | Up to 100 messages; default 50. |
| unread | boolean | Set to 1 for unread messages only. |
{
"success": true,
"data": [
{
"id": 99812,
"inbox": "signup.test@diplo.edu.pl",
"from": {
"email": "no-reply@acme.example",
"name": "Acme"
},
"subject": "Your Acme verification code",
"snippet": "Your code is 482913. It expires in 10 minutes.",
"otp_code": "482913",
"magic_link": null,
"attachment_count": 0,
"size": 4812,
"is_read": false,
"received_at": "2026-10-21T09:13:02Z"
}
],
"meta": {
"inbox": "signup.test@diplo.edu.pl",
"expires_at": "2026-10-28T09:12:44Z",
"count": 1,
"latest_id": 99812
}
}GET/message?id=…
Returns one message with its plain text, sanitised HTML with masked links, the detected OTP code and magic link, attachments and signed download links. Reading a message marks it as read.
DELETE/message?id=…
Deletes a message and its attachments.
Downloads
Download links in message responses are signed and work for one hour without the API key, so you can pass them to other tools. With your key you can also call these endpoints directly.
| Parameter | Type | Description |
|---|---|---|
| GET /attachment?id=… | file | One attachment, by attachment id. |
| GET /raw?id=… | .eml | The original message, by message id. |
| GET /attachments-zip?id=… | .zip | All attachments of a message as a ZIP file. |
Webhooks
Turn on webhooks in your dashboard to receive a JSON POST for each new message. The payload contains the inbox, sender, subject, snippet, OTP code, magic link, text and attachment list.
POST /your/endpoint
Content-Type: application/json
X-PostInbox-Event: message.received
X-PostInbox-Delivery: 5521
X-PostInbox-Timestamp: 1792662782
X-PostInbox-Signature: sha256=8f3c…Each request is signed with your signing secret. Compute an HMAC-SHA256 of the timestamp, a dot and the raw body, compare it with the signature header and reject requests older than five minutes:
<?php
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_POSTINBOX_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_POSTINBOX_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('POSTINBOX_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// $event['otp_code'], $event['inbox'], $event['subject'] ...
http_response_code(204);import crypto from "node:crypto";
export function verify(rawBody, headers, secret) {
const ts = headers["x-postinbox-timestamp"];
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = headers["x-postinbox-signature"] || "";
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}Answer with any 2xx status. Failed deliveries are retried after 1, 5 and 15 minutes, 1 hour and 6 hours. A 4xx answer (other than 408 or 429) stops the retries.