Transactional Messaging API

One API for transactional email, SMS and mobile push. Send a message, get an operation id back, and learn the outcome by polling or by webhook.

202 means queued, not delivered

Every send returns 202 Accepted with an operationId. Delivery outcome comes from operation status or the delivery report — never from the send response.

Every send needs an idempotency key

idempotencyKey is required on all three channels. It is what makes a retry safe — read the idempotency contract before you write any retry logic.

Download for AI review

Copy or download this guide as Markdown to paste into an AI assistant for help integrating against it.

Environment

You are reading the guide for the host that served this page. The badge in the header and every example below already point at it — nothing to substitute by hand, and no other environment's addresses appear on this page.

PropertyValue
Environment
Base URLhttps://messaging.olanzo.com
All pathsare under /api/v1/messaging

Authentication

Unlike the other Olanzo APIs, this one issues its own tokens — you do not go to the Authenticate host. Exchange your client credentials here, then send the token as Authorization: Bearer <accessToken>.

Keep your client secret on your server

A secret embedded in a browser page or a mobile app is a published secret — anyone can read it and send messages billed to you. Call the token endpoint from your own backend only.

POST /api/v1/messaging/token

Exchanges client credentials for an access token.

Tokens last 3600 seconds. There is no refresh token — call this again when it expires.

Parameters

NameTypeRequiredDescription
clientIdstringYesYour integrator client id.
clientSecretstringYesYour integrator client secret. Server-side only.

Responses

StatusMeaning
200Returns accessToken and expiresIn (3600).
401The credentials were not recognised.
curl -X POST https://messaging.olanzo.com/api/v1/messaging/token \
  -H "Content-Type: application/json" \
  -d '{"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"}'
const payload = {"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"};

const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };

var payload = new StringContent(
    """
    {"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"}
    """,
    Encoding.UTF8,
    "application/json");

var response = await http.PostAsync("/api/v1/messaging/token", payload);
import requests

headers = {}
payload = {"clientId":"<your-client-id>","clientSecret":"<your-client-secret>"}

response = requests.post(
    "https://messaging.olanzo.com/api/v1/messaging/token",
    headers=headers,
    json=payload,
)

Send email

POST /api/v1/messaging/email/send

Queues one transactional email.

Parameters

NameTypeRequiredDescription
idempotencyKeystringYesA stable key for this business operation. Reusing it with the same content returns the original operation; reusing it with different content is a 409. See the contract.
fromstringYesSender address. Must be on your allowlist, or the request is a 400.
replyTostringNoWhere replies go.
subjectstringYesSubject line. Substitution tags work here.
emailBodystringYesThe message body. Substitution tags work here too.
recipientobjectYesWho it goes to — { "to": "..." }.
attachmentsarrayNoOptional file attachments.

Responses

StatusMeaning
202Queued. Returns an operationId.
400Validation failed — bad format, a sender or app not on your allowlist, an invalid number, or a substitution tag that does not occur in the content.
401Missing or expired bearer token, or invalid credentials.
403Your account is not enabled for this channel.
409The idempotency key was reused with a different payload.
curl -X POST https://messaging.olanzo.com/api/v1/messaging/email/send \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{"idempotencyKey":"order-1042-confirmation","from":"no-reply@yourcompany.com","replyTo":"support@yourcompany.com","subject":"Your order is confirmed","emailBody":"<p>Thanks for your order.</p>","recipient":{"to":"customer@example.com"},"attachments":[{"content":"<base64-of-the-file>","filename":"invoice-1042.pdf","type":"application/pdf","disposition":"attachment"}]}'
const payload = {"idempotencyKey":"order-1042-confirmation","from":"no-reply@yourcompany.com","replyTo":"support@yourcompany.com","subject":"Your order is confirmed","emailBody":"<p>Thanks for your order.</p>","recipient":{"to":"customer@example.com"},"attachments":[{"content":"<base64-of-the-file>","filename":"invoice-1042.pdf","type":"application/pdf","disposition":"attachment"}]};

