Postal Privado delivers messages to your customers via their preferred channel — WhatsApp, email, or SMS — 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:
# 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.
Submit a message for delivery.
| Field | Type | Required | Notes |
|---|---|---|---|
| recipient | object | Yes | Recipient identifier — must include phone_e164 OR email |
| recipient.phone_e164 | string | Either | E.164 format: +34600123456 (delivers via WhatsApp/SMS) |
| recipient.email | string | Either | Valid email address (delivers via email) |
| envelope | object | Yes | Message contents (encrypted at rest) |
| envelope.subject | string | Yes | Max 200 chars — shown on consent request |
| envelope.body | string | Yes | Max 10,000 chars — only delivered after consent |
| 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) |
| channel_hint | enum | No | whatsapp | sms | email | voice | telegram | signal |
{
"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
}| Status | Meaning |
|---|---|
| 401 | Missing or invalid Authorization header |
| 403 | App is suspended |
| 422 | Invalid payload (validation errors in `details`) |
| 429 | Anti-bombing: max 3 pending consent requests per recipient |
Messages transition through states as they are processed. Terminal states will not change.
| State | Meaning | Terminal? |
|---|---|---|
| pending | Created, waiting to be routed | No |
| notified | Consent request sent to recipient | No |
| accepted | Recipient consented — message queued for delivery | No |
| delivering | Delivery in progress on the chosen channel | No |
| delivered | Channel acknowledged successful delivery | No |
| confirmed | Recipient explicitly confirmed receipt (cryptographic ack) | Yes |
| rejected | Recipient rejected consent, or sender is blocked | Yes |
| failed | All delivery attempts failed (or rate-limited at consent) | Yes |
| expired | TTL elapsed before delivery completed | Yes |
On reaching a terminal state, the encrypted envelope is purged from storage.
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.
Postal Privado is built around recipient consent. Here's how it works:
state: "notified" and consent_required: true. The body of the original message is NOT shown to the recipient yet — only envelope.subject appears on the consent screen.accepted → delivering → delivered. You receive webhooks at each stage.state: "accepted" immediately and skip the consent step.rejected and future sends are blocked. They can re-enable from their recipient portal.state: "failed".Configure a webhook URL in your dashboard settings to receive real-time event notifications.
| Event | Fired when |
|---|---|
| message.consent_requested | Consent message sent to recipient (first contact) |
| message.accepted | Recipient accepted, or preexisting consent on file |
| message.rejected | Recipient rejected, or you were already blocked |
| message.delivered | Channel confirmed delivery |
| message.confirmed | Recipient explicitly confirmed receipt (cryptographic ack) |
| message.failed | All delivery attempts failed, or anti-bombing rate-limit |
{
"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" }
}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.
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.
| Plan | Monthly total |
|---|---|
| Free | 100 |
| Starter | 5.000 |
| Pro | 40.000 |
| Enterprise | negotiated |
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.
| Plan | WhatsApp/mo | SMS/mo | Email/mo |
|---|---|---|---|
| Free | 30 | 0 | rest of total |
| Starter | 200 | 0 | rest of total |
| Pro | 800 | 50 | rest of total |
| Enterprise | negotiated | negotiated | negotiated |
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.
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-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"
}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.