Exende Docs

Test Inbox

Test Inbox

A private, temporary inbox for signup, password-reset, and OTP tests. Create once through x402, then read, wait, recover, or delete with the access token you generated before payment.

Updated October 20, 2026

Available. The receiving service is activated. The examples below are integration examples, not evidence of live email delivery.

A recoverable TypeScript quickstart

Use Node.js 22 or later. Install the pinned x402 client versions below. Save the TypeScript example as test-inbox.ts. Configure X402_PRIVATE_KEY through your secret environment for a wallet holding USDC on Base. The create command authorizes a $0.02 payment.

bash

Pinned client dependencies

npm install --save-exact @x402/fetch@2.23.0 @x402/evm@2.23.0 viem@2.55.19\nnpm install --save-dev --save-exact tsx@4.23.12

Set EXPECTED_FROM to your application’s exact SMTP envelope sender. Optionally set SUBJECT_CONTAINS and LINK_HOST. The script creates a private state file before payment, checks recovery first, and preserves the same request across retries. Add .private/ to your .gitignore; keep the token and received OTPs out of shared logs.

typescript

test-inbox.ts · create, recover, read, wait, delete

import { randomBytes } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { registerExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";

const base = "https://api.exende.dev";
const stateFile = ".private/test-inbox.json";
type RequestBody = {
  access_token: string;
  expected_from: string;
  subject_contains?: string;
  otp_length: number;
  link_host?: string;
};
type Inbox = {
  id: string; address: string; expires_at: string;
  messages_url: string; wait_url: string;
};

async function main() {
  const mode = process.argv[2] ?? "create";
  if (!["create", "read", "wait", "delete"].includes(mode)) {
    throw new Error("Use create, read, wait, or delete");
  }
  let body: RequestBody;
  try {
    body = JSON.parse(await readFile(stateFile, "utf8"));
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
    if (mode !== "create" || !process.env.EXPECTED_FROM) {
      throw new Error("Set EXPECTED_FROM to your app's sender mailbox for a new inbox");
    }
    body = {
      access_token: randomBytes(32).toString("hex"),
      expected_from: process.env.EXPECTED_FROM,
      subject_contains: process.env.SUBJECT_CONTAINS || undefined,
      otp_length: 6,
      link_host: process.env.LINK_HOST || undefined,
    };
    await mkdir(".private", { recursive: true, mode: 0o700 });
    // Save the token AND exact body before authorizing any payment.
    await writeFile(stateFile, JSON.stringify(body), { flag: "wx", mode: 0o600 });
  }
  const headers = { Authorization: "Bearer " + body.access_token };
  let response = await fetch(base + "/v1/inboxes/lookup", { headers });
  if (response.status === 404 && mode === "create") {
    const key = process.env.X402_PRIVATE_KEY;
    if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
      throw new Error("Provide X402_PRIVATE_KEY through your secret environment");
    }
    const client = new x402Client();
    registerExactEvmScheme(client, {
      signer: privateKeyToAccount(key as import("viem").Hex),
      networks: ["eip155:8453"],
    });
    client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
      if (selectedRequirements.network !== "eip155:8453" ||
          selectedRequirements.scheme !== "exact" ||
          selectedRequirements.amount !== "20000" ||
          selectedRequirements.asset.toLowerCase() !==
            "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913") {
        return { abort: true, reason: "Unexpected Test Inbox payment quote" };
      }
    });
    const paidFetch = wrapFetchWithPayment(fetch, client);
    response = await paidFetch(base + "/v1/inboxes", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
  }
  if (!response.ok) throw new Error("Inbox request returned HTTP " + response.status);
  const inbox = await response.json() as Inbox;
  if (mode === "create") {
    // Give this address to your own app's signup/reset flow.
    console.log({ id: inbox.id, address: inbox.address, expires_at: inbox.expires_at });
    return;
  }
  const path = "/v1/inboxes/" + encodeURIComponent(inbox.id);
  const after = process.env.AFTER_MESSAGE_ID;
  const suffix = mode === "read" ? "/messages" : mode === "wait"
    ? "/wait?timeout=60" + (after ? "&after=" + encodeURIComponent(after) : "") : "";
  response = await fetch(base + path + suffix, {
    method: mode === "delete" ? "DELETE" : "GET", headers,
  });
  if (!response.ok) throw new Error("Inbox operation returned HTTP " + response.status);
  console.log(response.status === 204 ? "No content" : await response.text());
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : "Inbox request failed");
  console.error("Keep .private/test-inbox.json. Retry with the same file; do not generate another token.");
  process.exitCode = 1;
});

bash

Run each step when your test is ready

npx tsx test-inbox.ts create\n# Trigger your own app's email flow with the returned address.\nnpx tsx test-inbox.ts wait\nnpx tsx test-inbox.ts read\nnpx tsx test-inbox.ts delete

One state file represents one test inbox. Use an isolated working directory per parallel test. To start a new test, archive or remove the old state after the inbox is no longer needed. Never replace the token to recover an uncertain payment outcome.

Create an inbox · $0.02 USDC

POST https://api.exende.dev/v1/inboxes accepts JSON. A new inbox requires x402 v2 exact payment on Base (eip155:8453). The price covers its lifetime, up to three accepted messages, reads, waits, recovery, and deletion. No Exende account or subscription is required.

FieldContract
access_tokenRequired. 64 lowercase hexadecimal characters from 32 cryptographically random bytes. Generate and save on the client before paying.
expected_fromRequired. Exact SMTP envelope sender mailbox, compared without case sensitivity. This is not the display From header.
subject_containsOptional case-insensitive subject substring filter, 1–120 printable characters.
otp_lengthOptional integer from 4 to 8; defaults to 6.
link_hostOptional exact DNS hostname without a scheme, port, wildcard, or path. Required to return a verification_url; only HTTPS links on that host qualify.

