Postal Privado delivers your messages to your customers by certified email — with delivery and read receipts, and an optional SMS heads-up — without you ever handling their contact details.
All API requests require an Authorization header with your sender API key. Get your key from the dashboard.
Authorization: Bearer nexo_sk_YOUR_API_KEY
Base URL: https://postalprivado.com/api/v1
Every POST request also requires an Idempotency-Key header (UUID recommended). Repeated calls with the same key within 24h return the original response without re-sending.
Send your first message in one curl command:
# Email is required — it carries the full message and identifies the recipient.
curl -X POST https://postalprivado.com/api/v1/send \
-H "Authorization: Bearer nexo_sk_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"recipient": { "email": "alumno@correduria.es" },
"envelope": {
"subject": "Acceso a tu plataforma de formación",
"body": "Hola, aquí tienes tu enlace: https://plataforma.example.com/login?token=abc123"
}
}'
# Optional: add phone_e164 so the recipient can enable an SMS heads-up in
# their portal. The SMS never carries the message — it only invites them to
# check their email.
# "recipient": { "email": "alumno@correduria.es", "phone_e164": "+34600123456" }The message is delivered to the recipient's email immediately — no consent step. You get message.delivered when it is sent and message.confirmed when it is opened.
Submit a message for delivery.
| Field | Type | Required | Notes |
|---|---|---|---|
| recipient | object | Yes | Recipient — must include email (the identity key) |
| recipient.email | string | Yes | Valid email. Carries the full message AND identifies the recipient. Sending with no email → 422 |
| recipient.phone_e164 | string | No | E.164: +34600123456. Used only for the opt-in SMS heads-up, never for content |
| recipient.handle | string | Legacy | pp_r_<uuid> from POST /v1/recipients. Prefer email. If sent, also include email so we self-correct a stale handle |
| envelope | object | Yes | Message contents (encrypted at rest) |
| envelope.subject | string | Yes | Max 200 chars — the email subject |
| envelope.body | string | Yes | Max 10,000 chars — the email body |
| envelope.cta | object | No | Call-to-action button (required if ack_tier=cryptographic) |
| envelope.cta.label | string | Yes* | Button label (max 60) |
| envelope.cta.url | string | Yes* | Button URL (https only) |
| ack_tier | enum | No | best_effort (default) | cryptographic |
| priority | enum | No | normal (default) | urgent |
| ttl_hours | number | No | 1–720, default 168 (7 days) |
| external_ref | string | No | Your internal ID, returned in webhooks (max 200) |
Identity is keyed by email
Send recipient.email on every request. We resolve the recipient by email, so if a person's email changes you simply send the new one — no sync call needed. The opaque pp_r_ handle from POST /v1/recipients is legacy: it still works, but if you cache it, include the email alongside it and the email wins when they disagree.
{
"message_id": "04b6bad3-6378-41cb-8a42-d41bf052d269",
"state": "accepted", // queued for email delivery
"expires_at": "2026-05-18T09:43:19.597+00:00",
"consent_required": false, // direct delivery — no consent step
"consent_request_id": null
}| Status | Meaning |
|---|---|
| 401 | Missing or invalid Authorization header |
| 403 | App is suspended |
| 422 | Invalid payload, or recipient has no email (recipient_no_email) |
| 429 | Rate limit: too many requests (per-minute / per-hour anti-burst) |
Messages transition through states as they are processed. Terminal states will not change.
| State | Meaning | Terminal? |
|---|---|---|
| pending | Created, waiting to be routed | No |
| accepted | Queued for delivery | No |
| delivering | Email send in progress | No |
| delivered | Email handed to the mail provider | No |
| confirmed | Recipient opened the email (read receipt) | Yes |
| rejected | Sender is blocked by the recipient | Yes |
| failed | Delivery failed, bounced, or marked as spam | Yes |
| expired | TTL elapsed before delivery completed | Yes |
Message content is retained encrypted at rest (AES-256-GCM, per-recipient keys) together with its delivery evidence trail. Recipients can request erasure under GDPR; any administrative decryption outside the normal request path is audited.
Poll the status of a message.
curl https://postalprivado.com/api/v1/messages/MSG_ID \ -H "Authorization: Bearer nexo_sk_YOUR_API_KEY"
{
"id": "04b6bad3-6378-41cb-8a42-d41bf052d269",
"state": "delivered",
"created_at": "2026-05-11T09:43:19.597Z",
"expires_at": "2026-05-18T09:43:19.597Z",
"delivered_at": "2026-05-11T09:44:02.118Z",
"channel_used": "email",
"external_ref": null
}Polling is supported but webhooks are strongly recommended for production — they fire within seconds of a state change.
Postal Privado delivers every message to the recipient's email — no consent step. Identity-verified (KYC) senders deliver directly, and the recipient controls everything from their portal.
accepted → delivering → delivered, with a webhook at each stage. delivered means the email was handed to the mail provider.confirmed and you receive message.confirmed. Open tracking is best-effort — it can be inflated by Apple Mail Privacy Protection or blocked by privacy proxies — so treat it as evidence, not proof.failed with message.failed — the optimistic delivered is corrected.phone_e164 and the recipient enables it in their portal, they also get a short SMS telling them to check their email. The SMS never carries the message body.state: "rejected" and message.rejected — no email is sent.Configure a webhook URL in your dashboard settings to receive real-time event notifications.
| Event | Fired when |
|---|---|
| message.accepted | Message queued for delivery |
| message.delivered | Email handed to the mail provider |
| message.confirmed | Recipient opened the email (best-effort read receipt) |
| message.failed | Delivery failed, bounced, or marked as spam |
| message.rejected | Recipient has blocked your app |
| message.link_clicked | Recipient clicked the CTA link |
{
"event": "message.delivered",
"message_id": "04b6bad3-6378-41cb-8a42-d41bf052d269",
"external_ref": "your-internal-id-or-null",
"state": "delivered",
"ack_tier": "best_effort",
"channel_used": "email",
"timestamp": "2026-05-11T09:44:02.118Z",
"evidence_summary": { "type": "provider_ack", "provider": "resend" }
}Each webhook request includes an X-Nexo-Signature header (HMAC-SHA256 of the raw body). Verify with your webhook secret:
import crypto from "crypto";
function verifyWebhook(rawBody: string, signature: string, secret: string) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express example
app.post("/webhooks/postal-privado", express.raw({ type: "*/*" }), (req, res) => {
const sig = req.headers["x-nexo-signature"] as string;
if (!verifyWebhook(req.body.toString(), sig, process.env.PPOSTAL_WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
console.log(event.event, event.message_id, event.state);
res.sendStatus(200);
});Failed webhook deliveries are retried with exponential backoff: 1 min → 5 min → 30 min → 2 h → 12 h → 24 h. After 24 hours the dispatch is marked failed and no further retries occur.
Correspondence gives your app persistent, encrypted conversation threads with a recipient, organized by topic — a business object of yours (a gig, an incident, a claim file). Postal Privado stores the thread (retained, encrypted at rest with per-recipient keys), delivers email nudges through the same pipeline as /v1/send, and keeps the evidence trail. The UI is yours: your operations inbox and your recipient portal render the thread; Postal Privado has no recipient-facing chat UI.
Idempotent per (recipient, topic_ref): posting the same topic again returns the existing conversation (200 instead of 201). Omit topic_ref for the recipient's general thread. The recipient must have an email on file (422 recipient_no_email); a recipient who blocked you returns 403.
POST /api/v1/conversations
Authorization: Bearer <api_key>
{
"recipient": { "email": "artist@example.com" },
"topic_ref": "gig:268185",
"topic_label": "Sala Apolo — 22 Aug"
}
// 201 Created (200 if it already existed)
{
"conversation_id": "afac3c9b-…",
"topic_ref": "gig:268185",
"topic_label": "Sala Apolo — 22 Aug",
"status": "open",
"unread_from_app": 0,
"unread_from_recipient": 0,
"last_message_at": null,
"recipient_portal_url": "https://postalprivado.com/p/mt_…"
}Integration requirement: your thread UI must show a discreet notice — "Canal certificado por Postal Privado" — linked to recipient_portal_url, where the recipient manages their delivery preferences and can exercise their rights. It may be small; it may not be removed.
Staff messages carry an author_label (the recipient sees people, not a company). With nudge.enabled, Postal Privado emails the full message to the recipient (subject to a per-conversation cooldown), with a CTA deep-linking into your portal. The nudge is a regular send: it consumes your monthly quota and honors the recipient's SMS heads-up opt-in. Recipient messages are posted by your backend on behalf of your portal-authenticated user.
POST /api/v1/conversations/:id/messages
{ "body": "Confirmed for Saturday — bring the setlist.",
"author_label": "Ops — Xarxa",
"nudge": { "enabled": true, "cooldown_minutes": 15,
"cta_url": "https://yourapp.com/c/<portal-token>" } }
// → { "message_id": "…", "nudge_message_id": "…" }
POST /api/v1/conversations/:id/messages/recipient
{ "body": "Got it, see you there." }
GET /api/v1/conversations/:id/messages?since=<ISO> // decrypted, for polling
GET /api/v1/conversations?since=&topic_ref= // staff inbox in one poll
POST /api/v1/conversations/:id/read
{ "reader": "recipient", "up_to_message_id": "…" } // fires conversation.message.readsince is a strict cursor: pass the created_at of the last message you have and you receive only what came after it (ascending, max 500 per call). On the inbox list, since filters by last_message_at (max 200, most recent first). For /read: reader is who read — it marks the other side's messages as read up to (and including) up_to_message_id.
// GET /api/v1/conversations/:id/messages → 200
{
"conversation_id": "afac3c9b-…",
"messages": [
{ "message_id": "0f544e7e-…", "direction": "app",
"author_label": "Ops — Xarxa",
"body": "Confirmed for Saturday — bring the setlist.",
"channel_origin": "web", "nudge_message_id": "93b78e8c-…",
"created_at": "2026-07-11T05:17:07Z", "read_at": null },
{ "message_id": "a88cb5bf-…", "direction": "recipient",
"author_label": null,
"body": "Got it, see you there.",
"channel_origin": "web", "nudge_message_id": null,
"created_at": "2026-07-11T05:17:11Z", "read_at": null }
]
}Two new event types arrive through your existing webhook endpoint, signed with the same HMAC scheme: conversation.message.received (the recipient wrote — what your staff inbox listens for) and conversation.message.read. Message bodies never travel in webhooks — fetch them via GET.
// POST to your webhook_url — headers:
// X-Postalprivado-Signature: sha256=<hmac of the raw body>
// X-Nexo-Signature: (legacy alias, same value)
{ "event": "conversation.message.received",
"conversation_id": "afac3c9b-…",
"topic_ref": "gig:268185",
"timestamp": "2026-07-11T05:17:11Z",
"message_id": "a88cb5bf-…", // the conversation message to GET
"channel_origin": "web" }
{ "event": "conversation.message.read",
"conversation_id": "afac3c9b-…",
"topic_ref": "gig:268185",
"timestamp": "2026-07-11T06:02:40Z",
"up_to_message_id": "0f544e7e-…",
"messages_read": 1 }Rate limits, separate from your send quota: 10 messages/min per conversation, 60/min per app. Roadmap: recipients replying directly from their email client into the thread, and SMS/WhatsApp nudges on higher plans (always subject to the recipient's opt-in).
Each plan has three layers of limits — a monthly cap, anti-burst rate limits, and per-channel sub-caps. Anti-burst limits are hard: exceeding them returns 429 Too Many Requests. Monthly caps are currently soft: sends past the cap keep working while we contact you about upgrading. Every response carries X-RateLimit-* headers indicating remaining quota.
| Plan | Monthly total |
|---|---|
| Free | 100 |
| Starter | 5.000 |
| Pro | 40.000 |
| Enterprise | negotiated |
SMS notices carry real per-message carrier costs, so each plan has a ceiling on them. Email volume is bounded only by the monthly total cap. WhatsApp delivery is not currently available (see note below).
| Plan | SMS notices/mo | Email/mo |
|---|---|---|
| Free | 0 | rest of total |
| Starter | 0 | rest of total |
| Pro | 50 | rest of total |
| Enterprise | negotiated | negotiated |
SMS is a notice, not a content channel. The SMS sub-cap counts short heads-up texts ("you have a message — check your inbox") that recipients opt into from their preferences portal. SMS never carries message content; the full message is always delivered by email.
WhatsApp is not currently available. The channel is on the roadmap. When it ships it will be utility-only (messages with message_class: "marketing" routed to WhatsApp are rejected with 422) and capped per plan. Until then, all content is delivered by email.
What happens when you hit a monthly cap. During the current ramp-up period sends keep working past monthly caps and we reach out about the right plan. Anti-burst limits below are always enforced with 429.
These windows reset continuously (sliding window on created_at). They protect against accidental loops, not legitimate batch sends — if you consistently hit them, you should be on a higher plan.
| Plan | Per minute | Per hour |
|---|---|---|
| Free | 10 | 100 |
| Starter | 30 | 500 |
| Pro | 120 | 2.000 |
| Enterprise | custom | custom |
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit-Hour: 500
X-RateLimit-Remaining-Hour: 487
X-RateLimit-Limit-Minute: 30
X-RateLimit-Remaining-Minute: 0
{
"error": "Per-minute rate limit reached",
"reason": "per_minute"
}Postal Privado sits on top of carriers (Resend for email, LabsMobile for SMS) whose delivery guarantees we inherit. Each delivery attempt receives exponential retries (1m → 5m → 30m → 2h → 12h → 24h) with dead-letter after 6 failures within 24 hours. Free / Starter / Pro plans carry no contractual SLA; SLA terms are available on Enterprise.
2026-07-11
POST /v1/send must include recipient.email. Requests without it return 422 recipient_no_email. Email is the primary identity key for recipients.message.opened webhook event and the opened_at field on message responses are now populated from actual email open tracking — not estimated. Click tracking is intentionally disabled.opened_at, clicked_at) without requiring the recipient to act immediately./v1/conversations endpoints for two-way certified threads. See the Correspondence section.