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 **v1 API** (`/api/v1/*`) | **API key** — `Authorization: Bearer <key>` or `X-Api-Key: <key>` |
| Manage apps, API keys, **webhooks**, delivery log / retry | **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.
Overview
- v1 API — App-authenticated HTTP API: identity (
/me) and app-to-user messaging (/messages). Keys are issued per application; each app has anenvofsandboxorproduction. - 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 | Missing/invalid 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 |