API Reference

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.

Authentication

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.

Quickstart

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.

POST /v1/send

Submit a message for delivery.

Request body

FieldTypeRequiredNotes
recipientobjectYesRecipient — must include email (the identity key)
recipient.emailstringYesValid email. Carries the full message AND identifies the recipient. Sending with no email → 422
recipient.phone_e164stringNoE.164: +34600123456. Used only for the opt-in SMS heads-up, never for content
recipient.handlestringLegacypp_r_<uuid> from POST /v1/recipients. Prefer email. If sent, also include email so we self-correct a stale handle
envelopeobjectYesMessage contents (encrypted at rest)
envelope.subjectstringYesMax 200 chars — the email subject
envelope.bodystringYesMax 10,000 chars — the email body
envelope.ctaobjectNoCall-to-action button (required if ack_tier=cryptographic)
envelope.cta.labelstringYes*Button label (max 60)
envelope.cta.urlstringYes*Button URL (https only)
ack_tierenumNobest_effort (default) | cryptographic
priorityenumNonormal (default) | urgent
ttl_hoursnumberNo1–720, default 168 (7 days)
external_refstringNoYour 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.

Response (202 Accepted)

{
  "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
}

Error responses

StatusMeaning
401Missing or invalid Authorization header
403App is suspended
422Invalid payload, or recipient has no email (recipient_no_email)
429Rate limit: too many requests (per-minute / per-hour anti-burst)

Message states

Messages transition through states as they are processed. Terminal states will not change.

StateMeaningTerminal?
pendingCreated, waiting to be routedNo
acceptedQueued for deliveryNo
deliveringEmail send in progressNo
deliveredEmail handed to the mail providerNo
confirmedRecipient opened the email (read receipt)Yes
rejectedSender is blocked by the recipientYes
failedDelivery failed, bounced, or marked as spamYes
expiredTTL elapsed before delivery completedYes

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.

GET /v1/messages/:id

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.

Delivery flow

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.

  1. Send → delivered: The message goes out by email immediately. State moves accepted delivering delivered, with a webhook at each stage. delivered means the email was handed to the mail provider.
  2. Read receipt: When the recipient opens the email, state becomes 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.
  3. Bounce or complaint: If the email bounces or is marked as spam, state flips to failed with message.failed — the optimistic delivered is corrected.
  4. SMS heads-up (optional): If you include 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.
  5. Blocking: A recipient can block your app from their portal. Sends to a blocked recipient return immediately with state: "rejected" and message.rejected — no email is sent.

Webhooks

Configure a webhook URL in your dashboard settings to receive real-time event notifications.

Events

EventFired when
message.acceptedMessage queued for delivery
message.deliveredEmail handed to the mail provider
message.confirmedRecipient opened the email (best-effort read receipt)
message.failedDelivery failed, bounced, or marked as spam
message.rejectedRecipient has blocked your app
message.link_clickedRecipient clicked the CTA link

Payload

{
  "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" }
}

Verifying signatures

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);
});

Retry schedule

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 — topic threads

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.

Create (or fetch) a conversation

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.

Post messages

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.read

Reading the thread

since 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 }
  ]
}

Webhooks

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).

Fair usage policy

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.

Monthly caps (the "X messages/mo" advertised)

PlanMonthly total
Free100
Starter5.000
Pro40.000
Enterprisenegotiated

Per-channel sub-caps

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).

PlanSMS notices/moEmail/mo
Free0rest of total
Starter0rest of total
Pro50rest of total
Enterprisenegotiatednegotiated

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.

Anti-burst rate limits

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.

PlanPer minutePer hour
Free10100
Starter30500
Pro1202.000
Enterprisecustomcustom

Reading the response headers

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"
}

Best-effort delivery disclosure

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.

Changelog

2026-07-11

  • Email is now required. Every POST /v1/send must include recipient.email. Requests without it return 422 recipient_no_email. Email is the primary identity key for recipients.
  • Real read receipts. The 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.
  • Universal retention. Messages are no longer deleted after delivery. Encrypted content is retained for the full delivery TTL. This enables audit trails and read-receipt evidence (opened_at, clicked_at) without requiring the recipient to act immediately.
  • Correspondence API (v1). New /v1/conversations endpoints for two-way certified threads. See the Correspondence section.