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

On reaching a terminal state, the encrypted envelope is purged from storage.

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.

Fair usage policy

Each plan has three layers of limits — a monthly cap, anti-burst rate limits, and per-channel sub-caps. When any limit is hit, requests return 429 Too Many Requests with 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

WhatsApp and SMS carry real per-message carrier costs, so each plan has a hard ceiling on those channels. Email volume is bounded only by the monthly total cap.

PlanWhatsApp/moSMS/moEmail/mo
Free300rest of total
Starter2000rest of total
Pro80050rest of total
Enterprisenegotiatednegotiatednegotiated

WhatsApp is utility-only. Messages with message_class: "marketing" routed to WhatsApp are rejected with 422. Use email for marketing — WhatsApp's per-message carrier cost makes marketing-class routing structurally uneconomic on flat plans.

Why the WA cap is small. WhatsApp utility templates cost ~€0.02 each (Meta, Spain, May 2026). The advertised cap is the level at which we still hit our 40% gross-margin floor worst-case. WhatsApp top-up bundles are on the roadmap for H2 2026 — until then, the cap is hard and resets on the plan's monthly anniversary.

What happens when you hit the WA cap. New requests with channel_hint: "whatsapp" (or where WhatsApp would be auto-selected) return 429 with reason: "monthly_whatsapp". The fallback is your responsibility: retry with channel_hint: "email" if the recipient has an email on file.

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-Monthly: 5000
X-RateLimit-Remaining-Monthly: 0
X-RateLimit-Limit-Monthly-WhatsApp: 200
X-RateLimit-Remaining-Monthly-WhatsApp: 0
X-RateLimit-Limit-Hour: 500
X-RateLimit-Remaining-Hour: 487
X-RateLimit-Limit-Minute: 30
X-RateLimit-Remaining-Minute: 18

{
  "error": "Monthly message cap reached",
  "reason": "monthly_total"
}

Best-effort delivery disclosure

Postal Privado sits on top of carriers (Meta WhatsApp Cloud API, Twilio, Resend) 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.