ssmar97/supplier-verification-api
0
1"""2Supplier bank-detail verification API.3 4Backs the ElevenAgents outbound verification agent. The agent calls these endpoints5as server tools (webhooks) during a live call.6 7Companion to:8 - supplier-verification-business-case.md9 - supplier-verification-agent-prompt.md10 11Four design decisions worth understanding, because they carry the actual security12of this control:13 141. The agent is NEVER given the true account number.15 It receives the BSB and account name only. When the supplier reads their account16 number back, the agent posts what it heard to /check-account and the SERVER17 compares. A compromised or prompt-injected agent has nothing to leak.18 192. Matching is deterministic, done in code — never by the model.20 LLMs are not reliable digit comparators and can be talked out of a result.21 The comparison is a normalised, constant-time string check.22 233. Fail-closed.24 Suppliers start BLOCKED. Only a CONFIRMED outcome releases them. Dropped calls,25 agent errors, timeouts and unknown states all leave the supplier unpayable.26 274. Webhooks are authenticated.28 These endpoints sit on the public internet so ElevenLabs can reach them. Without29 a shared secret, anyone who finds the URL can mark a supplier verified — which30 would make this control worse than useless.31 32Storage is in-memory: this is a demo/prototype, not production. Restarting the33process resets all state.34"""35 36from __future__ import annotations37 38import os39import re40import secrets41from datetime import datetime, timezone42from enum import Enum43 44from fastapi import FastAPI, Header, HTTPException, status45from pydantic import BaseModel, Field46 47app = FastAPI(48 title="Supplier Verification API",49 description="Tool endpoints for the ElevenAgents supplier bank-detail verification agent.",50 version="0.1.0",51)52 53# The agent must send this as the X-Api-Key header. Override in the environment.54# .strip() because secret managers and copy-paste routinely append a trailing55# newline, which otherwise causes a 401 that looks identical to a wrong key.56API_KEY = os.getenv("VERIFICATION_API_KEY", "dev-secret-change-me").strip()57 58 59# --------------------------------------------------------------------------60# Enums61# --------------------------------------------------------------------------62 63class Outcome(str, Enum):64 """Call outcomes A-F from the conversation design."""65 66 CONFIRMED = "CONFIRMED" # A - details matched67 MISMATCH = "MISMATCH" # B - details did not match68 REFUSED = "REFUSED" # C - contact declined to verify69 WRONG_NUMBER = "WRONG_NUMBER" # D - not this business70 UNREACHABLE = "UNREACHABLE" # E - no suitable person reached71 SUSPICIOUS = "SUSPICIOUS" # F - change proposed / evasive / off-script72 73 74class Method(str, Enum):75 """How the confirmation was obtained. Challenge-response is stronger evidence."""76 77 CHALLENGE_RESPONSE = "CHALLENGE_RESPONSE" # supplier stated the number78 READ_BACK = "READ_BACK" # agent read partial, supplier said yes79 80 81class PaymentStatus(str, Enum):82 BLOCKED = "BLOCKED" # default for every new supplier83 RELEASED = "RELEASED" # verified, payable84 FROZEN = "FROZEN" # actively escalated, do not pay85 86 87# --------------------------------------------------------------------------88# Synthetic data89# --------------------------------------------------------------------------90# Three fixtures covering the demo paths: a clean confirmation, a mismatch91# (fraudulent details on the invoice), and a supplier that does not answer.92 93SUPPLIERS: dict[str, dict] = {94 "SUP-1001": {95 "supplier_name": "Coastal Plumbing Services Pty Ltd",96 "abn": "51 824 753 556",97 "sourced_phone": "+61 2 5550 1001",98 "sourced_from": "ABN Lookup + official website (2 independent sources)",99 "payment_status": PaymentStatus.BLOCKED,100 },101 "SUP-1002": {102 "supplier_name": "Harbour Electrical Contractors",103 "abn": "72 629 484 117",104 "sourced_phone": "+61 2 5550 1002",105 "sourced_from": "ASIC register + official website (2 independent sources)",106 "payment_status": PaymentStatus.BLOCKED,107 },108 "SUP-1003": {109 "supplier_name": "Northside Office Supplies",110 "abn": "18 331 902 644",111 "sourced_phone": "+61 2 5550 1003",112 "sourced_from": "ABN Lookup (1 source - flagged for human review)",113 "payment_status": PaymentStatus.BLOCKED,114 },115}116 117# A verification is one pending check: a supplier + the invoice that triggered it.118# `true_account_number` is what OCR read off the invoice. It is never sent to the119# agent; the whole point is to check whether the supplier recognises it.120VERIFICATIONS: dict[str, dict] = {121 "VER-2001": {122 "supplier_id": "SUP-1001",123 "client_business_name": "Marlow & Finch Architects",124 "client_location": "Newcastle",125 "invoice_number": "INV-8842",126 "invoice_date": "2026-07-28",127 "invoice_amount": "$4,180.00",128 "bsb": "062-142",129 "true_account_number": "10029384",130 "account_name": "Coastal Plumbing Services Pty Ltd",131 },132 "VER-2002": {133 "supplier_id": "SUP-1002",134 "client_business_name": "Marlow & Finch Architects",135 "client_location": "Newcastle",136 "invoice_number": "INV-8851",137 "invoice_date": "2026-07-30",138 "invoice_amount": "$7,920.00",139 "bsb": "013-006",140 "true_account_number": "48810277",141 "account_name": "Harbour Electrical Contractors",142 },143 "VER-2003": {144 "supplier_id": "SUP-1003",145 "client_business_name": "Ridgeway Freight Co",146 "client_location": "Wollongong",147 "invoice_number": "INV-3390",148 "invoice_date": "2026-08-01",149 "invoice_amount": "$1,240.00",150 "bsb": "082-057",151 "true_account_number": "70155390",152 "account_name": "Northside Office Supplies",153 },154}155 156# Append-only audit log. Every tool call lands here.157AUDIT_LOG: list[dict] = []158 159 160# --------------------------------------------------------------------------161# Helpers162# --------------------------------------------------------------------------163 164def require_api_key(x_api_key: str | None) -> None:165 """Reject unauthenticated callers.166 167 compare_digest avoids leaking the key through response-timing differences.168 """169 if x_api_key is None or not secrets.compare_digest(x_api_key.strip(), API_KEY):170 raise HTTPException(171 status_code=status.HTTP_401_UNAUTHORIZED,172 detail="Invalid or missing X-Api-Key header.",173 )174 175 176def get_verification(verification_id: str) -> dict:177 record = VERIFICATIONS.get(verification_id)178 if record is None:179 raise HTTPException(status_code=404, detail=f"Unknown verification {verification_id}")180 return record181 182 183def normalise_account_number(raw: str) -> str:184 """Strip everything that isn't a digit.185 186 Speech-to-text will hand us things like "one zero zero two, nine three eight four"187 rendered as "1002 9384", or with hyphens. Only the digits matter.188 """189 return re.sub(r"\D", "", raw or "")190 191 192def spoken_digits(value: str) -> str:193 """Format a number for clear TTS: '062-142' -> '0 6 2, 1 4 2'.194 195 ElevenLabs will otherwise read '062142' as 'sixty-two thousand one hundred196 forty-two', which nobody can check against their bank statement.197 """198 groups = [g for g in re.split(r"\D", value) if g]199 return ", ".join(" ".join(g) for g in groups)200 201 202def spoken_date(iso_date: str) -> str:203 """'2026-07-28' -> '28 July 2026'.204 205 TTS reads a raw ISO date as a run of numbers, which nobody can match against206 an invoice. Same rationale as spoken_digits().207 """208 try:209 parsed = datetime.strptime(iso_date, "%Y-%m-%d")210 except ValueError:211 return iso_date # pass through anything we don't recognise212 return f"{parsed.day} {parsed.strftime('%B %Y')}"213 214 215def log(verification_id: str, event: str, **detail) -> dict:216 entry = {217 "timestamp": datetime.now(timezone.utc).isoformat(),218 "verification_id": verification_id,219 "event": event,220 **detail,221 }222 AUDIT_LOG.append(entry)223 return entry224 225 226def set_payment_status(verification_id: str, new_status: PaymentStatus) -> None:227 supplier_id = VERIFICATIONS[verification_id]["supplier_id"]228 SUPPLIERS[supplier_id]["payment_status"] = new_status229 230 231# --------------------------------------------------------------------------232# Request/response models233# --------------------------------------------------------------------------234 235class CallContext(BaseModel):236 """Everything the agent needs to run the call - and nothing more.237 238 Deliberately excludes the true account number.239 """240 241 verification_id: str242 client_business_name: str243 client_location: str244 supplier_name: str245 sourced_phone: str246 invoice_number: str247 invoice_date: str248 invoice_amount: str249 bsb: str250 bsb_spoken: str = Field(description="BSB formatted for clear text-to-speech")251 account_name: str252 253 254class AccountCheckRequest(BaseModel):255 stated_account_number: str = Field(256 description="Exactly what the supplier said, as transcribed. Digits are extracted server-side."257 )258 259 260class AccountCheckResponse(BaseModel):261 match: bool262 next_action: str263 264 265class AccountHintResponse(BaseModel):266 account_last_three: str267 bsb_spoken: str268 account_name: str269 warning: str270 271 272class OutcomeRequest(BaseModel):273 outcome: Outcome274 method: Method | None = Field(275 default=None, description="Required when outcome is CONFIRMED."276 )277 notes: str | None = Field(default=None, description="Brief free-text note for the audit trail.")278 transcript_ref: str | None = None279 280 281class OutcomeResponse(BaseModel):282 verification_id: str283 outcome: Outcome284 payment_status: PaymentStatus285 escalated: bool286 message: str287 288 289class EscalateRequest(BaseModel):290 reason: str291 priority: str = "HIGH"292 transcript_ref: str | None = None293 294 295class CallbackRequest(BaseModel):296 reason: str297 preferred_window: str | None = None298 299 300class SimpleResponse(BaseModel):301 verification_id: str302 status: str303 message: str304 305 306# --------------------------------------------------------------------------307# Endpoints308# --------------------------------------------------------------------------309 310@app.get("/health", summary="Liveness check")311def health() -> dict:312 return {"status": "ok", "verifications_pending": len(VERIFICATIONS)}313 314 315@app.get(316 "/verifications/{verification_id}",317 response_model=CallContext,318 summary="Fetch call context (no account number)",319)320def read_verification(verification_id: str, x_api_key: str | None = Header(default=None)) -> CallContext:321 """Called at the start of the call to populate the agent's variables."""322 require_api_key(x_api_key)323 record = get_verification(verification_id)324 supplier = SUPPLIERS[record["supplier_id"]]325 326 log(verification_id, "context_fetched")327 328 return CallContext(329 verification_id=verification_id,330 client_business_name=record["client_business_name"],331 client_location=record["client_location"],332 supplier_name=supplier["supplier_name"],333 sourced_phone=supplier["sourced_phone"],334 invoice_number=record["invoice_number"],335 invoice_date=spoken_date(record["invoice_date"]),336 invoice_amount=record["invoice_amount"],337 bsb=record["bsb"],338 bsb_spoken=spoken_digits(record["bsb"]),339 account_name=record["account_name"],340 )341 342 343@app.post(344 "/verifications/{verification_id}/check-account",345 response_model=AccountCheckResponse,346 summary="Challenge-response: compare what the supplier said",347)348def check_account(349 verification_id: str,350 payload: AccountCheckRequest,351 x_api_key: str | None = Header(default=None),352) -> AccountCheckResponse:353 """The primary verification path.354 355 The supplier states their account number; the agent posts it here verbatim.356 Comparison happens in code, not in the model.357 """358 require_api_key(x_api_key)359 record = get_verification(verification_id)360 361 stated = normalise_account_number(payload.stated_account_number)362 363 # Refuse to score a check the agent made without a real number.364 #365 # A MISMATCH is a fraud alert. If an agent bug can manufacture one, the366 # signal is worthless: people investigate suppliers who did nothing wrong,367 # and after a few false alarms they stop trusting the control entirely.368 # "The agent called the tool wrong" and "the supplier gave bad details" are369 # different events and must never collapse into the same outcome.370 if len(stated) < 4:371 log(verification_id, "check_called_without_number", stated_length=len(stated))372 raise HTTPException(373 status_code=422,374 detail=(375 "No account number was supplied. This is not a mismatch — you called this "376 "tool before the contact gave you a number. Ask them to read out their "377 "account number, then call this tool again with exactly what they say."378 ),379 )380 381 expected = normalise_account_number(record["true_account_number"])382 matched = secrets.compare_digest(stated, expected)383 384 # Log the attempt, but never the digits the caller supplied - if the sourced385 # number turned out to be an attacker, that is their data, not ours to keep.386 log(387 verification_id,388 "account_check",389 matched=matched,390 stated_length=len(stated),391 method=Method.CHALLENGE_RESPONSE.value,392 )393 394 return AccountCheckResponse(395 match=matched,396 next_action=(397 "Details match. Thank the contact, close politely, then log outcome CONFIRMED "398 "with method CHALLENGE_RESPONSE."399 if matched400 else "Details do NOT match. Do not say so, do not correct them, do not reveal "401 "the expected value. Close the call politely and log outcome MISMATCH."402 ),403 )404 405 406@app.get(407 "/verifications/{verification_id}/account-hint",408 response_model=AccountHintResponse,409 summary="Read-back fallback: last three digits only",410)411def account_hint(verification_id: str, x_api_key: str | None = Header(default=None)) -> AccountHintResponse:412 """Fallback path, used only when the supplier declines to read their number out.413 414 Returns the last three digits so the agent can offer a partial read-back.415 Weaker evidence than challenge-response, and logged as such.416 """417 require_api_key(x_api_key)418 record = get_verification(verification_id)419 420 log(verification_id, "read_back_fallback_used")421 422 return AccountHintResponse(423 account_last_three=normalise_account_number(record["true_account_number"])[-3:],424 bsb_spoken=spoken_digits(record["bsb"]),425 account_name=record["account_name"],426 warning="Read-back is weaker evidence than challenge-response. Log method READ_BACK.",427 )428 429 430@app.post(431 "/verifications/{verification_id}/outcome",432 response_model=OutcomeResponse,433 summary="log_verification_outcome - called at the end of every call",434)435def log_outcome(436 verification_id: str,437 payload: OutcomeRequest,438 x_api_key: str | None = Header(default=None),439) -> OutcomeResponse:440 """Record the outcome and set payment status.441 442 Fail-closed: only CONFIRMED releases the supplier. Everything else blocks or freezes.443 """444 require_api_key(x_api_key)445 get_verification(verification_id)446 447 if payload.outcome is Outcome.CONFIRMED and payload.method is None:448 raise HTTPException(449 status_code=422,450 detail="method is required when outcome is CONFIRMED.",451 )452 453 # Outcomes that mean something went wrong enough to warrant a human, now.454 escalating = payload.outcome in {455 Outcome.MISMATCH,456 Outcome.WRONG_NUMBER,457 Outcome.SUSPICIOUS,458 }459 460 if payload.outcome is Outcome.CONFIRMED:461 desired = PaymentStatus.RELEASED462 message = "Supplier verified and released for payment."463 elif escalating:464 desired = PaymentStatus.FROZEN465 message = "Supplier frozen. Escalated for immediate human review."466 else:467 desired = PaymentStatus.BLOCKED468 message = "Supplier remains blocked pending human follow-up."469 470 # FROZEN is sticky: only a human may lift it.471 #472 # An agent can log several outcomes in one call (observed in testing: REFUSED,473 # then SUSPICIOUS). If a later, milder outcome could overwrite a freeze, anyone474 # who failed verification could simply keep talking until they got a CONFIRMED.475 # Escalations must never be undone by the thing that raised them.476 supplier_id = VERIFICATIONS[verification_id]["supplier_id"]477 current = SUPPLIERS[supplier_id]["payment_status"]478 479 if current is PaymentStatus.FROZEN and desired is not PaymentStatus.FROZEN:480 new_status = PaymentStatus.FROZEN481 message = (482 "Supplier is frozen pending human review. The outcome was recorded, but "483 "payment status was not changed."484 )485 log(486 verification_id,487 "status_change_refused",488 attempted_outcome=payload.outcome.value,489 retained_status=PaymentStatus.FROZEN.value,490 )491 else:492 new_status = desired493 set_payment_status(verification_id, new_status)494 495 log(496 verification_id,497 "outcome_logged",498 outcome=payload.outcome.value,499 method=payload.method.value if payload.method else None,500 payment_status=new_status.value,501 # The agent's own account of the call. It is NOT independently verified and502 # has been observed to be wrong (it once described a read-back request that503 # never happened). Server-observed facts live in the account_check entries;504 # this is a claim, and the field name says so.505 agent_asserted_notes=payload.notes,506 transcript_ref=payload.transcript_ref,507 )508 509 if escalating:510 log(verification_id, "escalated", reason=f"Auto-escalated on {payload.outcome.value}")511 512 return OutcomeResponse(513 verification_id=verification_id,514 outcome=payload.outcome,515 payment_status=new_status,516 escalated=escalating,517 message=message,518 )519 520 521@app.post(522 "/verifications/{verification_id}/escalate",523 response_model=SimpleResponse,524 summary="escalate_to_human",525)526def escalate(527 verification_id: str,528 payload: EscalateRequest,529 x_api_key: str | None = Header(default=None),530) -> SimpleResponse:531 require_api_key(x_api_key)532 get_verification(verification_id)533 534 set_payment_status(verification_id, PaymentStatus.FROZEN)535 log(536 verification_id,537 "escalated",538 reason=payload.reason,539 priority=payload.priority,540 transcript_ref=payload.transcript_ref,541 )542 543 return SimpleResponse(544 verification_id=verification_id,545 status=PaymentStatus.FROZEN.value,546 message="Escalated to human reviewer. Supplier frozen for payment.",547 )548 549 550@app.post(551 "/verifications/{verification_id}/callback",552 response_model=SimpleResponse,553 summary="flag_for_callback",554)555def flag_callback(556 verification_id: str,557 payload: CallbackRequest,558 x_api_key: str | None = Header(default=None),559) -> SimpleResponse:560 require_api_key(x_api_key)561 get_verification(verification_id)562 563 log(564 verification_id,565 "callback_scheduled",566 reason=payload.reason,567 preferred_window=payload.preferred_window,568 )569 570 return SimpleResponse(571 verification_id=verification_id,572 status=PaymentStatus.BLOCKED.value,573 message="Callback scheduled. Supplier remains blocked until verified.",574 )575 576 577# --------------------------------------------------------------------------578# Inspection endpoints - for you, not the agent579# --------------------------------------------------------------------------580 581@app.get("/suppliers/{supplier_id}", summary="Inspect supplier payment status")582def read_supplier(supplier_id: str) -> dict:583 supplier = SUPPLIERS.get(supplier_id)584 if supplier is None:585 raise HTTPException(status_code=404, detail=f"Unknown supplier {supplier_id}")586 return {"supplier_id": supplier_id, **supplier}587 588 589@app.get("/audit-log", summary="Inspect the full audit trail")590def read_audit_log(verification_id: str | None = None) -> dict:591 entries = AUDIT_LOG592 if verification_id:593 entries = [e for e in entries if e["verification_id"] == verification_id]594 return {"count": len(entries), "entries": entries}