Skip to content
PostInbox Mail

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.

ParameterTypeDescription
401 MISSING_API_KEY / INVALID_API_KEYauthThe key is missing, wrong or revoked.
402 INSUFFICIENT_FUNDSbillingYour balance doesn't cover the domain price.
403 API_LOCKEDaccessThe API is still locked; make your first top-up.
403 ACCOUNT_SUSPENDEDaccessThe account is suspended or banned.
404 *_NOT_FOUNDlookupThe inbox, message or file doesn't exist, has expired or isn't yours.
409 ADDRESS_TAKENcreateThe address is already in use.
422 INVALID_ALIAScreateThe address name breaks the domain's rules.
429 RATE_LIMITEDlimitsToo 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.

ParameterTypeDescription
domainstringDomain name to use, from GET /domains.
domain_idintegerAlternative to domain.
aliasstringAddress 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.

ParameterTypeDescription
pageintegerPage number, starting at 1.
per_pageintegerItems 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.

ParameterTypeDescription
emailstringThe inbox address.
since_idintegerOnly return messages with a higher id.
limitintegerUp to 100 messages; default 50.
unreadbooleanSet 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.

ParameterTypeDescription
GET /attachment?id=…fileOne attachment, by attachment id.
GET /raw?id=….emlThe original message, by message id.
GET /attachments-zip?id=….zipAll 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.