Sending a transactional message
One message to one person, now: a login code, an order update, a payment reminder, with the idempotency contract that makes a retry safe.
A transactional message is one message to one person, now: a login code, an order confirmation, a delivery update, a payment reminder. Your backend decides it should happen. Segmentic sends it and answers with what happened.
It is the opposite direction from everything else in this product. A campaign is a message we decided to send. A journey is one the person's own behaviour triggered. This one is your checkout handler saying "send this, now", and the differences all follow from that: the call is synchronous, it is idempotent on a key you choose, and it bypasses frequency caps and quiet hours.
The endpoint
POST https://api.segmentic.net/v1/messages
Authorization: Bearer sk_seg_...
Content-Type: application/json
The key is a management key (sk_seg_...), it is secret, and it belongs on your server. A write key (wk_seg_...) ships inside your app and is refused here with its own error code, write_key_rejected, so that somebody who copied the wrong value out of a page is told which value they need rather than sent looking for a typo.
The permission is campaign.send, not campaign.write. Owner, admin and marketer carry it. Approver, analyst, viewer and finance do not. Drafting a campaign and making messages leave the building are different acts, and this route performs the second one.
The route exists only when the deployment has a send path wired. Without one it is never registered and the mux's catch-all answers 404 with {"error":{"code":"unknown_endpoint","message":"no such endpoint: POST /v1/messages, see GET /v1/capabilities"}}. Check GET /v1/capabilities first: it reports "transactional": false on a deployment that cannot send. On a local machine the management API is served only when PUBLIC_API_ADDR is set, and it is unset by default, so there is nothing on that host until you set it.
The response is always in Persian, and Accept-Language has no effect on this host. The language middleware is mounted on the panel's own listener and not on this one, so the locale lookup falls through to its default, which is Persian. That is why reason_fa is named the way it is. Send the header if you like; nothing reads it.
The request
| Field | Type | Required | Default |
|---|---|---|---|
user_id | string | yes | none |
channel | string | yes | none |
template_id | number | yes | none |
idempotency_key | string | yes, in the body or the Idempotency-Key header | none |
category | string | no | transactional |
vars | object of string to string | no | none |
There is no field for the message text. A template_id is required and the content lives in the template. The reason is in the code: accepting a body inline "would put message text on the request path of a system that is often called from a checkout handler, and make every send unreviewable after the fact." See Templates.
Unknown fields are refused with 400. A caller who wrote idempotencyKey instead of idempotency_key would otherwise get a fresh key on every retry and send one message per attempt, which is the exact failure this endpoint exists to prevent, arriving through a typo nobody would ever see in a log.
The body is capped at 262144 bytes (256 KiB).
Validation runs in this order, and the first failure is the one you are told about:
user_idis presenttemplate_idis not zeroidempotency_keyis presentidempotency_keymatches its patternvarsholds at most 40 entriescategoryistransactional,critical, or absentchannelis one of the accepted values
Channel is checked last. A request with both a bad channel and a six-character key is told about the key.
The idempotency key
The key is not a request id. It is the name of the thing that happened in your own system. order-8821-shipped, otp:2026-08-01:u_9137, invoice-5512-reminder-1.
This matters because of what a generated key does. A checkout handler that calls uuid() on each attempt, times out at four seconds, and retries, has produced two different keys for one event. Both are new. Both send. The customer gets two SMS for one shipment and phones support about it. A key derived from the order and the step is the same string on both attempts, so the second attempt returns the first attempt's answer and sends nothing.
Shape: ^[A-Za-z0-9._:-]{8,200}$. Letters, digits, dot, dash, underscore and colon, at least 8 characters and at most 200.
| Accepted | Refused |
|---|---|
order-8821-shipped | short (under 8 characters) |
otp:2026-08-01:u_9137 | has space |
a1b2c3d4 | quote'inside |
x.y_z-1:2 | semi;colon |
The key is trimmed of surrounding whitespace and nothing else. Case is preserved, so Order-1 and order-1 are two different keys and two different messages. Folding them would merge two keys a strict caller may be using for two different things.
The key may arrive in the body as idempotency_key or as the HTTP header Idempotency-Key. The body wins; the header is read only when the body field is empty. The header form exists because most HTTP clients already have a retry wrapper that sets it.
What the key does
The message id is derived from the key, not generated: t{tenant_id}.{key}. For tenant 7 and key order-8821, the id is t7.order-8821. The t prefix distinguishes a transactional send from a campaign (c) and a journey (j) in the message log and in attribution.
Because the id is derived, the de-duplication below the ledger recognises a replay too: the delivery path's own de-duplication, the frequency counter and the message log all see the same id even if the ledger row were lost.
Scope is (tenant_id, idempotency_key). The same key in two accounts is two messages.
The ledger claim is one round trip: an insert with an ON CONFLICT update that returns whether this caller won. It is not a read followed by a write, because two retries of the same key arriving together is the ordinary case and a check-then-insert leaves a window both of them pass through.
| Situation | What you get |
|---|---|
| First call | The send runs. The result is stored against the key. |
| Repeat of a completed key | The stored result, with "replayed": true. No provider is contacted. |
| Repeat of a key whose send was suppressed | The same refusal, replayed. It is not retried. |
| Repeat while the first attempt is still running | 409 with Retry-After: 1 |
| Repeat after the first attempt errored | The reservation was released, so this attempt runs |
A replay returns the wording of the first request. reason_fa was rendered then and stored, so a later change to the sentence does not reach a key that has already been answered.
How long a key is remembered
Ledger rows are swept after API_IDEMPOTENCY_RETENTION, 7 days by default. After that window the same key sends again. A key is a retry guard, not a permanent record of what you have sent.
Abandoned reservations, the ones left behind by a process that died mid-send, are swept after API_STALE_RESERVATION, 1 minute by default. Without that sweep a crashed worker would answer every retry of one key with 409 for ever, and it would be the customer's order receipts that stopped.
Categories
| Category | What it skips |
|---|---|
marketing | nothing. Every rule applies. |
transactional | quiet hours always, and a frequency cap unless that cap names transactional |
critical | everything transactional skips, plus a channel opt-out and a topic opt-out. Never governed by any cap, whatever a policy says. |
Neither transactional nor critical skips an operator suppression. A person under a fraud hold or a legal hold receives nothing, including a security alert. See Consent and caps.
marketing is refused here with 400. The message is transactional: this endpoint does not send marketing; use a campaign. This endpoint bypasses caps and quiet hours, so accepting marketing on it would hand every customer a documented way around their own sending rules, and the first time it mattered would be a 3am promotional SMS to a whole list.
An unknown category value returns 503 message not sent, not 400. That is a real inconsistency in the current build and it is worth knowing about: a typo in category looks like an outage on our side, and a caller who retries it will keep getting 503. Check the field before you check us.
An absent category defaults to transactional, never to critical. Critical bypasses a channel opt-out, so defaulting to it would let a caller who omitted the field reach somebody who explicitly turned that channel off.
The template's own category overrides the one in your request. A template saved as marketing and sent through this endpoint with "category": "transactional" is delivered as marketing, which means quiet hours and frequency caps apply to it. The validator's refusal above is about the request field. The template is the final authority, because that is the field that stops an order confirmation being held until 9am and equally stops a promotion being tagged transactional to get around a cap.
A login code is critical, not transactional. Both reach the service line, so delivery looks identical, and the difference only shows up under an account rate ceiling: a ceiling that names transactional counts your OTPs, so a large campaign that fills the account's hour can hold one back. Somebody locked out of their own account by your marketing is not a rate limit working correctly. critical is the one category no stored policy can reach, whatever it says.
An absent category defaults to transactional, so a login code with the field omitted is exposed to exactly this. Set it explicitly.
Channels
channel must be exactly one of:
push sms email webpush inapp bale eitaa rubika
Three values that look plausible and are refused:
webis refused. The value iswebpush. Elsewhere in the platformwebis accepted as a historical alias, and the MCP tool schema still advertiseswebin its description, but this endpoint compares literally and the MCP description is wrong. Sendwebpush.messengeris refused. It is an authoring umbrella for campaigns, resolved per recipient. A transactional send must namebale,eitaaorrubikadirectly.webhookis refused here, and it has no delivery sender anywhere in the platform.
Which of these an installation can actually deliver on depends on what is configured. Sending on a channel with no configured transport produces a failed outcome, not a validation error.
Templates
The template must exist before you can send. It carries the title, the body, the image, the deep link, the buttons, the TTL, the priority, the SMS pattern binding, and the category that overrides yours.
Templates are created and edited on the control plane, which is the API the panel talks to:
GET /v1/templates template.read
POST /v1/templates template.write
POST /v1/templates/preview template.read
There is no template route on the management host, and the control plane is not routed from the internet. In the reference deployment api.segmentic.net publishes the management listener only. So an integration holding an sk_seg_ key cannot create, list, read or delete a template. It can only reference one by id, and the id has to come from a person who opened the panel. There is also no GET /v1/templates/{id} and no delete route on any surface.
An unknown, archived or foreign template_id returns 503 message not sent. It is not a 400 and not a 404, because the failure is raised by the store rather than by the request validator. The reservation is released, so your retry runs, and your retry fails the same way. When a send returns 503 immediately and repeatedly, check the template id first.
A template is cached on the send path for 30 seconds. An edit in the panel takes up to half a minute to reach a send.
Personalisation
vars is a flat map of string to string, at most 40 entries. Values are substituted into the template wherever it writes {{ key }}, with an optional fallback after a pipe: {{ first_name | مشتری عزیز }}.
Three sources are merged, weakest first, and each overrides the one before it:
- the account's shared dictionary (
GET/PUT /v1/settings/content-varson the panel API) - the person's profile and their first device:
user_id,first_name,last_name,email,phone,city,region,country,language,full_name,order_count,total_revenue,last_order_date,birthday, every stored trait, plusdevice_platformandapp_version - the
varson this request
Your values win, which is the only safe order: a shared value must not beat the person it is being sent to, and nothing must beat a value your system produced a millisecond ago.
A variable with no value and no fallback stops the send. The outcome is suppressed with reason missing_personalisation, and the list of missing keys is in the log rather than in the response. An empty string counts as missing. If the render produces no title and no body at all, the reason is empty_content instead. Use POST /v1/templates/preview on the panel API to see exactly what a set of values renders to, including which keys are missing; it calls the same renderer the send path calls.
Latin digits in a substituted value are converted to Persian digits in the title and the body, with a thousands separator. They are not converted in deep_link, icon, image or anything in the template's data map, because myapp://order/۱۲۳۴۵ is a link an app cannot parse. A value that contains any Latin letter keeps its ASCII digits, because the recipient is going to type an order code like AB-1234567 back into a search box.
The response
200 with this body:
| Field | Type | Present |
|---|---|---|
message_id | string | always |
status | string | always |
sent_at | RFC3339 timestamp | always |
reason | string | only when the message was deliberately not sent |
reason_fa | string | only when reason is set. The rendered sentence, always in Persian on this host. |
error | string | only when something failed |
replayed | boolean | only when true |
status is one of:
| Status | Meaning |
|---|---|
sent | at least one transport accepted it |
suppressed | we deliberately did not send. reason says why. |
deferred | held to be tried later |
failed | every transport rejected it. error carries the gateway's words. |
A send:
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-01T12:00:00Z"
}
A replay of a key whose original send was refused:
{
"message_id": "t7.order-8821-shipped",
"status": "suppressed",
"reason": "channel_opt_out",
"reason_fa": "کاربر این کانال را خاموش کرده است",
"replayed": true,
"sent_at": "2026-08-01T12:00:00Z"
}
200 does not mean the message went. suppressed, deferred and failed all arrive as 200, because the request was answered correctly and the send was not. The reason a transactional SMS with no approved pattern comes back as 200 with "status": "failed" is that the request was fine and the account is not ready to send. Branch on status, never on the HTTP code alone.
Every failure
| Code | Body | Cause | What to do |
|---|---|---|---|
400 | malformed JSON: ... | unparseable body, an unknown field, or a body over 256 KiB | fix the payload. Retrying will not help. |
400 | transactional: user_id is required | no user_id | fix the payload |
400 | transactional: template_id is required | template_id absent or zero | fix the payload |
400 | transactional: idempotency_key is required | no key in the body and none in the header | fix the payload |
400 | the key-shape message | the key fails ^[A-Za-z0-9._:-]{8,200}$ | fix the key. Do not generate a new one at random. |
400 | transactional: unknown channel | not one of the eight values | see Channels |
400 | transactional: this endpoint does not send marketing; use a campaign | "category": "marketing" | build a campaign |
400 | transactional: too many variables | more than 40 entries in vars | send fewer |
401 | {"error":{"code":"unauthenticated"}} | no key, or a bad one | check the key |
401 | {"error":{"code":"write_key_rejected"}} | you sent a wk_seg_ key | use the sk_seg_ key |
401 | {"error":{"code":"key_expired"}} | the key has expired | issue a new one |
403 | {"error":{"code":"forbidden","need":"campaign.send"}} | the key lacks the permission | grant campaign.send |
404 | {"error":{"code":"unknown_endpoint"}} | this deployment has no send path configured, so the route was never registered | ask your operator, and read GET /v1/capabilities |
409 | transactional: a message with this idempotency key is already in flight plus Retry-After: 1 | your own earlier attempt is still running | wait a second and repeat the same key |
429 | rate limit exceeded: N requests per minute plus Retry-After: 60 | the account's per-minute limit | back off |
429 | {"error":{"code":"budget_exhausted"}} plus Retry-After: 60 | the management host's weighted budget | back off |
503 | budget_unavailable | the budget could not be read, and this check fails closed | retry |
503 | message not sent | the template could not be loaded, or category held an unknown value, or the send path errored | check template_id and category, then retry |
200 | a full result | the message was sent and the ledger write failed | treat it as sent. Do not retry. |
That last row is deliberate. When the message really did go and only the record of it failed, a caller told otherwise would retry and send a second one, so the result is returned even though something went wrong on our side.
Rate limits and the two envelopes
Two separate mechanisms apply, and they fail in opposite directions on purpose.
The per-tenant rate limit. Read from the account on every request, falling back to the install default API_RATE_PER_MINUTE, which is 0, and zero disables metering entirely. When a limit is in force, every response carries X-RateLimit-Limit and X-RateLimit-Remaining, not only the refusals, because a caller who cannot see how close it is has no way to slow down before being refused. Over the limit is 429 with Retry-After: 60.
The window is a fixed minute bucket, not a sliding one. A caller can send close to two windows' worth of traffic across a boundary. That trade is deliberate and documented in the code.
This limiter fails open. If the limit cannot be read, the send is allowed. The reasoning is on the endpoint: it carries order receipts and login codes, and an outage in a counter must not be the reason somebody cannot sign in.
The management host's weighted budget. PUBLIC_API_BUDGET_PER_MINUTE defaults to 600 cost units per minute per key. POST /v1/messages costs 1 unit. Over budget is 429 budget_exhausted with Retry-After: 60. This one fails closed: a budget that cannot be verified returns 503 budget_unavailable.
The error envelope on this route is not uniform. On the management host, the authentication and permission refusals use the public envelope, {"error":{"code":"...","message":"...","need":"..."}}. Everything the handler itself produces, which is every 400, the 409, the rate-limit 429 and the 503, uses the flat shape {"error":"some text"}. An error handler for this one route has to read both.
Iranian SMS: the pattern requirement
A transactional SMS with no approved pattern does not fail at the gateway. It never reaches one. It is refused inside Segmentic and comes back as "status": "failed" with "error": "sms: a service line requires an approved pattern".
Iran has two kinds of SMS line and choosing wrong fails in two different, expensive ways.
| Line | Persian | Behaviour |
|---|---|---|
| Advertising | خط تبلیغاتی | Cheap, and invisible to every subscriber who has blocked advertising SMS at their operator, which is a very large share of Iranian numbers. The gateway still reports success. |
| Service | خط خدماتی | Reaches everyone, including those subscribers. Which is exactly why it may carry only transactional content, and only text the operator approved in advance. |
The line is chosen from the category, not by whoever writes the message: marketing goes out on the advertising line, everything else on the service line. It is deliberately not configurable per campaign, because the one place a marketer would reach for an override is exactly the place that costs the customer their service line.
So: every message sent through POST /v1/messages is routed to a service line, and a service line requires a pattern. A pattern is the message text as the operator approved it, with named placeholders, registered under a code. The gateway rejects any text on a service line that does not match a registered pattern.
This has happened in production. A migration in this repository records that the template's pattern_code column was never populated, "so every order code and every login code failed".
Registering a pattern
Registration happens in two places and only one of them is Segmentic.
- With your operator or gateway. You submit the text and they approve it. Segmentic has no integration that does this and no integration that asks Kavenegar or SMS.ir whether a code is approved.
- In Segmentic, so the send path knows the answer without asking anyone:
GET /v1/sms-patterns template.read
POST /v1/sms-patterns template.write
POST /v1/sms-patterns/{id}/status template.write
These are on the panel API. There is no SMS-pattern route on the management host, so pattern registration and approval state cannot be automated from an integration.
POST /v1/sms-patterns takes provider_id (required, non-zero), pattern_code (required), body (required) and an optional variables array. It returns the stored pattern: id, provider_id, pattern_code, body, variables, status, status_note, created_at, updated_at.
POST /v1/sms-patterns/{id}/status takes {"status": "...", "note": "..."}. The legal moves are:
pending -> approved | rejected the operator answered
approved -> revoked the operator withdrew it
any -> pending the registered body was edited
Those transitions are enforced in SQL, in the WHERE clause, so two people answering at once cannot both win. Editing the body of an approved pattern drops it back to pending and clears the note; editing only the variables, or saving an unchanged body, leaves the state alone. A revoked pattern is not re-approved by a status call, it is registered again, and it lands on pending. Uniqueness is (tenant, provider, pattern_code).
Binding a pattern to a template
Two fields on the template do this:
pattern_code: the operator's codepattern_tokens: a map from the gateway's token names to your own variable names
The two namespaces are genuinely different. Yours is first_name and order_id; theirs is often token, token2, token3.
A pattern token is filled from the template's data map, not from vars directly. The send path reads the value out of the rendered data, so a variable you pass in vars reaches the gateway only if the template's data map has a key that renders it.
The working shape, for a pattern whose approved text is «کد ورود شما: %token%» and whose token is named token:
{
"name": "کد ورود",
"channel": "sms",
"category": "transactional",
"body": "کد ورود شما: {{code}}",
"data": { "code": "{{code}}" },
"pattern_code": "verify-login",
"pattern_tokens": { "token": "code" }
}
{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"idempotency_key": "otp:2026-08-01:u_9137",
"vars": { "code": "8391" }
}
Values that travel through data keep their Latin digits, because data is machine-read.
Approval is resolved with the template in one query and cached for the same 30 seconds, using EXISTS (... status = 'approved') against the pattern code. The provider is deliberately not part of that check, because which gateway carries the message is decided by routing at send time.
What happens without one
| Condition | Result |
|---|---|
service line, no pattern_code on the template | refused before the gateway: failed, sms: a service line requires an approved pattern |
| service line, code present but not currently approved | refused before the gateway: failed, sms: this pattern is not approved by the operator |
| the number does not parse as an Iranian mobile | failed, sms: not a valid Iranian mobile number |
| no service line configured at all | failed, no provider. It does not silently fall back to the advertising line. |
Refusing here rather than letting the gateway punish it is deliberate: sending under a withdrawn pattern is the offence that costs the customer the line, and with the line goes every order code and every login code they send.
Two related rules follow from the same fact:
- Link shortening is never applied on a service line. The test is whether a pattern code is present. Rewriting any part of an approved pattern makes it stop matching.
- The opt-out footer is never appended to transactional text. A marketing SMS gets
لغو۱۱added to its body; transactional text is left exactly as approved, for the same reason.
Complete examples
curl
curl -sS -X POST https://api.segmentic.net/v1/messages \
-H "Authorization: Bearer sk_seg_..." \
-H "Content-Type: application/json" \
-d '{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"category": "transactional",
"idempotency_key": "order-8821-shipped",
"vars": { "code": "8391" }
}'
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-01T12:00:00Z"
}
Node
The retry loop is the part worth copying. It repeats the same key, so a repeat that lands on a completed send returns the stored answer instead of sending again.
const KEY = process.env.SEGMENTIC_API_KEY; // sk_seg_...
async function sendTransactional(payload) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch("https://api.segmentic.net/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.status === 409 || res.status === 429 || res.status === 503) {
const wait = Number(res.headers.get("Retry-After") || 1);
await new Promise((r) => setTimeout(r, wait * 1000));
continue; // the SAME key, never a new one
}
const out = await res.json();
if (!res.ok) throw new Error(`segmentic ${res.status}: ${JSON.stringify(out)}`);
return out;
}
throw new Error("segmentic: gave up after 4 attempts");
}
// One shipment, one key, derived from the shipment itself.
const result = await sendTransactional({
user_id: "u_9137",
channel: "sms",
template_id: 42,
category: "transactional",
idempotency_key: "order-8821-shipped",
vars: { code: "8391" },
});
if (result.status !== "sent") {
// suppressed, deferred or failed. All of these arrive as HTTP 200.
console.warn("not delivered:", result.status, result.reason, result.error);
}
What this endpoint does not do
Each of these is absent from the code, not merely undocumented.
- No lookup by idempotency key. Once you have lost the response, there is no route on the management host that answers "what did key X produce". If you have the message id, the panel API finds its exact row through
GET /v1/messages?message_id=.... The same route also searches by user, recipient and date range. - No message-log route on the management host.
POST /v1/messagesis there;GET /v1/messagesis not. - No template routes on the management host. See Templates.
- No SMS-pattern routes on the management host. See Registering a pattern.
- No scheduling. There is no send-at field. The call sends now or it does not send.
- No cancel and no recall. A recall applies to a message that waited in a queue. This one does not wait.
- No attachments and no inline content of any kind.
- No delivery-receipt callback for one message. The SMS operator receipt is collected, but it is exposed only aggregated, in a campaign's report. What you can subscribe to is the ordinary event stream:
message_sentandmessage_failedare events like any other, so an outbound relay can forward them. See Webhooks.