Mail for Pi
Developer Documentation
Everything you need to integrate Mail for Pi into your application.
Getting Started
Mail for Pi — App messaging & webhooks (developer API)
Base URL: use your Mail for Pi deployment origin, e.g. https://mail.example.com. Below it is written as https://<host>.
Official hosted documentation: /developer/docs (this file).
Service status: /status (public page; JSON at GET /api/status).
Developer access
- Submit a request at
/developer/requestwith your Pi Network username. - Access is invite-only: an administrator reviews the request and sends an activation link (
/developer/activate?token=...). - After activation, use the developer dashboard (
/developer) to create applications and API keys. - New applications start in sandbox on the free plan. Production requires a paid plan (
productionAllowed), an admin-approved upgrade request, and an active Pi subscription on the application.
Quickstart
- Create an application from the developer dashboard (MyPiMail UI).
- Generate an API key for that app (sandbox keys start with
mypimail_sandbox_...). - Send your first message (replace the key and recipient):
curl -sS -X POST "https://<host>/api/v1/messages" \
-H "Authorization: Bearer mypimail_sandbox_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"to":"recipient@pi.mail","text":"Hello from my app"}'-
(Optional) Call
GET /api/v1/meto confirm the key and seeapplicationId,env, andplan. -
(Optional) Register a webhook in the developer dashboard (Webhooks tab on your application) or via
POST /api/platform/apps/{appId}/webhookswith a logged-in user session (not the API key). Verify incomingPOSTpayloads usingX-MyPiMail-Signature(see Signature verification).
Minimal working example
Send a message and receive a webhook end-to-end:
- Create a webhook endpoint for your app with
POST /api/platform/apps/{appId}/webhooks(session auth) and a publichttps://URL. Mail for Pi rejects localhost and private IPs — for local dev, use a tunnel (e.g. ngrok, Cloudflare Tunnel) that exposeshttps://....to your machine. - Run a small HTTP server that accepts
POSTand returns 2xx quickly:
// Node + Express (npm i express)
import express from "express"
const app = express()
app.use(express.json())
app.post("/webhook", (req, res) => {
console.log(req.body) // MyPiMail JSON payload (verify signature in production — see below)
res.sendStatus(200)
})
app.listen(3000, () => console.log("listening :3000"))- Point your tunnel at port 3000, register that HTTPS URL with Mail for Pi, then
POST /api/v1/messageswith your API key. You should see the payload logged when the message is delivered (or deferred/failed, depending on outcome).
Authentication types
| Use case | Method |
|---|---|
| Send messages / use messaging **v1 API** (`/api/v1/me`, `/api/v1/messages`) | **Messaging API key** (`mypimail_…`) — `Authorization: Bearer <key>` or `X-Api-Key: <key>` |
| Record App Users (identify / delete) | **App Users key** (`mfpau_…`) with purpose `app_users_identify` or `app_users_delete` — see [App Users overview](#app-users-overview) |
| Manage apps, API keys, **webhooks**, delivery log / retry, App Users owner UI | **User session** — logged-in MyPiMail account (dashboard / `withAuth` cookies), **not** the API key |
Do not use the app API key on /api/platform/... routes; those expect the human developer session. Do not use App Users keys to send messages (and messaging keys cannot identify App Users).
Overview
- v1 messaging API — App-authenticated HTTP API: identity (
/me) and app-to-user messaging (/messages). Keys are issued per application (mypimail_…); each app has anenvofsandboxorproduction. - App Users — Backend-asserted profiles via
POST /api/v1/users/identify(mfpau_…identify), purpose preferences viaPOST /api/v1/users/:appUserId/preferences(separate preferences credential), owner preview/send under platform session routes. Does not replace messaging API keys. Does not mean Mail for Pi independently verified the Pi username. Marketing send policy-disabled; Testnet multi denied (TESTNET_CAMPAIGNS_NOT_AVAILABLE). Operator enrollment required. See App Users overview. - Webhooks — When a message delivery outcome is recorded (
AppMessageEvent), MyPiMail mayPOSTa JSON payload to each active HTTPS endpoint registered for that application. Signing:X-MyPiMail-Signature: sha256=<hex>(HMAC-SHA256 of the raw body). - Delivery tracking — Each send records an
AppMessageEvent(viewable in the dashboard Logs tab orGET .../messages). Each POST to a webhook creates/updates aWebhookDeliveryrow. Owners can list webhook deliveries and manually retry failed ones via platform APIs.
App authentication (v1)
Headers
Authorization: Bearer <full_api_key>
orX-Api-Key: <full_api_key>
The key must start with mypimail_ and be long enough to include the stored prefix (first 16 characters are used for lookup). Revoked keys are rejected.
Sandbox vs production
app.envis eithersandboxorproduction.- Production apps require the app’s plan to allow production (
productionAllowed) and an active subscription matching the plan. Otherwise v1 returnsPLAN_RESTRICTIONorsubscription_inactive(403). POST /api/v1/messagesworks in sandbox and production. Sandbox enforces daily API call quota (quotaApiCallsPerDay). Production enforces daily message quota (quotaMessagesPerDay) plus API call quota when set.
Quotas and rate limits
Before the handler runs, v1 routes may enforce daily quota and per-minute rate limit (rateLimitPerMinute, default 100). Exceeded quota → 429 with codes such as QUOTA_EXCEEDED or MESSAGE_QUOTA_EXCEEDED; rate limit → RATE_LIMIT_EXCEEDED.
Plan limits (webhooks)
Webhook registration also requires the plan flag webhooksEnabled (enabled on enterprise by default). Endpoint count is capped per plan via maxWebhookEndpoints (e.g. free: 1, starter: 5, pro: 20, enterprise: 100). Register endpoints in the dashboard Webhooks tab or with POST .../webhooks; exceeding the cap returns 403.
GET /api/v1/me
Returns the authenticated application’s identity.
Auth: API key.
Response (JSON):
| Field | Description | |
|---|---|---|
| `applicationId` | App id | |
| `name` | App display name | |
| `env` | `sandbox` \ | `production` |
| `plan` | Plan name (e.g. `free`) | |
| `status` | e.g. `active` | |
| `requestId` | Correlation id (also on response header) |
Headers: Responses include X-Request-Id (same value as requestId in body when present). You may send x-request-id on the request to propagate your own id.
Example:
curl -sS "https://<host>/api/v1/me" \
-H "Authorization: Bearer mypimail_sandbox_YOUR_KEY_HERE"POST /api/v1/messages
Sends an app-to-user message (text). Available in sandbox and production (production: plan limits and active subscription required).
Auth: API key.
Body (JSON):
| Field | Required | Limits |
|---|---|---|
| `to` | yes | max 128 chars — existing user: `handle@pi.mail`, `piusername`, or user id; deferred (no account yet): Pi username only (no `@`; alphanumeric, `_`, `-`) |
| `text` | yes | max 50_000 chars |
| `subject` | no | max 200 chars |
| `fromDisplay` | no | max 128 chars (defaults derived from app name) |
Outcomes:
| HTTP | Meaning |
|---|---|
| **200** | Delivered to an existing user’s mailbox. Body includes `ok`, `threadId`, `messageId`, `folder` (e.g. `INBOX`, `REQUESTS`, `SPAM`). |
| **202** `status: "deferred"` | Recipient has no MyPiMail account yet; message stored for deferred delivery when they sign up. Body includes `targetPiUsername`. |
| **202** `status: "dropped"` | Delivery was not stored in mailbox (policy / caps / errors). Body includes `reason`. Triggers webhook `message.failed`. |
| **400** | Validation error, unknown recipient (when not valid deferred target), invalid JSON. |
| **403** | Suspended app, plan restriction, inactive production subscription, or other policy errors from auth/plan. |
Example (success):
curl -sS -X POST "https://<host>/api/v1/messages" \
-H "Authorization: Bearer mypimail_sandbox_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"to":"alice@pi.mail","subject":"Hi","text":"Hello"}'Idempotency (safe retries)
Optional header: Idempotency-Key
Use a unique key per logical send (we recommend a UUID). If the network fails after Mail for Pi accepted the request, retry with the same key and the same body to get the original response without sending twice.
| Behavior | Detail |
|---|---|
| Scope | Keys are scoped per authenticated application and endpoint (`v1:messages:{applicationId}:…`) — the same raw key on a different app does not collide. |
| Stored responses | Only the first **2xx** response is stored and replayed (200 delivered, 202 deferred, 202 dropped). |
| Not stored | **4xx** and **5xx** responses are not cached; fix the request and retry with the same or a new key. |
| TTL | **24 hours** (Redis when `REDIS_URL` is set; otherwise in-memory per process). |
| Body mismatch | Reusing a key with a **different body** returns the **first** successful response. Use a new key for a new logical send. |
Example with idempotency:
curl -sS -X POST "https://<host>/api/v1/messages" \
-H "Authorization: Bearer mypimail_sandbox_YOUR_KEY_HERE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{"to":"alice@pi.mail","text":"Hello"}'Webhook idempotency is separate: when you receive webhooks, deduplicate on payload field id (the AppMessageEvent id). Send idempotency (Idempotency-Key) only applies to POST /api/v1/messages retries.
Delivery semantics
These describe what each webhook type means for your integration.
-
message.delivered— The message was accepted into the recipient’s mailbox (aMessagerow exists). It is not always “visible in INBOX”: the message may land inINBOX,REQUESTS, orSPAM(and related routing). Usedata.deliveryFolder(normalized:INBOX|REQUESTS|SPAM|UNKNOWN) anddata.messageId/data.threadIdfor correlation. -
message.deferred— The recipient does not yet have a MyPiMail account for the given Pi-identity style address. The message is queued; when the user signs up / becomes deliverable, MyPiMail will import it. NomessageIduntil a latermessage.delivered(after import). -
message.failed— Delivery did not complete into the mailbox (e.g. dropped after routing: flood, caps, transaction errors, etc.).data.messageIdis typicallynull. Treat as terminal for that send unless you implement app-level retry.
API ↔ webhook mapping: v1 202 status: "dropped" records FAILED and fires message.failed. 202 status: "deferred" fires message.deferred. 200 delivered fires message.delivered.
Message delivery log (platform — user session)
Lists AppMessageEvent rows for your application — the same data shown in the developer dashboard Logs tab (filter, inspect, CSV export).
Auth: logged-in MyPiMail user who owns the application (session cookies).
GET /api/platform/apps/{appId}/messages
Query parameters:
| Param | Description | ||
|---|---|---|---|
| `limit` | Default 50, max 100 | ||
| `cursor` | Pagination cursor (event `id`) | ||
| `from` | Optional start date (`YYYY-MM-DD`) | ||
| `to` | Optional end date (`YYYY-MM-DD`) | ||
| `status` | Optional filter: `DELIVERED` \ | `DEFERRED` \ | `FAILED` |
| `recipient` | Optional partial match on recipient |
Response: { "entries": [ ... ], "nextCursor": "<id>" | null }
Each entry includes: id, recipient, status, threadId, messageId, deliveryFolder, deferredMessageId, errorMessage, requestId, createdAt.
Example:
curl -sS -b cookies.txt \
"https://<host>/api/platform/apps/APP_ID/messages?status=DELIVERED&limit=50"Webhook system
When webhooks fire
After a successful AppMessageEvent database write for your applicationId, MyPiMail schedules HTTPS POST requests to each active WebhookEndpoint (fire-and-forget, decoupled from the v1 HTTP response). There is no server-side auto-retry loop for the HTTP call; failures are logged and recorded on WebhookDelivery.
Event types
| `type` | Source status |
|---|---|
| `message.delivered` | `DELIVERED` |
| `message.failed` | `FAILED` |
| `message.deferred` | `DEFERRED` |
HTTP behavior
- Method:
POST - Content-Type:
application/json - Body: UTF-8 JSON (see Payload format)
- Timeout: ~5 seconds per request. If your endpoint does not respond within ~5 seconds, the HTTP delivery is treated as failed and the
WebhookDeliveryrow is marked failed (timeout / network error). - Header:
X-MyPiMail-Signature: sha256=<hex>— HMAC-SHA256 of the exact raw request body using the endpoint’s secret
Managing webhook endpoints (platform — user session)
All routes require a logged-in MyPiMail user who owns the application. Use the browser session (cookies) or any client that forwards session cookies after login.
GET /api/platform/apps/{appId}/webhooks
Lists endpoints. Secrets are never returned.
Response: { "webhooks": [ { "id", "url", "isActive", "createdAt", "updatedAt" } ] }
POST /api/platform/apps/{appId}/webhooks
Body: { "url": "https://..." }
Rules:
- HTTPS only (
https://) - No userinfo in the URL (
https://user:pass@...is rejected) - Hostname must not be localhost, loopback, or a blocked private/metadata literal IP range (SSRF hardening)
Response: includes secret once at creation time — store it securely; it cannot be fetched again via list.
Plan may cap count via maxWebhookEndpoints → 403 when exceeded. Requires webhooksEnabled on the plan (the dashboard Webhooks tab is hidden when disabled). Register in the dashboard or via this API.
DELETE /api/platform/apps/{appId}/webhooks/{id}
Removes the endpoint.
Examples (session cookie file from browser export):
curl -sS -b cookies.txt "https://<host>/api/platform/apps/APP_ID/webhooks"
curl -sS -b cookies.txt -X POST "https://<host>/api/platform/apps/APP_ID/webhooks" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/mypimail"}'Webhook payload format
Every payload includes version: "v1" for forward compatibility.
Top-level fields:
| Field | Type | Description | ||
|---|---|---|---|---|
| `version` | string | `"v1"` | ||
| `id` | string | **`AppMessageEvent` id** — use for deduplication | ||
| `type` | string | `message.delivered` \ | `message.failed` \ | `message.deferred` |
| `createdAt` | string | ISO 8601 | ||
| `data` | object | See below |
data object:
| Field | Type | Description | ||||
|---|---|---|---|---|---|---|
| `applicationId` | string | Your app id | ||||
| `threadId` | string \ | null | Thread id when delivered | |||
| `messageId` | string \ | null | Message id when delivered | |||
| `status` | string | `DELIVERED` \ | `FAILED` \ | `DEFERRED` | ||
| `recipient` | string | Recipient identifier as stored | ||||
| `deliveryFolder` | string \ | null | `INBOX` \ | `REQUESTS` \ | `SPAM` \ | `UNKNOWN` (mainly for delivered) |
Sample: message.delivered
{
"version": "v1",
"id": "clxxxxxxxxxxxxxxxxxxxxxxxx",
"type": "message.delivered",
"createdAt": "2026-02-17T12:00:00.000Z",
"data": {
"applicationId": "clappxxxxxxxxxxxxxxxxxxxxxxx",
"threadId": "clthrxxxxxxxxxxxxxxxxxxxxxxx",
"messageId": "clmsgxxxxxxxxxxxxxxxxxxxxxxx",
"status": "DELIVERED",
"recipient": "alice@pi.mail",
"deliveryFolder": "INBOX"
}
}Sample: message.deferred
{
"version": "v1",
"id": "clxxxxxxxxxxxxxxxxxxxxxxxx",
"type": "message.deferred",
"createdAt": "2026-02-17T12:00:00.000Z",
"data": {
"applicationId": "clappxxxxxxxxxxxxxxxxxxxxxxx",
"threadId": null,
"messageId": null,
"status": "DEFERRED",
"recipient": "futureuser",
"deliveryFolder": null
}
}Sample: message.failed
{
"version": "v1",
"id": "clxxxxxxxxxxxxxxxxxxxxxxxx",
"type": "message.failed",
"createdAt": "2026-02-17T12:00:00.000Z",
"data": {
"applicationId": "clappxxxxxxxxxxxxxxxxxxxxxxx",
"threadId": null,
"messageId": null,
"status": "FAILED",
"recipient": "bob@pi.mail",
"deliveryFolder": null
}
}Idempotency
Webhook events are uniquely identified by the payload field id (the AppMessageEvent row id). The same logical event may be POSTed more than once — always deduplicate using id.
- Your receiver should treat
idas the idempotency key: ignore or deduplicate if you have already processed the sameid. - Under normal operation you should not receive two different payloads with the same
idfor the same logical outcome; if your handler retries or your network duplicates delivery, dedupe onidto stay safe.
Signature verification
- Header:
X-MyPiMail-Signature - Format:
sha256=<lowercase_hex_digest> - Algorithm: HMAC-SHA256
- Key: the endpoint
secretreturned when the webhook was created - Message: the raw HTTP body string (bytes as received; for JSON, verify on the exact string before re-serializing)
Parse the header: strip the sha256= prefix, compare the hex digest to your computed HMAC using a timing-safe comparison.
Node.js
import crypto from "node:crypto"
const secret = process.env.PIMAIL_WEBHOOK_SECRET // store from POST /webhooks response
const rawBody = requestBodyString // exact string from the HTTP body
const header = req.headers["x-mypimail-signature"] // lowercase header name typical in Node
if (!header || !header.startsWith("sha256=")) {
throw new Error("Missing or invalid signature header")
}
const theirHex = header.slice("sha256=".length)
const hmac = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")
const a = Buffer.from(theirHex, "hex")
const b = Buffer.from(hmac, "hex")
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid signature")
}Python
import hmac
import hashlib
secret_str = "your_webhook_secret" # from POST /webhooks response
raw_body: bytes = request_body_bytes # exact POST body bytes (do not decode/re-encode before verify)
header = request.headers.get("X-MyPiMail-Signature", "")
if not header.startswith("sha256="):
raise ValueError("invalid signature header")
their_hex = header[len("sha256=") :]
expected = hmac.new(secret_str.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(their_hex, expected):
raise ValueError("invalid signature")Webhook deliveries API (platform — user session)
GET /api/platform/apps/{appId}/webhook-deliveries
Query parameters:
| Param | Description | ||
|---|---|---|---|
| `limit` | Default 50, max 100 | ||
| `cursor` | Pagination cursor (delivery `id`) | ||
| `status` | Optional filter: `pending` \ | `success` \ | `failed` |
Response: { "deliveries": [ ... ], "nextCursor": "<id>" | null }
Each item includes: id, webhookId, eventId, url, status, responseStatus, errorMessage, attemptCount, lastAttemptAt, createdAt, updatedAt.
POST /api/platform/apps/{appId}/webhook-deliveries/{deliveryId}/retry
Re-sends the same logical event payload to the webhook’s current URL/secret; increments attemptCount and updates the same delivery row.
Response (success):
{ "ok": true, "delivered": true, "attemptCount": 2 }delivered is whether the latest HTTP attempt returned success (2xx). Errors may return 404 (delivery/event not found) or 400 (e.g. inactive webhook, bad event) with a code field.
Example:
curl -sS -b cookies.txt -X POST \
"https://<host>/api/platform/apps/APP_ID/webhook-deliveries/DELIVERY_ID/retry"Webhook reliability recommendations
We recommend:
- Treat webhooks as at-least-once from your perspective: assume duplicates or redelivery can happen (network, manual retry, future queueing).
- Webhook events may be delivered more than once. Always deduplicate using
id(the payload field =AppMessageEventid). - For failed HTTP deliveries, use
GET .../webhook-deliveriesandPOST .../retryafter you fix the receiver. - Respond to webhook
POSTwith 2xx quickly; offload work to a queue internally if needed.
Troubleshooting
| Symptom | Check |
|---|---|
| 401 on v1 messaging | Missing/invalid messaging API key, revoked key |
| 403 on `/messages` | Suspended app, `PLAN_RESTRICTION`, or `subscription_inactive` on production |
| 403 on webhooks create | `maxWebhookEndpoints` reached or plan lacks `webhooksEnabled` |
| Webhook URL rejected | Must be public **HTTPS**; localhost/private literal IPs blocked |
| Signature mismatch | Verify on **raw** body; no pretty-print reorder before verify |
| `X-Request-Id` | Send to support when debugging v1 calls |
| App Users identify / enrollment | See [App Users troubleshooting](#app-users-troubleshooting) |
One dashboard, separate application authority (011)
On the public https://www.mypimail.com deployment, eligible production/Mainnet and sandbox/Testnet applications use the same dashboard and integration origin. The application's Pi network does not select a different Mail for Pi server or Pi payment/auth credential. app.email-pi.com remains our internal Testnet deployment and rejects production applications. Databases, accounts, keys and enrollments are never copied or merged between deployments.
Every application still needs an approved developer owner, active status, verified domain, compatible admin classification, and applicable subscription gates. App Users activates automatically for eligible applications on the first mutating write that needs processing (credential issuance or identify/preferences). Administrators may suspend or explicitly enable App Users per application; legacy disabled rows (enabled=false without admin suspension marker) stay inactive until an admin decides. Sandbox is not approval. All/Mainnet/Testnet filters reflect recorded classification; pending/rejected applications remain visible in All. Users, credentials, quota and history remain application-scoped, even with identical usernames in different apps.
New App Users keys use mfpau2_, remain in the isolated AppUsersCredential store, and bind issuing deployment + Application + purpose + application env/classification snapshot. processNetwork continues to mean the issuing deployment: a Testnet application issued here has deployment mainnet and application scope sandbox/testnet. Changing to an incompatible app configuration denies identify and preference grants with the old key; obtain a newly authorized key after review. Existing mfpau_ keys retain their legacy scope and gain no new cross-classification permission. Messaging keys and their permissions are unchanged.
Valid delete/preference credentials retain restrictive deletion/withdrawal access when new processing is disabled, subject to application/deployment/purpose/revocation checks. Preference grants recheck full eligibility. Owners may revoke keys or delete app profiles using their session and CSRF protection even after developer approval is withdrawn. A revoked key never works. Deletion and withdrawal do not restore profiles, grant consent, enroll apps or authorize sends.
Testnet messaging restrictions follow the Application even on the public dashboard. Marketing and AppUser multi-recipient send remain unavailable. BLOCK, purpose withdrawal, quotas, idempotency and recipient restrictions remain in force.
Deployment note: this policy needs the additive 011 credential-scope migration before activation. It has not been applied to permanent databases by this change. Old binaries reject new keys by prefix; retain the columns on rollback and keep a compatible restrictive-operation handler available. Do not roll back suppression/delivery protections or manually copy keys to legacy storage.
Backend-asserted profiles for users of your application — recorded by Mail for Pi when your authenticated backend calls identify after your successful login.
Status: Identify, purpose preferences, owner preview, and AppUser send are implemented in code and proven on disposable PostgreSQL. Testnet live trial (real Pi login / real mailbox) requires a separate runtime authorization — see package TESTNET_TRIAL_RUNBOOK.md. Not a Mainnet product rollout. App Users auto-activates for eligible apps; admin suspension remains available.
App Users lifecycle (activation + suspension)
| Status | Meaning |
|---|---|
| `not_provisioned` | No `AppUsersState` row yet |
| `active` | `enabled=true` — new processing allowed (subject to eligibility / network) |
| `admin_suspended` | `enabled=false` **and** `adminSuspendedAt` set — sticky until admin re-enables |
| `legacy_disabled` | `enabled=false` **without** `adminSuspendedAt` — pre-auto-activation disable; stay inactive until admin decides |
Activation point (write paths only): POST /api/platform/apps/:appId/users/credentials and identify/preferences auth when requireEnabled. Never from GET/dashboard list endpoints. Creates an active row with pilot defaults when absent + eligible. Does not re-enable admin_suspended or legacy_disabled, does not rebind processNetwork, does not issue keys, and does not grant consent/TRUST.
Admin: Applications panel → App Users — status, eligibility denial, limits, Suspend / Activate (CSRF + admin). Suspension does not delete users, credentials, or tombstones; restrictive delete/withdrawal paths remain.
Hosted with the rest of the developer docs at
/developer/docs(sections below). Messaging API keys and App Users keys are separate. No claim that Mail for Pi or this integration is “Pi-approved.”
End-to-end path (what to implement)
1. User signs in with Pi inside YOUR app (your client + your backend)
2. YOUR backend verifies the Pi session (YOUR /me or equivalent)
3. YOUR backend calls Mail for Pi identify (mfpau2_… identify key; legacy mfpau_ keys retain old scope)
→ optional piUsername ONLY from that verified /me (never from the browser)
4. Owner dashboard: Users list / search (session + CSRF)
5. YOUR backend reports purpose preferences (mfpau2_… preferences key)
→ operational_updates | marketing; allowed | withdrawn
→ marketing grants are recordable but policy-disabled for send
6. Owner: message-preview (advisory; no send / no quota consume)
7. Owner: allowed send (session) — single AppUser form or multi AppUser form
→ server rechecks everything; App User multi is denied on all networks (`APP_USER_MULTI_NOT_AVAILABLE`)Mail for Pi authenticates your App Users credential and stores your assertion. It does not independently re-verify the Pi username with Pi Platform.
Identity model (do not conflate)
| Concept | What it is | What it is not |
|---|---|---|
| `externalUserId` | Your backend account id (case-sensitive, opaque to Mail for Pi) | A mailbox address or MyPiMail User id |
| `appUserId` | Mail for Pi opaque profile id for **your app** | A recipient destination |
| `piUsername` | Optional backend-asserted Pi username after **your** `/me` | Independent Pi verification by Mail for Pi |
| Mailbox destination | Resolved at send/preview from username → existing `@pi.mail` User | Created by identify |
| Messaging API key (`mypimail_…`) | Sends legacy app→user mail | Cannot identify App Users |
| App Users key (`mfpau2_…`; legacy `mfpau_…`) | Identify / delete / preferences, separately scoped | Cannot send mail |
- Identify does not create or link a Mail for Pi account.
- Recording a profile does not send messages and does not imply recipient consent.
- Never submit
consent=trueor invent an automatic grant.
App Users overview
| This is | This is not |
|---|---|
| A record that your backend asserted an `externalUserId` for your app | A second Pi login, Widget flow, or required MyPiMail mailbox |
| Scoped to one application | A MyPiMail `User` id or inbox handle |
| Best-effort after **your** login succeeds | Automatic mail, consent capture, or Requests/TRUST changes |
| Pilot limits in code (`APP_USERS_PILOT`) | Commercial pricing or proof of concurrent capacity |
App Users quickstart (Users | Integration)
See your app’s Pi users and connect your backend after Pi login.
Requires a backend — the secret key never goes in the browser.
Users tab
Compact Send a message (operational updates only), search, paginated list (10 per page). Select a user to set the recipient. Recording a user is not permission to message them.
Integration tab (three steps)
- Create your connection key — Create Identify key → store in
APP_USERS_IDENTIFY_KEY(server only). Manage existing keys separately; Preferences/Delete stay under Advanced. - Add it to your backend — Copy the full self-contained TypeScript example (helper included). Place the call after your Pi
/meverification and your session creation. SetMAIL_FOR_PI_ORIGINto the Mail for Pi address where you created the key (this deployment’s origin). Your app’s Pi network classification is a separate setting. Do not invent consent or take usernames from the browser. - Verify connection — Sign in to your app with Pi, then Check connection. Success requires a recorded Pi username and last identified time.
If eligibility or verification is missing, Integration shows the exact next action. Eligible apps auto-activate App Users when you issue a key or call identify — there is no separate operator enroll step for the normal path. An administrator may still suspend App Users for a specific application.
Limits, CAS/revisions, tombstones, and full API reference: Technical details / API reference below.
Prerequisites and Testnet setup
Developer steps (you)
- Request developer access —
/developer/request(invite-only). - Activate after admin approval —
/developer/activate?token=…soUser.developerStatus=approved. - Create a sandbox application on the Testnet deployment (application
env=sandbox). New apps start with network classification pending. - Domain verification (owner)
- Publish the verification token at exactly:
{your-app-origin}/.well-known/mailforpi-verification.txt - Complete Verify in the application settings UI.
- On the Testnet process, domain verify/regenerate applies to sandbox apps only.
- Publish the verification token at exactly:
- Wait for admin Testnet classification (
piNetworkStatus=testnet) — you cannot self-classify. - App Users activation — eligible apps auto-activate on first credential issue or identify/preferences write (
AppUsersStatewith pilot budgets). Admin may suspend or explicitly enable from the admin Applications panel. - Issue credentials and call identify / preferences (quickstart above).
Testnet messaging restrictions (current policy)
| Rule | Behavior |
|---|---|
| Single recipient AppUser / legacy send | Allowed only when mailbox exists and Testnet first-contact / trust rules pass |
| Multi-recipient AppUser / campaigns send | **Denied** with `TESTNET_CAMPAIGNS_NOT_AVAILABLE` — **not** rewritten into repeated singles |
| Marketing purpose | Preference may be **recorded**; **send remains policy-disabled** (G1) |
| Missing / ambiguous mailbox | Fail closed — **no new deferred** for AppUser mode |
| Preview | Advisory only — no delivery, TRUST, or message-quota consume |
Admin / operator steps (platform)
| Step | Who | Notes |
|---|---|---|
| Approve developer request + activation path | Admin | Sets `developerStatus=approved` |
| **Approve as Pi Testnet** (classification) | Admin on Testnet process | After domain verified; separate from Mainnet classification |
| Enable / suspend App Users (`AppUsersState`) | Automatic for eligible apps; admin can suspend or explicitly enable | Separate from classification; not a paid-plan gate |
| Issue Identify / Preferences keys | Application owner | After App Users is active for the app |
Important: admin UI access ≠ approved developer for APIs
Being able to open an admin dashboard does not satisfy runtime App Users checks. Identify/preferences require the application owner to have developerStatus=approved. Otherwise APIs return DEVELOPER_ACCESS_REVOKED (403).
Domain Verified and classification Pending are different stages: verified ownership ≠ Testnet/Mainnet classification ≠ App Users active. Eligible apps activate App Users automatically on first processing write; admin suspension is sticky until explicit re-enable.
Origins (do not mix credentials)
| Mail for Pi deployment | Origin | Application `env` binding for App Users |
|---|---|---|
| Internal Testnet (`app.email-pi.com`) | `https://app.email-pi.com` | `sandbox` only |
| Public Mainnet (`www.mypimail.com`) | `https://www.mypimail.com` | eligible `production/mainnet` and `sandbox/testnet` |
Deployment origin ≠ Pi network classification.
MAIL_FOR_PI_ORIGIN is the Mail for Pi host where you created the Identify key. Your application’s Pi network status (pending / testnet / mainnet) is a separate admin classification.
Issue the key on the same deployment you will call. Never reuse a Testnet secret on Mainnet or the reverse. The Integration UI shows this page’s origin automatically — it does not rewrite existing keys.
Sandbox / Pi-Testnet apps on the Mainnet deployment: eligible apps use the public origin with new version-2 application-scoped credentials (auto-activated on first processing write). Version-1 keys retain the previous strict process/env binding and are not silently upgraded. Unverified, pending, rejected, inactive, incompatible, admin-suspended, or legacy-disabled apps remain denied.
Technical details / API reference
POST /api/v1/users/identify
Auth: Authorization: Bearer <app_users_identify key> (or X-Api-Key). Purpose must be app_users_identify.
Request body (Content-Type: application/json, max 2048 UTF-8 bytes):
{
"externalUserId": "YOUR_BACKEND_ACCOUNT_ID",
"attributes": {
"locale": "el-GR",
"externalPlan": "starter"
}
}| Field | Rules |
|---|---|
| `externalUserId` | Required string, 1–128 UTF-8 bytes, case-sensitive, **no** trim/normalize; no control characters |
| `attributes` | Optional. Only `locale` and `externalPlan` allowed |
| `locale` | Omit = preserve; `null` = clear; string ≤ 35 UTF-8 bytes (BCP-47-like pattern) |
| `externalPlan` | Omit = preserve; `null` = clear; string ≤ 64 UTF-8 bytes |
| `piUsername` | Optional. **Omit** = preserve; **`null`** = clear (needs revision + `identityEventId`, no Pi evidence); **string** = set/update (canonical ASCII `[a-z0-9_-]{1,128}`, needs evidence + revision + event) |
| `piUsernameEvidence` | Required when setting username: `{ source: "developer_backend_pi_me", observedAt, evidenceRef }`. Opaque `evidenceRef` ≤ 128 ASCII bytes (no URL/token). Timestamps ISO UTC; reject >5 min future. |
| `profileSharingEvidence` | Required when setting username: `{ noticeVersion, occurredAt, evidenceRef }` |
| `expectedProfileRevision` | Required for set/clear. Nonnegative safe integer; must match current profile (`0` on first set). |
| `identityEventId` | Required for set/clear. UUID. Same event + same payload replays; same event + different payload → `409 IDENTITY_EVENT_CONFLICT`. |
| Unknown keys / wrong types | `400 INVALID_REQUEST` |
Username trust model: piUsername is an optional developer-backend assertion after your Pi /me verification. Mail for Pi authenticates your backend key; it does not independently verify the Pi username. Never submit a client-supplied username. Never send Pi access tokens to this API.
Attributes are informational labels only. They never change Mail for Pi entitlements, Premium, quota, or consent.
Success response (200 update, 201 create):
{
"data": {
"id": "<appUserId>",
"firstIdentifiedAt": "<ISO-8601>",
"lastIdentifiedAt": "<ISO-8601>",
"source": "backend_asserted",
"piUsername": "alice",
"profileRevision": 1
}
}piUsername may be null when not provided. profileRevision increments on every accepted username set/change/clear.
Example — set username after your /me (placeholders only):
{
"externalUserId": "YOUR_BACKEND_ACCOUNT_ID",
"piUsername": "alice",
"piUsernameEvidence": {
"source": "developer_backend_pi_me",
"observedAt": "2026-09-22T10:00:00.000Z",
"evidenceRef": "me_obs_001"
},
"profileSharingEvidence": {
"noticeVersion": "share-v1",
"occurredAt": "2026-09-22T10:00:00.000Z",
"evidenceRef": "share_001"
},
"expectedProfileRevision": 0,
"identityEventId": "11111111-1111-4111-8111-111111111111"
}Idempotency / timestamps
- Same
(application, externalUserId)updates one profile — no second row. lastIdentifiedAtadvances on a ~60s write throttle when identify touches the profile. Independent of username edits; not an “online now” proof.sourceis alwaysbackend_assertedwhile the profile is active.- Stale
expectedProfileRevision→409 PROFILE_REVISION_CONFLICT. - Same
identityEventId+ same payload → prior outcome (replay); same event + different payload →409 IDENTITY_EVENT_CONFLICT.
POST /api/v1/users/:appUserId/preferences
Auth: Authorization: Bearer <app_users_preferences key>. Purpose must be app_users_preferences. Never use Identify/Delete keys. Never owner-session grant.
Reports a purpose choice for one AppUser. The developer app hosts the real preference UI; Mail for Pi stores the report.
{
"purpose": "operational_updates",
"choice": "allowed",
"expectedRevision": 0,
"profileRevision": 1,
"eventId": "22222222-2222-4222-8222-222222222222",
"occurredAt": "2026-09-22T10:05:00.000Z",
"noticeVersion": "ops-notice-v1",
"evidenceRef": "pref_001",
"source": "developer_user_action"
}| Field | Rules |
|---|---|
| `purpose` | `operational_updates` or `marketing` |
| `choice` | `allowed` or `withdrawn` |
| `expectedRevision` | Choice-row CAS (0 when none yet) |
| `profileRevision` | Must match current AppUser generation |
| `eventId` | UUID; same event + same payload replays; conflict → `409` |
| `occurredAt` | ISO UTC; reject excessive future skew |
| `noticeVersion` / `evidenceRef` | Required for `allowed`; opaque bounds as identify |
| `source` | `developer_user_action` |
Semantics
- Missing choice = unknown (not allowed).
- New allowed needs explicit affirmative evidence — no preselected toggle /
consent=true. - Withdrawal wins over late grant replay for the same generation.
- Marketing may be stored; sending marketing remains disabled by platform policy (G1).
- Username clear / profile delete invalidates address-generation grants and retains minimum suppressions (not full erasure).
Owner list display and search
Owner GET /api/platform/apps/:appId/users returns piUsername (or null), provenance when set, profileRevision, and purpose-choice summary. UI shows Pi username primary (Pi username not provided when null); externalUserId is secondary. Optional q = canonical username prefix (max 128); cursors bind current q.
Preview (advisory)
POST /api/platform/apps/:appId/users/message-preview (owner session + CSRF):
- Max 25 unique
appUserIds, purpose, subject ≤ 200, text ≤ 50_000. - Returns per-profile eligible / safe reason +
selectionDigest. - Consumes no send budget; creates no Message / TRUST / deferred / quota reservation.
- Foreign / unknown IDs reject the whole request generically.
Reasons include: missing_username, recipient_unavailable, recipient_ambiguous, choice_required, opted_out, recipient_policy_denied, not_in_app_audience, network_not_allowed, plan_or_quota_denied, marketing policy disabled, etc. Never returns mailbox User IDs.
Send (owner session)
Use existing owner routes with exclusive AppUser forms (do not mix with legacy to / recipientUserIds):
| Mode | Route | Body (conceptual) |
|---|---|---|
| Single | `POST .../messages/send` | `{ appUserId, purpose, profileRevision, subject, text, operationKey }` |
| Multi | `POST .../campaigns/send` | **Unavailable (B1/G2):** `403 APP_USER_MULTI_NOT_AVAILABLE` for `appUserIds.length > 1` on all networks — never shrunk to singles |
Server behavior
- Preview digest / selection is advisory.
- On send: durable operation keyed by
operationKey(UUID); same key + same payload resumes; changed payload →409 OPERATION_KEY_CONFLICT/PREVIEW_STALEas applicable. - Recheck ownership, purpose choice, suppression, BLOCK, audience (multi), destination snapshot, Testnet rules, quotas — before external delivery.
- Authorization commit (
authCommittedAt) happens after DB recheck and before delivery. - Withdrawal before auth commit → item ineligible / reservation released.
- Withdrawal after auth commit → in-flight/delivered mail is not recalled; pending unclaimed items cancel on later recheck.
- Results are per profile:
delivered|pending|ineligible|failed|duplicate(duplicate mailbox groups share one delivery). - Queued/accepted ≠ delivered.
pending/ resumable means the owner must continue same-key posts; status reads have no send side effects. - App User multi →
APP_USER_MULTI_NOT_AVAILABLEon all networks (never shrink to singles). Testnet legacy campaigns →TESTNET_CAMPAIGNS_NOT_AVAILABLE. - Missing mailbox → ineligible; no new deferred for AppUser mode.
Shared message quota uses a durable ledger (limit − baseline − reserved|finalized). Legacy Redis-only message counters are not the authority for cut-over days.
Status reads may include contentCleared / contentClearedAt. When cleared, subject/text are returned as null (never reconstructed). Cleared operations still retain operationKey / payloadHash so the same key cannot authorize a new send.
Retention (approved code path only): additional copies of completed send-operation subject/text and recipient username snapshots may be stripped 30 days after completedAt via CLI (scripts/app-users/cleanup-send-operation-content.ts, dry-run default, --apply to mutate). --days defaults to 30; values below 30, non-integer, or non-finite are rejected (longer periods allowed). Mailbox Message/Thread, suppressions, tombstones, and dedup/accounting receipts are not removed by this cleanup. No automatic scheduler is enabled by this documentation.
CLI runbook (authorized DB only):
- Dry-run:
npx tsx scripts/app-users/cleanup-send-operation-content.ts→ JSON counts only. - Apply: add
--apply(optional--limit=N,--batch=N,--days=30|>=30). - Invalid
--days=29/--days=abc/--days=Infinity→ exit 1 before mutation.
DELETE /api/v1/users/:appUserId
Auth: separate app_users_delete key (purpose never upgrades from Identify).
- Empty body; success
204(including idempotent delete of an existing tombstone). - Owner dashboard delete (session + CSRF) is equivalent for suppression semantics.
- Later identify for the same external ID →
409 APP_USER_DELETED. - Disclosure: field/suppression cleanup — not a claim that every stored trace elsewhere is erased. Approved (code only, not auto-enabled): additional copies of send-operation
subject/textand recipient username snapshots may be stripped 30 days after definitive completion (completedAt); mailbox Message/Thread, suppressions, tombstones, and dedup/accounting receipts remain. Other retention categories and any live scheduler remain pending — seeG4_RETENTION_PENDING.md.
Error shape
{ "error": { "code": "<CODE>", "message": "<safe message>" } }| HTTP | Code (examples) | When |
|---|---|---|
| 401 | `MISSING_API_KEY`, `INVALID_API_KEY` | Missing / invalid / revoked key |
| 403 | `INSUFFICIENT_SCOPE` | Wrong purpose |
| 403 | `APP_USERS_PILOT_DISABLED` | Not active (admin suspended, legacy disabled, or not yet provisioned / ineligible) |
| 403 | `NETWORK_MISMATCH` | Credential/app/process network disagree |
| 403 | `MAINNET_VERIFICATION_REQUIRED` | Domain + classification incomplete |
| 403 | `DEVELOPER_ACCESS_REVOKED` | Owner `developerStatus` not `approved` |
| 403 | `APPLICATION_SUSPENDED`, `PLAN_RESTRICTION`, `subscription_inactive` | Shared app gates |
| 403 | `APP_USER_MULTI_NOT_AVAILABLE` | App User multi-recipient send (all networks) |
| 403 | `TESTNET_CAMPAIGNS_NOT_AVAILABLE` | Testnet legacy campaigns / campaigns endpoint |
| 400 | `INVALID_REQUEST` | Validation / invalid JSON or UTF-8 |
| 409 | `APP_USER_DELETED`, `APP_USER_LIMIT_REACHED`, `PROFILE_REVISION_CONFLICT`, `IDENTITY_EVENT_CONFLICT`, `PREVIEW_STALE`, `OPERATION_KEY_CONFLICT` | Tombstone / cap / CAS / event / digest |
| 413 | `PAYLOAD_TOO_LARGE` | Body over limit |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | Content-Type not JSON |
| 429 | `RATE_LIMIT_EXCEEDED`, `APP_USERS_DAILY_LIMIT`, `MESSAGE_QUOTA_EXCEEDED` | Budgets |
| 503 | `APP_USERS_UNAVAILABLE` | Temporary |
| 404 | `NOT_FOUND` | Target missing |
Retry guidance: retry only temporary cases (429, 502, 503, 504, transport); respect short Retry-After; no retry for 400 / 401 / 403 / 409 / 413 / 415.
Pilot limits (code constants)
| Limit | Value |
|---|---|
| Retained rows / app (active **+** tombstones) | 1000 |
| Identify / minute / app | 60 (retries count) |
| Identify / UTC day / app | 1000 (retries count) |
| Delete / minute / app | 60 (**separate** budget) |
| Preferences / minute / app | 60 (**separate** budget) |
| Identify/preferences JSON body | 2048 bytes |
| Preview / send selection | max 25 AppUsers |
Examples
Placeholders only — never paste real keys or production user ids.
HTTP identify (Testnet)
POST /api/v1/users/identify HTTP/1.1
Host: app.email-pi.com
Authorization: Bearer mfpau2_YOUR_IDENTIFY_KEY_HERE
Content-Type: application/json
{"externalUserId":"YOUR_BACKEND_ACCOUNT_ID","attributes":{"locale":"el-GR","externalPlan":"starter"}}curl identify (Testnet)
curl --connect-timeout 2 --max-time 3 \
--request POST 'https://app.email-pi.com/api/v1/users/identify' \
--header 'Authorization: Bearer mfpau2_YOUR_IDENTIFY_KEY_HERE' \
--header 'Content-Type: application/json' \
--data '{"externalUserId":"YOUR_BACKEND_ACCOUNT_ID","attributes":{"locale":"el-GR"}}'TypeScript server helpers
Use helpers from lib/platform/appUsers/examples.ts:
identifyAppUserFromBackend— after YOUR login; optionalusernameMutationonly from YOUR/meplus real profile-sharing evidence. Parses successdata.profileRevision/piUsernamefor persistence.planAppUserUsernameMutation/exampleIntegrateAfterPiLogin— repeat-safe login flow: skip mutation when the persisted username is unchanged; use stored revision on rename; reuseidentityEventIdon retry; never invent sharing evidence in the login callback.reportAppUserPreferenceFromBackend— after YOUR user action; never inventchoice: "allowed"without evidence.- Secret from server env — never
NEXT_PUBLIC_*. externalUserId/ username from authenticated server principal — never browser body fields.- Keep login successful when identify returns
{ ok: false }.
Repeat-login rule: After the first successful username set, persist profileRevision from the response. On later logins with the same username, call identify without piUsername / mutation fields. Re-submitting expectedProfileRevision: 0 (or a stale revision) with a new identityEventId yields 409 PROFILE_REVISION_CONFLICT.
Owner dashboard Users → Integration shows this deployment’s origin for MAIL_FOR_PI_ORIGIN automatically (Mainnet: https://www.mypimail.com; internal Testnet: https://app.email-pi.com with an internal-test notice). It does not offer a Testnet/Mainnet radio and does not rewrite existing keys.
App Users troubleshooting
| Symptom | Likely cause |
|---|---|
| Developer access pending | Complete approve + `/developer/activate` |
| Domain Verified, classification Pending | Wait for admin classification |
| `403 APP_USERS_PILOT_DISABLED` | App Users inactive (suspended / legacy disabled / not provisioned) |
| `403 DEVELOPER_ACCESS_REVOKED` | Owner not approved developer |
| `403 MAINNET_VERIFICATION_REQUIRED` | Domain / classification incomplete |
| `403 NETWORK_MISMATCH` | Wrong deployment origin/key, or `Application.env` not bound to this process (e.g. sandbox app on Mainnet deployment) |
| `403 TESTNET_CAMPAIGNS_NOT_AVAILABLE` | Multi-recipient / campaigns on Testnet |
| `401` | Missing/invalid/revoked App Users key |
| `403 INSUFFICIENT_SCOPE` | Purpose mismatch (identify vs preferences vs delete) |
| `409 APP_USER_DELETED` | Same external ID suppressed |
| `409 PROFILE_REVISION_CONFLICT` / `IDENTITY_EVENT_CONFLICT` | Stale CAS / event replay mismatch |
| `409 PREVIEW_STALE` | Selection changed since preview digest |
| Preview eligible but send ineligible | Expected — preview is advisory; policy can change |
| Marketing preference stored but send denied | Marketing send policy-disabled (G1) |
| Users UI: **Waiting for first user** | Need successful backend identify |
Current scope limits
- Code path through identify → preferences → preview → allowed send is implemented; live Testnet mailbox proof remains a separately authorized runbook.
- Not presented as Mainnet-ready product rollout or “Pi-approved.”
- Messaging API keys remain a separate capability.
- G1 marketing send locked; G2 App User multi locked on all networks (
APP_USER_MULTI_NOT_AVAILABLE); G4 send-op content cleanup code closed (CLI only; D3b/D4/scheduler not approved; proposed future durations ≠ policy). SeeG4_RETENTION_PENDING.md+MAINNET_TRANSFER_PLAN.md. - Compatible rollback = keep suppressions/checks and disable AppUser send entrypoints — not restore a binary that ignores withdrawals.