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.
Every send returns 202 Accepted with an operationId. Delivery outcome comes from operation status or the delivery report — never from the send response.
idempotencyKey is required on all three channels. It is what makes a retry safe — read the idempotency contract before you write any retry logic.
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.
| Property | Value |
|---|---|
| Environment | |
| Base URL | https://messaging.olanzo.com |
| All paths | are 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>.
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.
Exchanges client credentials for an access token.
Tokens last 3600 seconds. There is no refresh token — call this again when it expires.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
clientId | string | Yes | Your integrator client id. |
clientSecret | string | Yes | Your integrator client secret. Server-side only. |
Responses
| Status | Meaning |
|---|---|
200 | Returns accessToken and expiresIn (3600). |
401 | The 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
Queues one transactional email.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
idempotencyKey | string | Yes | A 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. |
from | string | Yes | Sender address. Must be on your allowlist, or the request is a 400. |
replyTo | string | No | Where replies go. |
subject | string | Yes | Subject line. Substitution tags work here. |
emailBody | string | Yes | The message body. Substitution tags work here too. |
recipient | object | Yes | Who it goes to — { "to": "..." }. |
attachments | array | No | Optional file attachments. |
Responses
| Status | Meaning |
|---|---|
202 | Queued. Returns an operationId. |
400 | Validation 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. |
401 | Missing or expired bearer token, or invalid credentials. |
403 | Your account is not enabled for this channel. |
409 | The 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
Send any valid E.164 number. An 8-digit number with no country code is treated as Costa Rica.
Queues one transactional SMS.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
idempotencyKey | string | Yes | A 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. |
sender | string | Yes | Sender id. Must be on your allowlist. |
message | string | Yes | The message text. Substitution tags work here. |
to | string | Yes | Destination in E.164, e.g. +50680000000. A bare 8-digit number is taken as Costa Rica. |
personalizationSubstitutionTags | array | No | See Substitution tags. |
callbackUrl | string | No | Where we post delivery updates for this message. |
Responses
| Status | Meaning |
|---|---|
202 | Queued. Returns an operationId. |
400 | Validation 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. |
401 | Missing or expired bearer token, or invalid credentials. |
403 | Your account is not enabled for this channel. |
409 | The 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
to is an E.164 number — the same identifier you use for SMS. We resolve it to the customer's signed-in devices.
Queues one mobile push notification.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
idempotencyKey | string | Yes | A 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. |
appId | string | Yes | Which mobile app to deliver to. Must be on your allowlist. |
title | string | Yes | Notification title. |
message | string | Yes | Notification body. |
to | string | Yes | The 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. |
data | object | No | Key-value payload delivered alongside the notification, for the app to act on. |
personalizationSubstitutionTags | array | No | See Substitution tags. |
Responses
| Status | Meaning |
|---|---|
202 | Queued. Returns an operationId. |
400 | Validation 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. |
401 | Missing or expired bearer token, or invalid credentials. |
403 | Your account is not enabled for this channel. |
409 | The 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.
| Condition | Response | What happens |
|---|---|---|
| New key | 202 Accepted | A new operation is created and queued. |
| Same key, same content | 202 Accepted | You get the original operation back. Nothing is sent twice. |
| Same key, different content | 409 Conflict | Rejected. Use a new key for a genuinely different message. |
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.
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
Reads the current state of a queued operation.
channel is email, sms or push.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | email, sms or push. |
operationId | string | Yes | The id the send returned. |
Responses
| Status | Meaning |
|---|---|
200 | The operation's current state. |
401 | Missing or expired bearer token. |
404 | No 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.
Reads the per-recipient delivery outcome for one operation.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | email, sms or push. |
operationId | string | Yes | The id the send returned. |
Responses
| Status | Meaning |
|---|---|
200 | Per-recipient outcomes with timings and failure reasons. |
401 | Missing or expired bearer token. |
404 | No 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"
}
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.
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
| Status | Name | What it means |
|---|---|---|
202 | Accepted | Message queued successfully. |
400 | ValidationFailed | Invalid format, a sender or app not on your allowlist, an invalid phone number, or a substitution tag that does not occur in the content. |
401 | Unauthorized | Missing or expired bearer token, or invalid credentials. |
403 | Forbidden | Your account is not enabled for that channel. |
404 | NotFound | Operation id not found, or not yours. |
409 | IdempotencyKeyReuse | The 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.