Skip to content
Docs
FA
Sign in
Start here
  • Overview
  • Quickstart
  • Concepts
Collect data
  • Designing events
  • Event dictionary
  • Placing events
  • Identity
  • Web SDK
  • Android SDK
  • Devices and push
  • Server to server
  • Product catalogue
  • Webhooks
Engage customers
  • Segments
  • Journeys
  • Transactional
  • Consent and caps
  • In-app and inbox
Analyze and export
  • Reports and exports
Developer reference
  • API reference
    • Ingest endpoints
    • Management API
  • Errors
  • Limits
  • OpenAPI
Developer tools
  • MCP server
  • Working with an agent
Privacy and changes
  • Personal data
  • Versioning

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

MESSAGE SOURCES
Transactional APIOne request
CampaignAudience send
JourneyAutomated action
SEGMENTICDelivery routerApply idempotency, policy and provider routing
CHANNELS
SMSCustomer line
PushDevice route
Email and in-appRendered content
How transactional requests, campaigns and journeys reach the correct delivery channel
HTTP
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

FieldTypeRequiredDefault
user_idstringyesnone
channelstringyesnone
template_idnumberyesnone
idempotency_keystringyes, in the body or the Idempotency-Key headernone
categorystringnotransactional
varsobject of string to stringnonone

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:

  1. user_id is present
  2. template_id is not zero
  3. idempotency_key is present
  4. idempotency_key matches its pattern
  5. vars holds at most 40 entries
  6. category is transactional, critical, or absent
  7. channel is 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.

AcceptedRefused
order-8821-shippedshort (under 8 characters)
otp:2026-08-01:u_9137has space
a1b2c3d4quote'inside
x.y_z-1:2semi;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.

SituationWhat you get
First callThe send runs. The result is stored against the key.
Repeat of a completed keyThe stored result, with "replayed": true. No provider is contacted.
Repeat of a key whose send was suppressedThe same refusal, replayed. It is not retried.
Repeat while the first attempt is still running409 with Retry-After: 1
Repeat after the first attempt erroredThe 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

CategoryWhat it skips
marketingnothing. Every rule applies.
transactionalquiet hours always, and a frequency cap unless that cap names transactional
criticaleverything 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:

  • web is refused. The value is webpush. Elsewhere in the platform web is accepted as a historical alias, and the MCP tool schema still advertises web in its description, but this endpoint compares literally and the MCP description is wrong. Send webpush.
  • messenger is refused. It is an authoring umbrella for campaigns, resolved per recipient. A transactional send must name bale, eitaa or rubika directly.
  • webhook is 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:

  1. the account's shared dictionary (GET/PUT /v1/settings/content-vars on the panel API)
  2. 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, plus device_platform and app_version
  3. the vars on 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:

FieldTypePresent
message_idstringalways
statusstringalways
sent_atRFC3339 timestampalways
reasonstringonly when the message was deliberately not sent
reason_fastringonly when reason is set. The rendered sentence, always in Persian on this host.
errorstringonly when something failed
replayedbooleanonly when true

status is one of:

StatusMeaning
sentat least one transport accepted it
suppressedwe deliberately did not send. reason says why.
deferredheld to be tried later
failedevery transport rejected it. error carries the gateway's words.

A send:

JSON
{
  "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:

JSON
{
  "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

CodeBodyCauseWhat to do
400malformed JSON: ...unparseable body, an unknown field, or a body over 256 KiBfix the payload. Retrying will not help.
400transactional: user_id is requiredno user_idfix the payload
400transactional: template_id is requiredtemplate_id absent or zerofix the payload
400transactional: idempotency_key is requiredno key in the body and none in the headerfix the payload
400the key-shape messagethe key fails ^[A-Za-z0-9._:-]{8,200}$fix the key. Do not generate a new one at random.
400transactional: unknown channelnot one of the eight valuessee Channels
400transactional: this endpoint does not send marketing; use a campaign"category": "marketing"build a campaign
400transactional: too many variablesmore than 40 entries in varssend fewer
401{"error":{"code":"unauthenticated"}}no key, or a bad onecheck the key
401{"error":{"code":"write_key_rejected"}}you sent a wk_seg_ keyuse the sk_seg_ key
401{"error":{"code":"key_expired"}}the key has expiredissue a new one
403{"error":{"code":"forbidden","need":"campaign.send"}}the key lacks the permissiongrant campaign.send
404{"error":{"code":"unknown_endpoint"}}this deployment has no send path configured, so the route was never registeredask your operator, and read GET /v1/capabilities
409transactional: a message with this idempotency key is already in flight plus Retry-After: 1your own earlier attempt is still runningwait a second and repeat the same key
429rate limit exceeded: N requests per minute plus Retry-After: 60the account's per-minute limitback off
429{"error":{"code":"budget_exhausted"}} plus Retry-After: 60the management host's weighted budgetback off
503budget_unavailablethe budget could not be read, and this check fails closedretry
503message not sentthe template could not be loaded, or category held an unknown value, or the send path erroredcheck template_id and category, then retry
200a full resultthe message was sent and the ledger write failedtreat 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.

LinePersianBehaviour
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.

  1. 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.
  2. 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 code
  • pattern_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:

the template
{
  "name": "کد ورود",
  "channel": "sms",
  "category": "transactional",
  "body": "کد ورود شما: {{code}}",
  "data": { "code": "{{code}}" },
  "pattern_code": "verify-login",
  "pattern_tokens": { "token": "code" }
}
the send
{
  "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

ConditionResult
service line, no pattern_code on the templaterefused before the gateway: failed, sms: a service line requires an approved pattern
service line, code present but not currently approvedrefused before the gateway: failed, sms: this pattern is not approved by the operator
the number does not parse as an Iranian mobilefailed, sms: not a valid Iranian mobile number
no service line configured at allfailed, 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

Shell
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" }
  }'
200
{
  "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.

send.js
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/messages is there; GET /v1/messages is 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_sent and message_failed are events like any other, so an outbound relay can forward them. See Webhooks.
PreviousJourneysNextConsent and caps

On this page

  • The endpoint
  • The request
  • The idempotency key
  • Categories
  • Channels
  • Templates
  • Personalisation
  • The response
  • Every failure
  • Rate limits and the two envelopes
  • Iranian SMS: the pattern requirement
  • Complete examples
  • What this endpoint does not do

Segmentic

This page is written from the code