Senti integration guide
This is the whole of what your server has to do to be on Senti. It is written for the developer who will implement it. Everything between the Senti app and the Senti core is Senti's problem and is not described here.
You implement up to three HTTPS endpoints on your own server. Senti calls them. You never call Senti.
| You want | You implement | You give Senti |
|---|---|---|
| A dial code, so people can walk a menu | One processing URL | The URL and a bearer token for it |
| A till, so people can pay you | One validation URL and one processing URL | Both URLs and a bearer token for each |
Everything is JSON over HTTPS. Every request from Senti has the same three headers. Every reply from you is a small JSON object with a 200 status.
1. What every request from Senti looks like
POST {your url}
Content-Type: application/json
Authorization: Bearer {the token you gave Senti for this URL}
Device-Id: dv_k3n7p2q9r4s8t1u6v0w5x2y7z9
- Authorization. Check it equals the token you gave Senti, with a constant-time comparison. If it does not, reply 401. That is the only authentication. There is no signature to verify.
- Device-Id. An opaque id for the phone. Stable for that phone across every session and every one of your endpoints. It identifies a device, not a person and not a phone number. You may store it, key your own records on it, or allowlist it. You cannot derive anything from it.
Senti sends nothing else about the person. No phone number, no name, no location. If you need any of that, ask for it in your own menu.
Senti's requests come from a fixed IP address, published in the console, so you can allowlist it if you want to.
2. A dial code: the processing URL
Senti calls this URL once per step of a menu session. The first call is the dial. Every later call is the person's reply to your previous screen.
2.1 What you receive
{
"session_id": "ses_9f3a1c7e2b4d8a6f0e1c5b7d9a3f2e4c",
"code": "*384*10#",
"input": "",
"state": null
}
| Field | Meaning |
|---|---|
session_id |
Identifies this conversation. Same value on every step until it ends. Use it if you want to keep your own record of where the person is. |
code |
The code the person dialled. Matters only if one URL serves several codes. |
input |
What the person typed. Empty string on the first step. Any printable characters, up to 60. 0, 00, *, # have no special meaning to Senti; they mean whatever you decide. |
state |
Exactly what you returned as state with your previous screen. Absent on the first step and absent whenever your previous screen did not set one. |
Senti does not send a history of inputs. state is how you know where the person is without storing anything.
2.2 What you reply
Continue, asking for more input:
{
"text": "Shule ya Mwenge\n1. Ada\n2. Matokeo\n3. Ratiba",
"end": false,
"state": "menu"
}
End the session:
{
"text": "Asante. Umepokea taarifa kwa SMS.",
"end": true
}
End the session and open a web page:
{
"text": "Malizia usajili kwenye kiungo hiki.",
"end": true,
"link": "https://shule.mwenge.ac.tz/jisajili?s=8f3a"
}
| Field | Required | Meaning |
|---|---|---|
text |
yes | What the person sees. Line breaks are preserved. No formatting of any kind. |
end |
yes | false: the person can type, and the next call brings their input. true: the session is over. |
state |
no | Any string up to 1024 characters. Senti stores it and sends it back unchanged on the next call. Ignored when end is true. |
link |
no | Any https URL. Only honoured when end is true. The app shows an Open button; tapping it opens the phone's browser. Nothing comes back to you. |
Reply with status 200 and Content-Type: application/json. Unknown fields are ignored.
2.3 Using state
Put a screen name in it and switch on it. That is the entire pattern.
def handle(req):
state = req.get("state")
inp = req.get("input", "")
if state is None:
return {"text": "Shule ya Mwenge\n1. Ada\n2. Matokeo", "end": False, "state": "menu"}
if state == "menu":
if inp == "1":
return {"text": "Weka namba ya mwanafunzi:", "end": False, "state": "ask_student"}
if inp == "2":
return {"text": "Matokeo yatatolewa tarehe 15.", "end": True}
return {"text": "Chaguo si sahihi.\n1. Ada\n2. Matokeo", "end": False, "state": "menu"}
if state == "ask_student":
student = lookup(inp)
if not student:
return {"text": "Namba haipo. Jaribu tena:", "end": False, "state": "ask_student"}
return {
"text": f"{student.name}\nDeni: TZS {student.due:,}\n1. Lipa sasa\n2. Rudi",
"end": False,
"state": f"confirm:{student.id}",
}
if state.startswith("confirm:"):
student_id = state.split(":", 1)[1]
if inp == "1":
return {"text": f"Tumia namba 50020002{student_id} kulipa.", "end": True,
"link": f"https://shule.mwenge.ac.tz/ada/{student_id}"}
return {"text": "Shule ya Mwenge\n1. Ada\n2. Matokeo", "end": False, "state": "menu"}
return {"text": "Hitilafu. Piga tena.", "end": True}
You can put anything in state, including an encoded record of everything the person has entered so far. Senti never reads it.
2.4 Rules you must respect
| Rule | Value | If you break it |
|---|---|---|
| Reply within | 5 seconds | The person sees "Huduma haipatikani kwa sasa. Jaribu tena." and the session ends. |
| Status | 200 | Same. |
| Body | Valid JSON with text and end |
Same. |
| Body size | 4 KB | Same. |
| Redirects | None. Senti does not follow them. | Same. |
| TLS | A certificate that validates against public roots | Your URL is refused at registration. |
Senti does not retry a processing URL. A failed step is a failed step.
2.5 Things that are your decision, not Senti's
- Back. If you want
0to go back, handle it in your code. Senti sends0like any other input. - Language. Ask in your first screen, remember the choice against the
Device-Id. - Who may use the menu. Check
Device-Idagainst your own list. Reply{"text": "Access denied.", "end": true}to anyone else. To let a staff member find their id, show it to them from your menu once. - Maintenance. Reply
{"text": "Huduma iko kwenye matengenezo.", "end": true}. Senti has no maintenance mode of its own. - Abandoned sessions. If the person closes the app, you are not told. Your next request for that session simply never comes. Do not do anything on a step that you would regret if the next step never arrived.
3. A till: the validation URL and the processing URL
A till is a number people type to pay you. They type the till, or one of its aliases, followed immediately by a reference, with no separator. Senti splits it and calls you twice: once to ask what to show, once to tell you the person confirmed.
Whatever the person typed, you always receive your 8-digit till. You never see an alias.
3.1 Validation: what you receive
{
"till": "50020002",
"reference": "2024118"
}
reference is whatever followed your till number. It is an empty string if the person typed only the till. Senti always calls you, including for an empty reference. You decide what an empty reference means.
3.2 Validation: what you reply
Proceed, fixed amount:
{
"success": true,
"account": "Amina Juma",
"amount": { "type": "exact", "value": 150000 },
"text": "Ada ya muhula wa pili"
}
Proceed, the person chooses an amount up to a maximum:
{
"success": true,
"account": "Amina Juma",
"amount": { "type": "partial", "value": 150000 },
"text": "Deni: 150,000. Lipa kiasi chochote."
}
Proceed on an empty reference, as a shop or a church would:
{
"success": true,
"account": "Mchango wa Kanisa",
"amount": { "type": "partial", "value": 5000000 },
"text": null
}
Refuse:
{
"success": false,
"text": "Namba ya mwanafunzi haipo."
}
| Field | Required | Meaning |
|---|---|---|
success |
yes | true to let the person continue, false to stop them with your text. |
account |
on success: true |
Who or what is being paid for. Up to 40 characters; longer is cut. Shown to the person. Your business name is not needed; Senti already has it. |
amount.type |
on success: true |
exact or partial. |
amount.value |
on success: true |
Positive integer, whole units of your currency. On exact it is the amount. On partial it is the most the person may pay. There is no minimum; if an amount is too small, say so at process. |
text |
on success: false; optional on success: true |
A line for the person, up to 120 characters; longer is cut. On refusal it is the only thing they see. |
The person sees your business name from Senti's records, then account, then the amount, then text.
Validation must not change anything on your side. It may be called several times for the same reference while the person corrects their input.
3.3 Process: what you receive
Sent once, after the person has seen the account and the amount and tapped Confirm.
{
"session_id": "ses_8f3a1c7e2b4d8a6f0e1c5b7d9a3f2e4c",
"till": "50020002",
"reference": "2024118",
"amount": 150000,
"msisdn": "255718017738"
}
| Field | Meaning |
|---|---|
session_id |
Identifies this confirmation. Same id format as a menu session. Senti sends each one exactly once. If you ever see the same id twice, do not act twice. |
till |
Your 8-digit till. Never an alias. |
reference |
The reference from validation. |
amount |
What the person confirmed. Already checked against the rule you returned. You may check again. |
msisdn |
The phone number exactly as the person typed it, spaces removed. Digits and an optional leading +, up to 20 characters. Senti does not check the country, the length or the network; a Kenyan number is as valid to Senti as a Tanzanian one. Normalise it yourself if your rail needs a format. Use it to push a prompt, or ignore it. |
What you do now is yours. Push a wallet prompt on your own M-Pesa, Tigo Pesa, Airtel Money or Selcom account. Return a link to your own checkout page. Record the confirmation and reply with a thank you. Senti does not know and does not need to know.
3.4 Process: what you reply
Proceeded, tell the person what to do next:
{
"success": true,
"text": "Angalia simu yako na weka PIN ya M-Pesa."
}
Proceeded, send them to your own page:
{
"success": true,
"text": "Malizia malipo kwenye ukurasa huu.",
"link": "https://shule.mwenge.ac.tz/lipa/8f3a2c"
}
Declined:
{
"success": false,
"text": "Kiasi kimezidi kikomo cha siku."
}
| Field | Required | Meaning |
|---|---|---|
success |
yes | Whether you acted. |
text |
yes | What the person sees, up to 300 characters; longer is cut. On success: true it is the final screen. On success: false it is the reason. |
link |
no | Any https URL. Only with success: true. Opens in the phone's browser. |
After this reply, Senti is done. It will not call you again about this confirmation. Whether money moved is between you, your rail and the person.
3.5 Rules you must respect
| Rule | Validation | Process | If you break it |
|---|---|---|---|
| Reply within | 5 seconds | 8 seconds | The person sees an error and may try again. On process, you may already have acted, so make process idempotent on session_id. |
| Status | 200 | 200 | Same. |
| Body | Valid JSON per 3.2 | Valid JSON per 3.4 | Same. |
| Body size | 4 KB | 4 KB | Same. |
| Redirects | None | None | Same. |
| TLS | Public certificate | Public certificate | URL refused at registration. |
3.6 A minimal till in Python
from flask import Flask, request, jsonify
import hmac
app = Flask(__name__)
VALIDATION_TOKEN = "…" # what you gave Senti for /senti/validate
PROCESS_TOKEN = "…" # what you gave Senti for /senti/process
def authed(token):
got = request.headers.get("Authorization", "")
return hmac.compare_digest(got, f"Bearer {token}")
@app.post("/senti/validate")
def validate():
if not authed(VALIDATION_TOKEN):
return "", 401
ref = request.json.get("reference", "")
student = find_student(ref)
if not student:
return jsonify(success=False, text="Namba ya mwanafunzi haipo.")
return jsonify(success=True, account=student.name,
amount={"type": "exact", "value": student.due},
text="Ada ya muhula wa pili")
@app.post("/senti/process")
def process():
if not authed(PROCESS_TOKEN):
return "", 401
body = request.json
if already_handled(body["session_id"]):
return jsonify(success=True, text="Angalia simu yako na weka PIN.")
student = find_student(body["reference"])
if not student or body["amount"] != student.due:
return jsonify(success=False, text="Kiasi si sahihi.")
push_mpesa_prompt(msisdn=body["msisdn"], amount=body["amount"], ref=body["reference"])
mark_handled(body["session_id"])
return jsonify(success=True, text="Angalia simu yako na weka PIN ya M-Pesa.")
4. Links, on both kinds of object
You may end a menu session or a process with one link. Senti checks it before it reaches the phone:
- It must be
https. - At most 2048 characters. No
user:password@in it.
A link that fails a check is dropped; your text is still shown.
The link opens in the phone's own browser, not inside the app. Nothing about what happens on that page comes back to Senti. If you need the person to continue afterwards, they dial again.
5. Saying no
Everything that is an error from your point of view is a normal reply with your own words:
- Menu:
{"text": "…", "end": true}. - Validation:
{"success": false, "text": "…"}. - Process:
{"success": false, "text": "…"}.
HTTP error statuses are only for when you cannot answer at all. Then the person sees Senti's generic line, because there is no text of yours to show.
6. What Senti keeps
Per call, one row: which object, which device id, when, how long you took, and the outcome. Nothing you sent, nothing the person typed, no amounts, no phone numbers. Your monthly statement per object is a sum over those rows.
7. Checklist before you ask Senti to switch you on
- Endpoints are HTTPS with a certificate that validates.
- Every endpoint checks the bearer token and replies 401 otherwise.
- Every reply is 200 with
Content-Type: application/json. - Menu replies always have
textandend. Validation and process replies always havesuccess. - Menu steps reply in under 5 seconds. Validation under 5. Process under 8.
- Validation changes nothing. Process is idempotent on
session_id. - Any
linkyou send is https. - Nothing on a menu step would be wrong if the next step never came.