const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/email/send", {
  method: "POST",
  headers: { "Authorization": "Bearer <accessToken>", "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var payload = new StringContent(
    """
    {"idempotencyKey":"order-1042-confirmation","from":"no-reply@yourcompany.com","replyTo":"support@yourcompany.com","subject":"Your order is confirmed","emailBody":"<p>Thanks for your order.</p>","recipient":{"to":"customer@example.com"},"attachments":[{"content":"<base64-of-the-file>","filename":"invoice-1042.pdf","type":"application/pdf","disposition":"attachment"}]}
    """,
    Encoding.UTF8,
    "application/json");

var response = await http.PostAsync("/api/v1/messaging/email/send", payload);
import requests

headers = {"Authorization": "Bearer <accessToken>"}
payload = {"idempotencyKey":"order-1042-confirmation","from":"no-reply@yourcompany.com","replyTo":"support@yourcompany.com","subject":"Your order is confirmed","emailBody":"<p>Thanks for your order.</p>","recipient":{"to":"customer@example.com"},"attachments":[{"content":"<base64-of-the-file>","filename":"invoice-1042.pdf","type":"application/pdf","disposition":"attachment"}]}

response = requests.post(
    "https://messaging.olanzo.com/api/v1/messaging/email/send",
    headers=headers,
    json=payload,
)

Send SMS

International numbers are supported

Send any valid E.164 number. An 8-digit number with no country code is treated as Costa Rica.

POST /api/v1/messaging/sms/send

Queues one transactional SMS.

Parameters

NameTypeRequiredDescription
idempotencyKeystringYesA stable key for this business operation. Reusing it with the same content returns the original operation; reusing it with different content is a 409. See the contract.
senderstringYesSender id. Must be on your allowlist.
messagestringYesThe message text. Substitution tags work here.
tostringYesDestination in E.164, e.g. +50680000000. A bare 8-digit number is taken as Costa Rica.
personalizationSubstitutionTagsarrayNoSee Substitution tags.
callbackUrlstringNoWhere we post delivery updates for this message.

Responses

