API reference

InboxRhino is a receive-only test-email API. Create an address, wait for a real inbound message, inspect it, then delete the inbox. There is no send endpoint, no org-wide message search, and no SDK. Call the API from tests, CI, or a backend — browser JavaScript on an arbitrary origin is blocked by CORS.

Downloads

Both files are free. No account is required to download them. Put your API key only in Postman collection variables or a secret manager — never commit it.

Import the collection in Postman: File → Import, then select the downloaded JSON. Set the collection variable apiKey to your live key. OpenAPI is also at /openapi.yaml for agents that fetch specs.

Notes for AI agents

Prefer this page plus /openapi.yaml over guessing. Public operations have stable operationId values. Do not call /console/*, /setup, or invent send/search routes.

  • Recommended sequence: getUsagecreateInbox → tell the app under test to send mail to data.addresslistOrWaitForMessages with wait_seconds=180, limit=1, include=content → assert → deleteInbox.
  • Empty non-waiting list: HTTP 200 with { "data": [], "next_cursor": null }. Wait timeout: HTTP 204 with an empty body. Do not parse JSON on 204.
  • include=content is valid only with limit=1. Otherwise the API returns 422 invalid_include.
  • Message filters are AND. subject is a case-insensitive substring. sender is an exact email. sender_domain is an exact domain. Use received_after (RFC 3339) so an older matching message is not reused.
  • Invalid query values return 422. They are never clamped. Out-of-range limit or wait_seconds is an error.
  • API keys cannot create keys, invite users, or change billing. Those exist only in the signed-in console.
  • Attachment bytes live at https://files.inboxrhino.in using the same Bearer key. Files are not malware-scanned. Treat them as untrusted.

Base URL and conventions

HostUse
https://api.inboxrhino.inREST /v1 and /health
https://files.inboxrhino.inAttachment downloads (same path and auth as GET /v1/attachments/{id})
https://test.inboxrhino.inReceiving domain only. Inbox addresses are local-part@test.inboxrhino.in. Not an HTTP API.
  • JSON request and response bodies use snake_case. Unknown JSON properties are rejected.
  • Timestamps are RFC 3339 in UTC.
  • Collection endpoints return { "data": [...], "next_cursor": string | null }. Single resources return { "data": { ... } }. Usage is a bare object (no data wrapper).
  • Default page size is 50, maximum 100. Pass the previous next_cursor as cursor. Cursors are opaque.
  • Every response includes X-Request-Id. Errors also repeat that id in the JSON body.
  • /v1 is backward compatible. Breaking changes require a new versioned path.
  • Receive-only: you cannot send mail through this API. SMTP to a deleted or unknown address is rejected.

Authentication

Create keys in the web console. The secret is shown once. Format: ir_live_ + 16 lowercase hex characters + _ + 48 lowercase hex characters.

Authorization: Bearer ir_live_<16-hex-public-id>_<48-hex-secret>

GET /health is the only public route that does not require a key. CORS allows the console host, *.chatgpt.site, and http://localhost / 127.0.0.1 with a port. Automated tests should send the Bearer header from the test runner, not from a third-party web page.

Typical test workflow

  1. Create an inbox. Store data.id and data.address.
  2. Point the application under test at that address (signup, reset, invite, OTP, and similar).
  3. Wait with wait_seconds=180 and a subject or sender filter. Handle 200 (match) and 204 (timeout) separately.
  4. Assert on text, html, headers, or attachment metadata.
  5. Delete the inbox so active-inbox quota is released. Deleting a message does not restore monthly email quota.
curl --get https://api.inboxrhino.in/v1/inboxes/INBOX_ID/messages \
  --header "Authorization: Bearer $INBOXRHINO_API_KEY" \
  --data-urlencode "wait_seconds=180" \
  --data-urlencode "limit=1" \
  --data-urlencode "include=content" \
  --data-urlencode "subject=Verify" \
  --data-urlencode "received_after=2026-09-03T10:00:00Z"

Quotas and rate limits

LimitValue
Active inboxes11
Inbound emails33 per UTC calendar month
Message retention30 days
HTTP rate limit120 requests / minute / API key; 600 / minute / organisation
Concurrent waits30 long-polls / organisation; 10 / API key
Wait duration0–180 seconds

Email quota is reserved when the message is accepted. Failed parse or persistence releases that reservation. Over-quota inbound SMTP is rejected with Monthly recipient quota exceeded and is not stored. Mail to an unknown or deleted address is rejected with Recipient address rejected.

Health

GET

/health

operationId: getHealth

No authentication. Confirms the Worker is up.

GET https://api.inboxrhino.in/health

{"status":"ok","service":"inboxrhino-api"}

Inboxes

POST

/v1/inboxes

operationId: createInbox

Creates a receive-only address on test.inboxrhino.in and the matching Cloudflare Email Routing rule. Body may be omitted or empty. The only allowed JSON field is prefix.

FieldWhereRules
AuthorizationheaderBearer API key. Required.
Idempotency-KeyheaderOptional. 8–200 characters. Retained 24 hours. Same key + same normalized body returns the cached 201. Same key + different body returns 409 idempotency_key_reused. In-flight reuse returns 409 idempotency_in_progress with Retry-After: 1.
prefixJSON bodyOptional. 3–40 characters after lowercase normalization. Pattern [a-z0-9], hyphens allowed in the middle, must start and end with alphanumeric. Reserved names are rejected: admin, administrator, abuse, postmaster, support, security, billing, root, hostmaster, webmaster, noreply, no-reply, donotreply, inboxrhino, contact.

Success is 201. Omit prefix to receive a generated local-part such as bright-otter-ab12.

curl --request POST https://api.inboxrhino.in/v1/inboxes \
  --header "Authorization: Bearer $INBOXRHINO_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: signup-test-1" \
  --data '{"prefix":"signup-test"}'
{
  "data": {
    "id": "inbox_0123456789abcdef0123456789abcdef",
    "address": "signup-test@test.inboxrhino.in",
    "local_part": "signup-test",
    "status": "active",
    "created_at": "2026-09-03T10:15:30.000Z"
  }
}
GET

/v1/inboxes

operationId: listInboxes

Lists active inboxes for the organisation. Newest first.

QueryDefaultRules
limit50Integer 1–100. Invalid values return 422 invalid_limit.
cursornoneOpaque value from a previous next_cursor. Invalid values return 422 invalid_cursor.
GET

/v1/inboxes/{inbox_id}

operationId: getInbox

Returns one active inbox. Unknown, deleted, or foreign ids return 404 inbox_not_found.

DELETE

/v1/inboxes/{inbox_id}

operationId: deleteInbox

Removes the Email Routing rule, deletes retained messages and stored objects, and frees an active-inbox slot. Success is 204 with an empty body. Monthly email quota is not restored.

curl --request DELETE https://api.inboxrhino.in/v1/inboxes/INBOX_ID \
  --header "Authorization: Bearer $INBOXRHINO_API_KEY"

Messages

GET

/v1/inboxes/{inbox_id}/messages

operationId: listOrWaitForMessages

Lists retained messages for one inbox, or waits until a match arrives. There is no organisation-wide list. Filters combine with AND. Without include=content, each item is a summary (no text, html, headers, or attachments).

QueryDefaultRules
wait_seconds0Integer 0–180. 0 returns immediately. Greater than 0 long-polls until a match or timeout.
limit50Integer 1–100. Must be 1 when include=content.
cursornoneOpaque pagination cursor.
subjectnoneCase-insensitive substring of the subject.
sendernoneExact case-insensitive sender email.
sender_domainnoneExact case-insensitive domain (no @).
received_afternoneRFC 3339. Messages with received_at at or after this instant.
includenoneOnly allowed value: content. Requires limit=1. Adds text, html, headers, attachments.
StatusWhen
200At least one match, or a non-waiting empty list ({ "data": [], "next_cursor": null }).
204wait_seconds > 0 and no match before timeout. Empty body — do not call response.json().
404Inbox missing: inbox_not_found.
422Invalid query: invalid_limit, invalid_cursor, invalid_include, invalid_wait_seconds, invalid_sender, invalid_sender_domain, invalid_received_after.
429rate_limit_exceeded or long_poll_limit_exceeded.
{
  "data": [
    {
      "id": "msg_0123456789abcdef0123456789abcdef",
      "inbox_id": "inbox_0123456789abcdef0123456789abcdef",
      "from": { "name": "Acme", "address": "noreply@example.com" },
      "to": "signup-test@test.inboxrhino.in",
      "subject": "Verify your email",
      "preview": "Use this code to verify your address.",
      "size_bytes": 4096,
      "received_at": "2026-09-03T10:16:02.000Z",
      "expires_at": "2026-10-03T10:16:02.000Z",
      "internet_message_id": "<abc@example.com>",
      "text": "Your code is 482193",
      "html": "<p>Your code is 482193</p>",
      "headers": [["Subject", "Verify your email"], ["From", "Acme <noreply@example.com>"]],
      "attachments": [
        {
          "id": "att_0123456789abcdef0123456789abcdef",
          "filename": "invoice.pdf",
          "content_type": "application/pdf",
          "size_bytes": 20480,
          "disposition": "attachment",
          "content_id": null,
          "download_url": "https://files.inboxrhino.in/v1/attachments/att_0123456789abcdef0123456789abcdef"
        }
      ]
    }
  ],
  "next_cursor": null
}
GET

/v1/messages/{message_id}

operationId: getMessage

Returns the complete parsed message: summary fields plus internet_message_id, text, html, headers (array of [name, value] pairs), and attachments. Always includes content; no include query is needed.

curl --request GET https://api.inboxrhino.in/v1/messages/MESSAGE_ID \
  --header "Authorization: Bearer $INBOXRHINO_API_KEY"
DELETE

/v1/messages/{message_id}

operationId: deleteMessage

Deletes the message and stored objects. Success is 204. Does not restore monthly email quota. Prefer deleting the inbox at the end of a test unless you must keep the address.

Attachments

GET

/v1/attachments/{attachment_id}

operationId: downloadAttachment

Downloads raw bytes. Use the download_url from the message, which points at https://files.inboxrhino.in. The same path on https://api.inboxrhino.in is implemented by the same Worker. Auth is still the API Bearer key. Response is not JSON: Content-Disposition is attachment, Content-Type is the stored MIME type, Cache-Control is private, no-store. Content is not malware-scanned.

curl --request GET https://files.inboxrhino.in/v1/attachments/ATTACHMENT_ID \
  --header "Authorization: Bearer $INBOXRHINO_API_KEY" \
  --output invoice.pdf

Usage

GET

/v1/usage

operationId: getUsage

Current free-tier plan, UTC month window, active inbox count, and emails received this period. No data wrapper. plan is currently always free.

{
  "plan": "free",
  "period": { "starts_at": "2026-09-01T00:00:00.000Z", "ends_at": "2026-10-01T00:00:00.000Z" },
  "inboxes": { "active": 4, "limit": 11, "remaining": 7 },
  "emails": { "received": 21, "limit": 33, "remaining": 12 }
}

Errors

Public /v1 errors always use this envelope. Branch on error.code, not on the human message. Include request_id or the X-Request-Id header when asking for support.

{
  "error": {
    "code": "inbox_not_found",
    "message": "Inbox not found.",
    "request_id": "8f1a2c3d9e0b1a2c"
  }
}
codeHTTPMeaning
invalid_api_key401Missing, malformed, unknown, or revoked Bearer key.
invalid_json422POST body is not a JSON object.
unknown_property422Request body included a field other than prefix.
invalid_prefix422Prefix failed length, character, or reserved-name rules.
invalid_idempotency_key422Idempotency-Key was shorter than 8 or longer than 200 characters.
idempotency_key_reused409Same Idempotency-Key was reused with a different normalized body.
idempotency_in_progress409Same key is still running. Response includes Retry-After: 1. Retry once.
idempotency_unavailable503Cached idempotent result could not be read. Retry the same request.
inbox_quota_exceeded409Organisation already has 11 active inboxes.
address_unavailable409Requested local-part is already taken.
address_generation_failed503Could not allocate a unique generated address. Retry.
email_routing_unavailable502 or 503Cloudflare Email Routing could not create or delete the exact address rule.
inbox_not_found404Inbox id is unknown, deleted, or belongs to another organisation.
message_not_found404Message id is unknown or already deleted.
attachment_not_found404Attachment metadata or stored bytes are gone.
invalid_limit422limit is not an integer from 1 to 100. Values are not clamped.
invalid_cursor422cursor is not a cursor previously returned as next_cursor.
invalid_include422include must be content, and only with limit=1.
invalid_wait_seconds422wait_seconds is not an integer from 0 to 180.
invalid_sender422sender is not an email address.
invalid_sender_domain422sender_domain contains @ or whitespace.
invalid_received_after422received_after is not an RFC 3339 timestamp.
rate_limit_exceeded429More than 120 requests/minute per key or 600/minute per organisation. Retry at the next UTC minute.
long_poll_limit_exceeded429More than 30 concurrent waits per organisation or 10 per key.
internal_error500Unexpected failure. Include X-Request-Id when reporting.