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 — cta_url is optional: omit it and the CTA links to the recipient's hosted conversation page on postalprivado.com, so you can run conversations with no frontend of your own. 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>" } }
// cta_url optional — defaults to the hosted thread page
// → { "message_id": "…", "nudge_message_id": "…" }
POST /api/v1/conversations/:id/messages/recipient
{ "body": "Got it, see you there.",
"reply_to_message_id": "0f544e7e-…" } // optional quote anchor (both endpoints);
// must reference a message in the same stream
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,
"reply_to_message_id": "0f544e7e-…", // render the quoted snippet from your copy
"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",
"reply_to_message_id": "0f544e7e-…" } // null unless the reply quotes a message
{ "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).
Embed the conversation thread directly in your own app as an iframe. Postal Privado hosts the UI: your customer reads and replies in place, replies land in the same canonical stream as the API, and you never handle their contact details. Available to KYC-verified apps.
Your backend exchanges its API key for a short-lived widget session. Never call this from the browser — the API key must stay on your server.
POST /api/v1/widget/sessions
Authorization: Bearer nexo_sk_YOUR_API_KEY
{ "recipient": { "email": "ana@example.com" },
"topic_ref": "gig:268185", // optional — scopes the thread to a topic
"origin": "https://yourapp.com", // the page that will embed the iframe
"scope": "conversation" } // or "inbox" for the multi-thread feed
// → 202
{ "session_token": "ws_…",
"widget_url": "https://postalprivado.com/widget?s=ws_…",
"scope": "conversation",
"conversation_id": "afac3c9b-…",
"expires_at": "2026-07-22T13:00:00Z" } // sessions live 1 hour<iframe src="{widget_url}" style="width: 100%; height: 480px; border: 0;" />The widget only loads inside the origin you declared when issuing the session — enforced with a frame-ancestors CSP header. An invalid or expired token serves frame-ancestors 'none', which the browser reports as a bare “refused to connect” inside the iframe. Request a fresh session on every page load— don't cache the token or the URL.
conversation renders a single thread (resolved from topic_ref, creating the conversation if needed — same semantics as POST /v1/conversations). inbox renders every conversation between your app and that recipient, with unread badges.
Recipient replies (including quoted replies via reply_to_message_id) enter the canonical stream and fire conversation.message.received on your webhook, exactly like the recipient portal. Messages are marked read when they are actually on screen — the widget waits for the tab to be visible — and a read clears any pending email nudge for those messages. You don't poll anything: react to webhooks as usual.
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.
Correspondence and the embeddable widget are included in every plan. What scales with the plan is how many distinct recipients your app can hold conversations with (one canonical stream per recipient):
| Plan | Conversation recipients |
|---|---|
| Free | 9 |
| Starter | 100 |
| Pro | 5.000 |
| Enterprise | negotiated |
The cap is enforced only when opening a conversation with a new recipient: the request returns 403 conversation_recipient_cap. Existing threads keep working and inbound replies are never blocked. Conversation messages themselves don't consume your monthly message cap — the email nudges they trigger do.
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-08-06
/p/<token>/mensajes, reachable from their preferences portal. If you omit nudge.cta_url (now optional), the nudge email links there — you can run certified conversations without embedding anything.recipient.external_ref is now accepted on POST /v1/conversations and widget sessions. Pass your stable internal ID alongside the email and an email change updates the same recipient in place — the conversation never forks. Conflicts return 409 email_taken; an unknown ref without an email returns 422 unknown_external_ref.2026-07-29
reply_to_message_id is accepted on both message POSTs (staff and recipient), validated against the stream (422 reply_to_not_found), and echoed in GET responses and the conversation.message.received webhook. Render the quoted snippet from your own copy — bodies never travel twice.POST /v1/conversations on an existing topic now returns live unread counts and last_message_at aggregated from the canonical stream, never frozen alias values.+tag and dotted-Gmail variants of a known address now match their recipient. Registration still treats +tag addresses as distinct identities.2026-07-22
topic_ref tags messages within it. Existing per-topic conversation IDs keep working as aliases — addressing one filters its topic. One feed call now returns everything.conversation and inbox scopes, deferred email nudges suppressed on read.2026-07-21
replies.postalprivado.com; a recipient answering from their email client lands in the conversation and fires conversation.message.received with channel_origin: "email".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.