API Reference

Postal Privado delivers messages to your customers via their preferred channel — WhatsApp, email, or SMS — 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:

# Por móvil (WhatsApp / SMS)
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": { "phone_e164": "+34600123456" },
    "envelope": {
      "subject": "Acceso a tu plataforma de formación",
      "body": "Hola, aquí tienes tu enlace de acceso: https://plataforma.example.com/login?token=abc123"
    }
  }'

# Por email
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"
    }
  }'

If this is the recipient's first message from you, Postal Privado will send them a consent request. If they have already accepted, the message is delivered directly.

POST /v1/send

Submit a message for delivery.

Request body

FieldTypeRequiredNotes
recipientobjectYesRecipient identifier — must include phone_e164 OR email
recipient.phone_e164stringEitherE.164 format: +34600123456 (delivers via WhatsApp/SMS)
recipient.emailstringEitherValid email address (delivers via email)
envelopeobjectYesMessage contents (encrypted at rest)
envelope.subjectstringYesMax 200 chars — shown on consent request
envelope.bodystringYesMax 10,000 chars — only delivered after consent
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)
channel_hintenumNowhatsapp | sms | email | voice | telegram | signal

Response (202 Accepted)

{
  "message_id": "04b6bad3-6378-41cb-8a42-d41bf052d269",
  "state": "notified",            // or "accepted" if recipient already consented
  "expires_at": "2026-05-18T09:43:19.597+00:00",
  "consent_required": true,        // false once recipient has accepted
  "consent_request_id": "1a17fa2f-b95a-41f4-ab5d-089f36ffacbb"  // null when consent_required=false
}

Error responses

StatusMeaning
401Missing or invalid Authorization header
403App is suspended
422Invalid payload (validation errors in `details`)
429Anti-bombing: max 3 pending consent requests per recipient

Message states

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

StateMeaningTerminal?
pendingCreated, waiting to be routedNo
notifiedConsent request sent to recipientNo
acceptedRecipient consented — message queued for deliveryNo
deliveringDelivery in progress on the chosen channelNo
deliveredChannel acknowledged successful deliveryNo
confirmedRecipient explicitly confirmed receipt (cryptographic ack)Yes
rejectedRecipient rejected consent, or sender is blockedYes
failedAll delivery attempts failed (or rate-limited at consent)Yes
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": "whatsapp",
  "external_ref": null
}

Polling is supported but webhooks are strongly recommended for production — they fire within seconds of a state change.

Webhooks

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

Events

EventFired when
message.consent_requestedConsent message sent to recipient (first contact)
message.acceptedRecipient accepted, or preexisting consent on file
message.rejectedRecipient rejected, or you were already blocked
message.deliveredChannel confirmed delivery
message.confirmedRecipient explicitly confirmed receipt (cryptographic ack)
message.failedAll delivery attempts failed, or anti-bombing rate-limit

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": "whatsapp",
  "timestamp": "2026-05-11T09:44:02.118Z",
  "evidence_summary": { "type": "whatsapp_status", "provider": "meta" }
}

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.