StatusMeaning
202Queued. Returns an operationId.
400Validation failed — bad format, a sender or app not on your allowlist, an invalid number, or a substitution tag that does not occur in the content.
401Missing or expired bearer token, or invalid credentials.
403Your account is not enabled for this channel.
409The idempotency key was reused with a different payload.
curl -X POST https://messaging.olanzo.com/api/v1/messaging/sms/send \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{"idempotencyKey":"order-1042-dispatch","sender":"YOURBRAND","message":"Hola {{firstName}}, your order is on its way.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"callbackUrl":"https://yourcompany.com/webhooks/messaging"}'
const payload = {"idempotencyKey":"order-1042-dispatch","sender":"YOURBRAND","message":"Hola {{firstName}}, your order is on its way.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"callbackUrl":"https://yourcompany.com/webhooks/messaging"};

const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/sms/send", {
  method: "POST",
  headers: { "Authorization": "Bearer <accessToken>", "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var payload = new StringContent(
    """
    {"idempotencyKey":"order-1042-dispatch","sender":"YOURBRAND","message":"Hola {{firstName}}, your order is on its way.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"callbackUrl":"https://yourcompany.com/webhooks/messaging"}
    """,
    Encoding.UTF8,
    "application/json");

var response = await http.PostAsync("/api/v1/messaging/sms/send", payload);
import requests

headers = {"Authorization": "Bearer <accessToken>"}
payload = {"idempotencyKey":"order-1042-dispatch","sender":"YOURBRAND","message":"Hola {{firstName}}, your order is on its way.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"callbackUrl":"https://yourcompany.com/webhooks/messaging"}

response = requests.post(
    "https://messaging.olanzo.com/api/v1/messaging/sms/send",
    headers=headers,
    json=payload,
)

Send a mobile push

Push is addressed by phone number, not device token

to is an E.164 number — the same identifier you use for SMS. We resolve it to the customer's signed-in devices.

POST /api/v1/messaging/push/send

Queues one mobile push notification.

Parameters

NameTypeRequiredDescription
idempotencyKeystringYesA stable key for this business operation. Reusing it with the same content returns the original operation; reusing it with different content is a 409. See the contract.
appIdstringYesWhich mobile app to deliver to. Must be on your allowlist.
titlestringYesNotification title.
messagestringYesNotification body.
tostringYesThe recipient's E.164 number — not a device token. If nobody is signed in to the app for that number, the operation reports Failed with no registered device.
dataobjectNoKey-value payload delivered alongside the notification, for the app to act on.
personalizationSubstitutionTagsarrayNoSee Substitution tags.

Responses

StatusMeaning
202Queued. Returns an operationId.
400Validation failed — bad format, a sender or app not on your allowlist, an invalid number, or a substitution tag that does not occur in the content.
401Missing or expired bearer token, or invalid credentials.
403Your account is not enabled for this channel.
409The idempotency key was reused with a different payload.
curl -X POST https://messaging.olanzo.com/api/v1/messaging/push/send \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{"idempotencyKey":"order-1042-arrived","appId":"olanzo-consumer","title":"Your order has arrived","message":"Hola {{firstName}}, it is waiting at reception.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"data":{"orderId":"ORD-1042","screen":"order-detail"}}'
const payload = {"idempotencyKey":"order-1042-arrived","appId":"olanzo-consumer","title":"Your order has arrived","message":"Hola {{firstName}}, it is waiting at reception.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"data":{"orderId":"ORD-1042","screen":"order-detail"}};

const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/push/send", {
  method: "POST",
  headers: { "Authorization": "Bearer <accessToken>", "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var payload = new StringContent(
    """
    {"idempotencyKey":"order-1042-arrived","appId":"olanzo-consumer","title":"Your order has arrived","message":"Hola {{firstName}}, it is waiting at reception.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"data":{"orderId":"ORD-1042","screen":"order-detail"}}
    """,
    Encoding.UTF8,
    "application/json");

var response = await http.PostAsync("/api/v1/messaging/push/send", payload);
import requests

headers = {"Authorization": "Bearer <accessToken>"}
payload = {"idempotencyKey":"order-1042-arrived","appId":"olanzo-consumer","title":"Your order has arrived","message":"Hola {{firstName}}, it is waiting at reception.","to":"+50680000000","personalizationSubstitutionTags":[{"tagName":"firstName","tagValue":"María"}],"data":{"orderId":"ORD-1042","screen":"order-detail"}}

response = requests.post(
    "https://messaging.olanzo.com/api/v1/messaging/push/send",
    headers=headers,
    json=payload,
)

Idempotency contract

Send a stable idempotencyKey per business operation so a retry cannot produce a duplicate message. Key it to the thing that happened — order-1042-confirmation — not to the attempt.

ConditionResponseWhat happens
New key202 AcceptedA new operation is created and queued.
Same key, same content202 AcceptedYou get the original operation back. Nothing is sent twice.
Same key, different content409 ConflictRejected. Use a new key for a genuinely different message.
A 409 means your key is being reused for something else

It is not a transient failure and retrying will not clear it. Either the content changed when it should not have, or two different messages are sharing a key.

Substitution tags

Pass key-value markers in personalizationSubstitutionTags and we replace them inside the subject, the email body or the SMS message.

A tag that does not appear in the content is rejected

Sending a tag the message never uses returns 400. That is deliberate — it catches a renamed placeholder before your customer receives a message with a gap in it.

{
  "idempotencyKey": "order-1042-dispatch",
  "sender": "YOURBRAND",
  "message": "Hi [firstname], your order is on its way.",
  "to": "+50680000000",
  "personalizationSubstitutionTags": [
    { "key": "[firstname]", "value": "Ana" }
  ]
}

Operation status

GET /api/v1/messaging/{channel}/operations/{operationId}

Reads the current state of a queued operation.

channel is email, sms or push.

Parameters

NameTypeRequiredDescription
channelstringYesemail, sms or push.
operationIdstringYesThe id the send returned.

Responses

StatusMeaning
200The operation's current state.
401Missing or expired bearer token.
404No such operation, or it does not belong to your account.
curl -X GET https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f \
  -H "Authorization: Bearer <accessToken>"
const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f", {
  method: "GET",
  headers: { "Authorization": "Bearer <accessToken>" },
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var response = await http.GetAsync("/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f");
import requests

headers = {"Authorization": "Bearer <accessToken>"}
response = requests.get(
    "https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f",
    headers=headers,
)

Delivery report

Per-recipient outcome for one operation, with submit, delivery and failure times and a reason where there is one. This is the source of truth — webhooks are a fast signal, this is the ledger.

GET /api/v1/messaging/{channel}/operations/{operationId}/delivery-report

Reads the per-recipient delivery outcome for one operation.

Parameters

NameTypeRequiredDescription
channelstringYesemail, sms or push.
operationIdstringYesThe id the send returned.

Responses

StatusMeaning
200Per-recipient outcomes with timings and failure reasons.
401Missing or expired bearer token.
404No such operation, or it does not belong to your account.
curl -X GET https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f/delivery-report \
  -H "Authorization: Bearer <accessToken>"
const response = await fetch("https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f/delivery-report", {
  method: "GET",
  headers: { "Authorization": "Bearer <accessToken>" },
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

var response = await http.GetAsync("/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f/delivery-report");
import requests

headers = {"Authorization": "Bearer <accessToken>"}
response = requests.get(
    "https://messaging.olanzo.com/api/v1/messaging/email/operations/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f/delivery-report",
    headers=headers,
)

Delivery webhooks

Rather than polling, give us a callbackUrl and we post each outcome to it as it happens. This is what we send you:

POST https://your-server.example/olanzo-webhook
Content-Type: application/json

{
  "operationId": "9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f",
  "channel": "sms",
  "to": "+50680000000",
  "status": "Delivered"
}
Webhooks are at-least-once, so treat them as a signal

A webhook can arrive more than once and can be retried. Use it to react quickly, and use the delivery report whenever you need to be certain — for reconciliation, billing, or anything you would defend to a customer.

Delivery outcomes only

We do not send opens or clicks to your endpoint. Those are engagement signals rather than delivery outcomes, and this channel is kept to delivery reporting.

Health probe

A liveness probe you can call without a token, useful for confirming reachability from your own monitoring. It says the service is up — not that a message will deliver.

curl -X GET https://messaging.olanzo.com/health
const response = await fetch("https://messaging.olanzo.com/health", {
  method: "GET",
});
using var http = new HttpClient { BaseAddress = new Uri("https://messaging.olanzo.com") };

var response = await http.GetAsync("/health");
import requests

headers = {}
response = requests.get(
    "https://messaging.olanzo.com/health",
    headers=headers,
)

Errors

StatusNameWhat it means
202AcceptedMessage queued successfully.
400ValidationFailedInvalid format, a sender or app not on your allowlist, an invalid phone number, or a substitution tag that does not occur in the content.
401UnauthorizedMissing or expired bearer token, or invalid credentials.
403ForbiddenYour account is not enabled for that channel.
404NotFoundOperation id not found, or not yours.
409IdempotencyKeyReuseThe idempotency key was reused with a different payload.

Questions people actually ask

Why is there no refresh token?
The client-credentials grant does not use them (RFC 6749 §4.4.3). Call /api/v1/messaging/token again with your client secret when the token expires.

Is international SMS supported?
Yes. Send any valid E.164 number. An 8-digit number with no country code is treated as Costa Rica.

Should I poll, or use webhooks?
Use webhooks to learn each outcome as it happens, and treat the delivery report as the source of truth for reconciliation or any time you need certainty. Webhooks are delivered at least once and can be retried, so they are a fast signal rather than a ledger.

A push says Failed with “no registered device”. Why?
Nobody is signed in to the mobile app for that phone number, so there was no device to deliver to. Send an SMS instead where the message must reach the customer regardless.

Do you tell me when a customer opens an email or clicks a link?
Not through webhooks. Those are engagement signals rather than delivery outcomes, and we keep your endpoint to delivery reporting only.