CoolFace
Apppublic

ssmar97/supplier-verification-api

sourceHugging Facemitupdated 2mo agoView on Hugging Face
0likes
main.py594 linesDownload Raw Back to root
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}