Build your own Account Connect endpoints

Account Connect is how your Silux Chat bot safely answers questions about a customer’s own account when your data lives in a system of your own. You add five small POST endpoints; you enter one address and one key on the dashboard; the platform tests the whole chain for you. With the reference code below, a typical build takes under an hour.
20 min readhttps://www.siluxchat.comcurl · Python · PHP · Node.js

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.

EndpointPurposeCalled when
POST {base}/verify-identityMatch the customer and send a one-time code to the mobile you holdThe customer asks about their own account
POST {base}/verify-otpCheck the code and open a short sessionThe customer types the code
POST {base}/account-summaryReturn the account overviewAfter verification
POST {base}/ordersReturn recent orders with status and trackingAfter verification
POST {base}/invoicesReturn invoices with amount, date and statusAfter 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

sequence
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 only

Three 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, orders or invoices until verify-otp has 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

RuleDetail
Method and bodyAll five endpoints are POST with a JSON body and reply with JSON.
Base addressAll five live under one base address, e.g. https://api.yourcompany.com/silux. The platform calls {base}/verify-identity and so on.
AuthenticationEvery 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” answersHTTP 200 with "success": false and a customer-safe message — details don’t match, wrong code, expired code.
HTTP 401Only for a missing or wrong API key, or an unknown/expired session_token.
TimeoutReply within a few seconds. The bot allows each call around 15 seconds before giving up.
SizeKeep 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.

request
{
  "full_name": "Jane Smith",
  "postcode": "SW1A 1AA",
  "email": "",
  "mobile_number": "07700900123"
}
200 — matched
{
  "success": true,
  "otp_token": "opaque-string-you-can-look-up-later",
  "message": "Code sent to mobile ending 123"
}
200 — not matched (never say which detail was wrong)
{"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).

request
{"otp_token": "<from step 1>", "otp_code": "482913"}
200 — correct
{"success": true, "session_token": "opaque-string"}
SituationReply (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

request
{"session_token": "<from step 2>"}
200
{
  "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

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

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

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_namepostcodeemailmobilecode
Test CustomerTE5 7STtest@example.com07700900000always 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 123456 and is never texted.
account-connect — choose your language
"""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

LanguageRun locally on port 8080Base address to enter
Python (Flask)ACCOUNT_CONNECT_API_KEY=change-me flask --app account_connect run --port 8080https://api.yourdomain.com
Node.js (Express)ACCOUNT_CONNECT_API_KEY=change-me node account-connect.jshttps://api.yourdomain.com
PHPphp -S localhost:8080https://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):

local chain test
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"}'
expected output (key order and escaping of £ and — vary by language)
{"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.

lockout behaviour
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

  1. 1
    Open Dashboard → Integrations → Account Connect.
  2. 2
    Enter your base address (e.g. https://api.yourcompany.com/silux) and your API key. The key is sent in the X-API-Key header.
  3. 3
    Press 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.
  4. 4
    Each 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.
  5. 5
    When 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

AreaWhat to do
HTTPSServe only over HTTPS with a valid certificate. Redirects are not followed, so give the final address.
KeyUse 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 limitingLimit 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 expiryKeep the 5-attempt lock, 10-minute codes and 30-minute sessions — or stricter. Make codes single use.
Shared storeKeep OTP and session records in Redis or your database when you run more than one process.
LoggingLog outcomes (matched / not matched / locked), never codes, tokens or full personal details.
SMS providerSend from a recognisable sender name; include your brand, the code, its lifetime and “never share it”. Queue and retry on provider errors.
MatchingNormalise names, postcodes (remove spaces, upper-case) and phone numbers (last ten digits) before comparing.
Response sizeKeep replies compact and customer-facing. The bot reads roughly the first 2,000 characters of each response.
Test customerKeep 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.

support@siluxcall.co.uk