Overview
If your customers’ accounts live in off-the-shelf software such as Shopify, Xero or Cliniko, you probably do not need to write anything — pick a ready-made connector instead. Account Connect is for everything else: an in-house billing platform, a bespoke CRM, a portal built on your own database, or a vendor system that only your developers can reach.
| Endpoint | Purpose | Called when |
|---|---|---|
| POST {base}/verify-identity | Match the customer and send a one-time code to the mobile you hold | The customer asks about their own account |
| POST {base}/verify-otp | Check the code and open a short session | The customer types the code |
| POST {base}/account-summary | Return the account overview | After verification |
| POST {base}/orders | Return recent orders with status and tracking | After verification |
| POST {base}/invoices | Return invoices with amount, date and status | After verification |
Try the finished thing first
A complete working copy of these endpoints runs at https://www.siluxchat.com/api/account-connect-demo/v1 with the key demo-account-connect-key and fictional customers only. See Testing with the live demo — you can point your dashboard at it before your own endpoints exist.
How the flow works
Customer ──► Bot: "What's my balance?"
Bot ──► asks for full name + postcode (or email / mobile)
Bot ──► POST {base}/verify-identity {full_name, postcode, email, mobile_number}
YOUR SYSTEM finds the account and TEXTS A ONE-TIME CODE to the mobile
number already on the account (never to a number the customer types)
◄── {"success": true, "otp_token": "…"}
Customer ──► types the code into the chat
Bot ──► POST {base}/verify-otp {otp_token, otp_code}
◄── {"success": true, "session_token": "…"}
Bot ──► POST {base}/account-summary {session_token} ◄── {"success": true, "account": {…}}
Bot ──► POST {base}/orders {session_token} ◄── {"success": true, "orders": […]}
Bot ──► POST {base}/invoices {session_token} ◄── {"success": true, "invoices": […]}
Bot ──► answers from that data onlyThree properties make this safe, and every implementation must keep them:
- The code always goes to the mobile number already on the account. Someone who guesses a name and postcode still cannot read the code.
- The bot is blocked in code — not merely instructed — from calling
account-summary,ordersorinvoicesuntilverify-otphas succeeded in the same conversation. See Customer identity verification & OTP. - Your system decides everything: which accounts match, how long codes and sessions last, and what data is returned. The bot only ever sees what you return.
Conventions
| Rule | Detail |
|---|---|
| Method and body | All five endpoints are POST with a JSON body and reply with JSON. |
| Base address | All five live under one base address, e.g. https://api.yourcompany.com/silux. The platform calls {base}/verify-identity and so on. |
| Authentication | Every request carries X-API-Key: <your key>. You choose the key. The header name defaults to X-API-Key and can be changed when you connect. |
| Normal “no” answers | HTTP 200 with "success": false and a customer-safe message — details don’t match, wrong code, expired code. |
| HTTP 401 | Only for a missing or wrong API key, or an unknown/expired session_token. |
| Timeout | Reply within a few seconds. The bot allows each call around 15 seconds before giving up. |
| Size | Keep replies compact. The bot reads roughly the first 2,000 characters of each response. |
1. POST {base}/verify-identity
Match the full name plus at least one of postcode, email or mobile. Normalise before comparing (case, spaces; for phones, compare the last ten digits so 07700 900123 and +447700900123 match). Then generate a random six-digit code, store only a hash of it, and text it to the registered mobile.
{
"full_name": "Jane Smith",
"postcode": "SW1A 1AA",
"email": "",
"mobile_number": "07700900123"
}{
"success": true,
"otp_token": "opaque-string-you-can-look-up-later",
"message": "Code sent to mobile ending 123"
}{"success": false, "message": "We could not match those details to an account."}Why the reply is deliberately vague
Saying “the postcode is wrong” tells an attacker the name was right. Always return the same message for every mismatch, and never reveal the full mobile number — the last three digits are enough for a real customer to recognise it.
2. POST {base}/verify-otp
Look up the otp_token, check it has not expired (we recommend 10 minutes), compare the hash of the typed code in constant time, count wrong attempts (lock after 5), and on success delete the OTP record and issue a session token (we recommend 30 minutes).
{"otp_token": "<from step 1>", "otp_code": "482913"}{"success": true, "session_token": "opaque-string"}| Situation | Reply (HTTP 200) |
|---|---|
| Wrong code | {"success": false, "message": "That code is not correct."} |
| Fifth wrong code (token is then deleted) | {"success": false, "message": "Too many wrong codes — please ask for a new one."} |
| Unknown, used or expired token | {"success": false, "message": "That code has expired — please ask for a new one."} |
3. POST {base}/account-summary
{"session_token": "<from step 2>"}{
"success": true,
"account": {
"account_number": "C1001",
"name": "Jane Smith",
"status": "active",
"plan": "Business Line — 12 months",
"monthly_price": "£19.95",
"contract_end": "2027-03-31",
"balance_due": "£0.00"
}
}Return any fields you like — the bot reads them out in plain English — but keep it to what a customer should see on your own “My Account” page. Format money and dates the way you want customers to hear them. More on shaping this response in Account lookups.
4. POST {base}/orders
{
"success": true,
"orders": [
{"order_number": "ORD-50231", "placed": "2026-09-20", "status": "dispatched",
"courier": "Royal Mail Tracked 48", "tracking": "TT123456789GB"}
]
}Newest first, a handful at most. An empty list ("orders": []) is a valid answer: the bot will say the customer has no orders. See Orders, delivery tracking & returns for status wording and returns.
5. POST {base}/invoices
{
"success": true,
"invoices": [
{"invoice": "INV-7781", "date": "2026-09-01", "amount": "£19.95", "status": "paid"}
]
}An expired or unknown session_token on endpoints 3–5 must return HTTP 401:
{"success": false, "message": "Session expired — please verify again."}The bot then asks the customer to verify again. See Billing & invoices.
The test customer (required)
Your system must recognise this fictional customer, so the platform can test the whole chain without texting a real person:
| full_name | postcode | mobile | code | |
|---|---|---|---|---|
| Test Customer | TE5 7ST | test@example.com | 07700900000 | always 123456 — never texted |
Give the test customer a little sample data for account, orders and invoices, so each step of the dashboard test has something to show. The reference implementations below already do this.
Never return
- Passwords, PINs or security answers.
- Full card or bank numbers — the last four digits at most.
- Another customer’s data, even partially.
- Internal notes, fraud flags or staff comments.
- Anything you would not show on your own “My Account” page.
Reference implementations
Each file below is complete and self-contained. Replace the functions marked YOUR SYSTEM with lookups in your own database and the SMS function with your provider. All three behave identically and were run end to end before publishing: test customer matched, code 123456 accepted, account/orders/invoices returned, wrong key → 401, wrong details → success: false, fifth wrong code locks the token, unknown session → 401.
- Constant-time comparison of the API key and of the code hash.
- A cryptographically secure random six-digit code; only its SHA-256 hash is stored.
- Codes live 10 minutes, sessions 30 minutes, five wrong codes lock the token, and a code works once.
- The test customer always gets
123456and is never texted.
"""Account Connect endpoints for Silux Chat — reference implementation (Python 3.9+, Flask).
pip install flask
ACCOUNT_CONNECT_API_KEY=change-me flask --app account_connect run --port 8080
Run it behind your HTTPS reverse proxy. Replace the functions marked YOUR SYSTEM with
lookups in your own database, and send_sms() with your SMS provider.
"""
import hashlib
import hmac
import os
import secrets
import threading
import time
from typing import Optional
from flask import Flask, jsonify, request
API_KEY = os.environ["ACCOUNT_CONNECT_API_KEY"] # the key you enter on the dashboard
OTP_TTL, SESSION_TTL, MAX_ATTEMPTS = 600, 1800, 5 # 10 minutes, 30 minutes, 5 wrong codes
app = Flask(__name__)
_lock = threading.Lock()
_otps = {} # otp_token -> {"customer_id", "code_hash", "exp", "tries"} (use Redis/DB in production)
_sessions = {} # session_token -> {"customer_id", "exp"}
TEST_CUSTOMER = {"id": "TEST", "full_name": "Test Customer", "postcode": "TE5 7ST",
"email": "test@example.com", "mobile": "07700900000"}
# ---------------- YOUR SYSTEM: replace these ----------------
def find_customer(full_name, postcode, email, mobile) -> Optional[dict]:
"""Return {"id", "mobile"} when the full name AND one of postcode/email/mobile match."""
def n(s):
return "".join((s or "").lower().split())
c = TEST_CUSTOMER
if n(full_name) == n(c["full_name"]) and (
(postcode and n(postcode) == n(c["postcode"])) or (email and n(email) == n(c["email"]))
or (mobile and n(mobile) == n(c["mobile"]))):
return c
return None # <- look the customer up in YOUR database here
def account_for(customer_id: str) -> dict:
return {"account_number": customer_id, "name": "Test Customer", "status": "active",
"plan": "Sample plan", "balance_due": "£0.00"}
def orders_for(customer_id: str) -> list:
return [{"order_number": "ORD-1", "placed": "2026-09-20", "status": "dispatched",
"courier": "Royal Mail Tracked 48", "tracking": "TT000000000GB"}]
def invoices_for(customer_id: str) -> list:
return [{"invoice": "INV-1", "date": "2026-09-01", "amount": "£19.95", "status": "paid"}]
def send_sms(mobile: str, text: str) -> None:
pass # <- call YOUR SMS provider here
# ------------------------------------------------------------
def _hash(code: str) -> str:
return hashlib.sha256(code.encode()).hexdigest()
def _fail(status: int, message: str):
return jsonify({"success": False, "message": message}), status
@app.before_request
def _check_key():
key = request.headers.get("X-API-Key", "")
if not key or not hmac.compare_digest(key.encode(), API_KEY.encode()):
return _fail(401, "Missing or wrong X-API-Key")
return None
def _body() -> dict:
data = request.get_json(silent=True)
return data if isinstance(data, dict) else {}
def _s(d: dict, k: str) -> str:
return str(d.get(k) or "").strip()
@app.post("/verify-identity")
def verify_identity():
b = _body()
c = find_customer(_s(b, "full_name"), _s(b, "postcode"), _s(b, "email"), _s(b, "mobile_number"))
if not c: # never say WHICH detail was wrong
return jsonify({"success": False, "message": "We could not match those details to an account."})
code = "123456" if c["id"] == "TEST" else f"{secrets.randbelow(10**6):06d}"
token = secrets.token_urlsafe(24)
with _lock:
_otps[token] = {"customer_id": c["id"], "code_hash": _hash(code), "exp": time.time() + OTP_TTL, "tries": 0}
if c["id"] != "TEST": # the test customer is never texted
send_sms(c["mobile"], f"Your verification code is {code}. It expires in 10 minutes.")
return jsonify({"success": True, "otp_token": token, "message": f"Code sent to mobile ending {c['mobile'][-3:]}"})
@app.post("/verify-otp")
def verify_otp():
b = _body()
token = _s(b, "otp_token")
with _lock:
o = _otps.get(token)
if not o or o["exp"] < time.time():
_otps.pop(token, None)
return jsonify({"success": False, "message": "That code has expired — please ask for a new one."})
if not hmac.compare_digest(o["code_hash"], _hash(_s(b, "otp_code"))):
o["tries"] += 1
if o["tries"] >= MAX_ATTEMPTS:
_otps.pop(token, None)
return jsonify({"success": False, "message": "Too many wrong codes — please ask for a new one."})
return jsonify({"success": False, "message": "That code is not correct."})
_otps.pop(token, None) # a code works once
session = secrets.token_urlsafe(32)
_sessions[session] = {"customer_id": o["customer_id"], "exp": time.time() + SESSION_TTL}
return jsonify({"success": True, "session_token": session})
def _customer_id() -> Optional[str]:
with _lock:
s = _sessions.get(_s(_body(), "session_token"))
return s["customer_id"] if s and s["exp"] >= time.time() else None
@app.post("/account-summary")
def account_summary():
cid = _customer_id()
return jsonify({"success": True, "account": account_for(cid)}) if cid else _fail(401, "Session expired — please verify again.")
@app.post("/orders")
def orders():
cid = _customer_id()
return jsonify({"success": True, "orders": orders_for(cid)}) if cid else _fail(401, "Session expired — please verify again.")
@app.post("/invoices")
def invoices():
cid = _customer_id()
return jsonify({"success": True, "invoices": invoices_for(cid)}) if cid else _fail(401, "Session expired — please verify again.")Running them
| Language | Run locally on port 8080 | Base address to enter |
|---|---|---|
| Python (Flask) | ACCOUNT_CONNECT_API_KEY=change-me flask --app account_connect run --port 8080 | https://api.yourdomain.com |
| Node.js (Express) | ACCOUNT_CONNECT_API_KEY=change-me node account-connect.js | https://api.yourdomain.com |
| PHP | php -S localhost:8080 | https://yourdomain.com/silux (with the rewrite) or https://yourdomain.com/silux/account-connect.php |
One process, or a shared store
The Python and Node.js versions keep codes and sessions in memory, so run a single worker process. With several workers or servers, move the OTP and session records into Redis or your database (with the same expiry times). The PHP version stores them in a locked JSON file next to the script; use your database in production and never let that file be served.
PHP: clean paths and protecting the store file
The PHP file accepts all three address styles: clean paths (/silux/verify-identity) via a rewrite, path info (/silux/account-connect.php/verify-identity) and ?action=. Clean paths are the tidiest:
# .htaccess in the same folder as account-connect.php (Apache)
RewriteEngine On
RewriteRule ^(verify-identity|verify-otp|account-summary|orders|invoices)$ account-connect.php?action=$1 [L,QSA]
# Never serve the store file
<Files ".account-connect-store.json">
Require all denied
</Files>Test your endpoints
Before connecting, run the chain yourself. These commands work against any of the three implementations (for PHP set BASE to the script URL):
BASE=http://localhost:8080 # PHP: http://localhost:8080/account-connect.php
KEY=change-me # the key your implementation expects
# 1. Wrong key -> HTTP 401
curl -s -w " [%{http_code}]\n" -X POST $BASE/verify-identity -H "X-API-Key: wrong" \
-H "Content-Type: application/json" -d '{}'
# 2. Wrong details -> 200 with success:false (and no hint about WHICH detail)
curl -s -X POST $BASE/verify-identity -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"full_name":"Test Customer","postcode":"XX1 1XX"}'
# 3. The test customer -> otp_token
OTP=$(curl -s -X POST $BASE/verify-identity -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"full_name":"Test Customer","postcode":"TE5 7ST"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["otp_token"])')
# 4. Code 123456 -> session_token
SES=$(curl -s -X POST $BASE/verify-otp -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"otp_token\":\"$OTP\",\"otp_code\":\"123456\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["session_token"])')
# 5. The three account endpoints
for EP in account-summary orders invoices; do
curl -s -X POST $BASE/$EP -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"session_token\":\"$SES\"}"; echo
done
# 6. An unknown session -> HTTP 401
curl -s -w " [%{http_code}]\n" -X POST $BASE/orders -H "X-API-Key: $KEY" \
-H "Content-Type: application/json" -d '{"session_token":"made-up"}'{"success":false,"message":"Missing or wrong X-API-Key"} [401]
{"success":false,"message":"We could not match those details to an account."}
{"success":true,"account":{"account_number":"TEST","name":"Test Customer","status":"active","plan":"Sample plan","balance_due":"£0.00"}}
{"success":true,"orders":[{"order_number":"ORD-1","placed":"2026-09-20","status":"dispatched","courier":"Royal Mail Tracked 48","tracking":"TT000000000GB"}]}
{"success":true,"invoices":[{"invoice":"INV-1","date":"2026-09-01","amount":"£19.95","status":"paid"}]}
{"success":false,"message":"Session expired — please verify again."} [401]Then check the lockout: request a fresh code and send five wrong codes to the same otp_token.
wrong code 1-4 -> {"success":false,"message":"That code is not correct."}
wrong code 5 -> {"success":false,"message":"Too many wrong codes — please ask for a new one."}
any further try -> {"success":false,"message":"That code has expired — please ask for a new one."}localhost is for you, not for us
The platform can only call a public HTTPS address. Deploy behind your reverse proxy (or use a secure tunnel while developing) before connecting on the dashboard.
Connect it on your dashboard
- 1Open Dashboard → Integrations → Account Connect.
- 2Enter your base address (e.g.
https://api.yourcompany.com/silux) and your API key. The key is sent in theX-API-Keyheader. - 3Press Connect & test. The platform creates all five connections and runs the chain with the test customer, through the same code path the bot uses in real conversations: Verify Identity → Verify OTP → Account Summary → My Orders → My Invoices.
- 4Each step shows a tick or a cross with the exact reply your system gave. Any step that fails stays switched off — the bot never uses a half-working chain. Fix it and press the button again.
- 5When every step passes, the dashboard shows identity verification: ready and the bot starts using it straight away. Every set-up and test is recorded in your Activity Log.
“That address can't be used”
The base address must be public HTTPS. Private, internal and loopback addresses (10.x, 192.168.x, 127.0.0.1, localhost and similar) are refused so the platform can never be used to reach an internal network.
If a step fails, the Troubleshooting & error reference lists every message and its fix.
Production checklist
| Area | What to do |
|---|---|
| HTTPS | Serve only over HTTPS with a valid certificate. Redirects are not followed, so give the final address. |
| Key | Use a long random key (32+ characters), store it as a secret, and rotate it by changing it on your side and re-entering it on the dashboard. |
| Rate limiting | Limit verify-identity per IP and per account (for example a few requests a minute per account) so nobody can use it to probe your customer list or run up your SMS bill. |
| Attempts and expiry | Keep the 5-attempt lock, 10-minute codes and 30-minute sessions — or stricter. Make codes single use. |
| Shared store | Keep OTP and session records in Redis or your database when you run more than one process. |
| Logging | Log outcomes (matched / not matched / locked), never codes, tokens or full personal details. |
| SMS provider | Send from a recognisable sender name; include your brand, the code, its lifetime and “never share it”. Queue and retry on provider errors. |
| Matching | Normalise names, postcodes (remove spaces, upper-case) and phone numbers (last ten digits) before comparing. |
| Response size | Keep replies compact and customer-facing. The bot reads roughly the first 2,000 characters of each response. |
| Test customer | Keep the test customer working in production — it is how the dashboard re-tests your chain. |
For the wider picture — data minimisation, UK GDPR roles and what the platform stores — read Security & data protection.
Get help
We are happy to review your endpoints
Send your base address (never the key) and a note of what your system holds to support@siluxcall.co.uk. Our integration team can check the replies against this specification and run the dashboard test with you.
Need help with your integration?
Our integration team can review your endpoints, help you choose the right connector and check a test run with you. Tell us which system you use and what you would like the bot to answer.