Batch, rates and errors.
Beyond single send: bulk ingest with partial success, tenant metrics, the uniform error envelope, certified vs plain and online evidence verification.
- 01POST /v1/messages/batch
Batch ingest (≤100)
One JSON request with up to 100 messages. Each item has its own idempotency_key. The response lists per-index results and created / failed / replayed counters.
max 100partial successempty_batchbatch_too_largePOST /v1/messages/batch curl -s -X POST "$MAILACK_API_URL/v1/messages/batch" \ -H "Authorization: Bearer $MAILACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "idempotency_key": "order-1", "from": "noreply@acme.mx", "to": "a@ejemplo.com", "subject": "Pedido 1", "text": "Gracias" }, { "idempotency_key": "order-2", "from": "noreply@acme.mx", "to": "b@ejemplo.com", "subject": "Pedido 2", "text": "Gracias" } ] }' | jq . - 02GET /v1/rates
Deliverability rates
Tenant aggregates over a day window (query days, default 14): ingested, sent, deferred, bounced, complained, sealed, rates and daily series. Same shape in the portal Rates screen.
These are operational tenant metrics, not a public deliverability claim for the product.
GET /v1/rates?days=14 curl -s "$MAILACK_API_URL/v1/rates?days=14" \ -H "Authorization: Bearer $MAILACK_API_KEY" | jq . - 03auth
API key scopes
The Bearer key resolves the tenant. Common scopes:
messages:sendsend, batch, domains, webhooks, templatesevidence:readreads, lists, rates, proof-bundlesuppressions:writesuppressionsapi_keys:writetenant key management - 04errors
Envelope, codes and HTTP status
Uniform shape: {"error":{"code":"snake_case","message":"…"}}. The message is human-readable and usually tells you the fix (e.g. "add and verify it in the portal"). Each code always maps to the same HTTP status:
400 · missing_idempotency_keymissing Idempotency-Key header on ingest; it is required and makes retries safe400 · invalid_bodymalformed JSON or no content: at least one of text or html is required400 · invalid_addressempty or malformed From/To address401 · unauthorizedmissing or invalid API key in Authorization: Bearer403 · insufficient_scopethe key is valid but lacks the scope the route requires403 · tenant_suspended / tenant_deactivatedthe account is suspended or deactivated; contact support404 · not_foundthe resource does not exist (or is not visible to your tenant: a cross-tenant read answers 404, not 403)404 · template_not_foundthe template_id used in the send does not exist in the tenant415 · unsupported_media_typeContent-Type other than application/json or message/rfc822422 · domain_not_verifiedthe From domain is not verified on your account; register it, publish the challenge TXT and verify it (Domains guide)422 · from_not_approvedthe sender is not an approved active sender of the verified domain422 · recipient_suppressedthe recipient is on the suppression list (global or tenant); the dispatcher will not queue it422 · template_render_errorfailed to render the template's {{variables}} with the supplied data422 · not_certifiedtried to seal a message sent with certified=false; there is no silent upgrade to certified422 · missing_proof_datathe message is not sealed into a Merkle batch yet (or it is plain and never will be); retry after sealing429 · quota_exceededmonthly quota exhausted; the message names the bag ("certified" or "plain"). Independent bags per UTC calendar month: exhausting one does not block the other; idempotent replay does not consume quotaThe complete, normative list of codes lives in the repository's OpenAPI contract (docs/openapi.yaml, Error schema). This table covers the errors you will hit while integrating sending.
- 05surfaces
Machine, portal and admin
Machine API (/v1/* with API key) is for SDKs and MCP. Portal (/v1/portal/*) uses the customer user session. Admin (/v1/admin/*) is the JAAK operator console.
API keyportal sessionadmin session - 06certified
Certified vs plain
Every message carries a certified flag. JSON sends accept the per-message override "certified": false; when omitted, the account default (default_certified) applies. Raw message/rfc822 bodies take no override: they always use the account default. A plain message is delivered the same way, but it never enters the Merkle tree: sealing it answers 422 not_certified and its proof-bundle 422 missing_proof_data. There is no silent upgrade to certified.
certified=truecanonicalized, Merkle leaf, sealable with NOM-151; quota_messages_month bagcertified=falsetransactional delivery with SMTP transcript and DSN, no seal; quota_plain_messages_month bagindependent quotasexhausting the plain bag does not block the certified one, and vice versabillingprice_per_message_cents (certified) and price_per_plain_message_cents (plain), per accountPOST /v1/messages with certified=false curl -s -X POST "$MAILACK_API_URL/v1/messages" \ -H "Authorization: Bearer $MAILACK_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "from": "noreply@acme.mx", "to": "cliente@ejemplo.com", "subject": "Aviso operativo", "html": "<p>Mensaje transaccional sin certificación.</p>", "certified": false }' | jq '{id, certified}' - 07GET /v1/messages/{id}/evidence · POST /v1/verify
Online evidence and verification
Besides the downloadable proof-bundle —which anyone can check at mailack.com/verificar, in their browser with nothing uploaded—, the API exposes evidence online: /evidence returns the sealed message summary (canonical hash, batch_id, Merkle root, certificate_id, sealed_at) and /v1/verify recomputes the Merkle inclusion proof by message_id and answers valid: true|false. Every verification consult is recorded in the access log.
evidenceonline verifyproof-bundleGET /v1/messages/{id}/evidence · POST /v1/verify # Resumen de evidencia del mensaje (hash, batch, raíz Merkle, constancia) curl -s "$MAILACK_API_URL/v1/messages/$MESSAGE_ID/evidence" \ -H "Authorization: Bearer $MAILACK_API_KEY" | jq . # Verificación online de la prueba de inclusión Merkle curl -s -X POST "$MAILACK_API_URL/v1/verify" \ -H "Authorization: Bearer $MAILACK_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"message_id\": \"$MESSAGE_ID\"}" | jq . # → {"valid": true, "merkle_root": "…", "certificate_id": "…", "sealed_at": "…"}