Read the current PAYMENT-REQUIRED challenge for recipient and settlement requirements. The client sends its proof in PAYMENT-SIGNATURE; a settled response carries PAYMENT-RESPONSE. Preserve your receipt when diagnosing an uncertain outcome.

json

Illustrative inbox metadata · synthetic identifiers and address

{
  "id": "ib_0000000000000000000000000000000000000000",
  "address": "inbox+0000000000000000000000000000000000000000@test.example.com",
  "expires_at": "2026-10-21T12:00:00.000Z",
  "messages_url": "https://api.exende.dev/v1/inboxes/ib_0000000000000000000000000000000000000000/messages",
  "wait_url": "https://api.exende.dev/v1/inboxes/ib_0000000000000000000000000000000000000000/wait",
  "limits": {
    "messages": 3,
    "message_bytes": 1048576,
    "ttl_seconds": 86400,
    "wait_seconds": 60
  },
  "status": "active"
}

Use the address returned by the API. The synthetic test.example.com address above cannot receive your test email. The server does not generate or return another read token.

Recover without paying again

Send GET /v1/inboxes/lookup with Authorization: Bearer <access_token> to recover the same metadata returned by creation. All read, wait, lookup, and delete requests use that same bearer token. Tokens belong in the header, never the URL.

While the inbox is active, repeating POST /v1/inboxes with the same token and identical body returns HTTP 200 without another payment. Reusing the token with a different body returns HTTP 409. After a network error, keep the token and exact body, try lookup, then retry that saved request if needed.

The token controls the inbox. Losing it prevents recovery. An inbox expires 24 hours after creation; replay does not extend its lifetime.

If payment settlement is unconfirmed, creation returns HTTP 502 and lookup returns HTTP 409 while that outcome is pending. Preserve the same token and request body. Do not authorize a second independent payment; use lookup to check recovery.

Read, wait, and delete

MethodRouteBehavior
GET/v1/inboxes/lookupRecover inbox metadata using the saved access token.
GET/v1/inboxes/{id}/messagesRead accepted messages and extraction results.
GET/v1/inboxes/{id}/wait?timeout=60&after={message_id}Wait up to 60 seconds for a message after the cursor.
DELETE/v1/inboxes/{id}Delete the inbox and its stored messages.

Messages returns { inbox_id, status: "active", expires_at, messages: [...] }. Wait is one bounded call, up to 60 seconds. Pass next_after from a received result as the next after cursor; it must identify a message in this inbox. An unknown cursor returns HTTP 400.

json

Illustrative wait response · HTTP 200

{
  "received": true,
  "timeout": false,
  "message": {
    "id": "im_00000000-0000-4000-8000-000000000000",
    "received_at": "2026-10-20T12:01:00.000Z",
    "from": "no-reply@your-app.example",
    "subject": "Verify your account",
    "text": "Your code is 428196",
    "text_truncated": false,
    "otp": "428196",
    "verification_url": null,
    "otp_status": "found",
    "link_status": "not_requested"
  },
  "next_after": "im_00000000-0000-4000-8000-000000000000"
}

json

Wait timeout · HTTP 200 · next_after preserves a supplied cursor

{
  "received": false,
  "timeout": true,
  "message": null,
  "next_after": null
}

A timeout means no matching message arrived during that wait. It does not prove that your application failed to send. Use a deadline in your test runner rather than an endless wait loop. DELETE returns { "id": "…", "deleted": true }.

Explicit extraction results

Each accepted message includes id, received_at, from, subject, text, text_truncated, otp, verification_url, otp_status, and link_status.

  • found: one eligible OTP or link was found. Check this status before using the corresponding value.
  • not_found: no eligible value was found; the value is null.
  • ambiguous: multiple eligible values were found; the value is null, so the client must not guess.
  • link_status: not_requested: no link_host was supplied, so verification_url is null.

The SMTP envelope sender filter is not sender authentication. It does not establish ownership or trust in the message. An exact HTTPS hostname filter constrains which URL can be returned; Exende never visits, follows, or validates the destination. Your test decides whether to open it.

Limits and error handling

Creation JSON is limited to 4,096 bytes; unknown fields are rejected. Each inbox lasts 86,400 seconds and accepts at most three messages, each no larger than 1,048,576 bytes (1 MiB). Attachments and outbound email are unsupported. A wait lasts at most 60 seconds. Read results are private; protect your token and stored messages.

Each inbox also has a maximum of 30 incoming email processing attempts. Invalid MIME and subject mismatches count toward this processing budget, even when no message is stored. The stored-message limit remains three.

HTTPAction
400Correct the request fields or after cursor.
401Supply the saved bearer access token.
402A new inbox requires the quoted x402 payment.
404Check the token and inbox identifier; use lookup for recovery.
409Keep the original body when reusing a token; inspect the error before retrying.
410The inbox has expired or is no longer usable; inspect the error code.
413 / 415Use an application/json create body of at most 4,096 bytes.
429Respect the service's request limit and retry guidance.
502Payment settlement is unconfirmed. Keep the same token and use lookup; a pending lookup returns 409.
503Receiving configuration or service is unavailable.

Errors use { "error": { "code", "message", "request_id" } }. Inspect the API error code before deciding to retry. A lost response is not permission to authorize a second independent payment: recover using the saved token first.

Machine-readable contract

The OpenAPI document defines the v1 request and response schemas. Test Inbox is a separate resource on the existing Exende API host.