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

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

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:

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:

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