--- name: podium-webchat-handler description: Ingest Podium webchat messages in production and survive the webchat-side failures — invalid phone formats accepted at the widget, contact auto-creation races producing duplicate records, session timeouts mid-conversation, attachment size overflows, cross-location chat routing wrong, and opt-out propagation lag. Use when hardening a webchat → API integration, building a multi-location chat widget, debugging duplicate contacts, or recovering from a cross-location routing incident. Trigger with "podium webchat", "podium chat widget", "podium phone validation", "podium contact dedup", "podium webchat session", "podium opt-out", "podium location routing". allowed-tools: Read, Write, Edit, Bash(curl:*), Bash(jq:*), Bash(python3:*), Grep version: 2.0.0 license: MIT author: Jeremy Longshore compatibility: Designed for Claude Code tags: - podium - webchat - phone-validation - e164 - contact-dedup - multi-location --- # Podium Webchat Handler ## Overview Ingest Podium webchat messages into your production system and operate the webchat layer when it breaks. This is not a setup walkthrough — it is the handler code your integration runs at 11am on a Saturday when a Brisbane customer's webchat lands on the Sydney store's queue, when two simultaneous webchats from the same phone produce two duplicate contact records, when a customer types `1` of a `1-2-3` answer and the session dies before they finish, and when a customer types STOP and the next session five minutes later still tries to SMS them. The six production failures this skill prevents: 1. **Invalid phone formats accepted** — Webchat asks for a phone number and a customer types `0412 345 678` (Australian local) or `(415) 555-1234` (US local) without E.164 normalization. The handler stores the local form. The later SMS reply attempt fails silently because Podium expects `+61412345678` / `+14155551234`. The agent thinks they replied; the customer never receives anything. 2. **Contact auto-creation race produces duplicates** — Two webchats arrive within milliseconds from the same phone (customer opens two tabs, or a webhook retry overlaps the first delivery). Both handlers check "does a contact with this phone exist?", both see no, both create — and now the same human is two contact records, with conversation history split across both. 3. **Webchat sessions time out mid-conversation** — Podium webchat sessions have a server-side expiry (default ~30 min idle). A customer types `1` of a `1-2-3` multiple-choice answer, walks away to grab lunch, comes back to finish, and discovers the agent picked up the conversation in fresh context without the `1` they sent. 4. **Attachment size overflows** — Podium accepts attachments up to 25MB. Webchat-to-API integrations that don't validate size client-side before upload fail server-side with a 413 — but only after the customer has waited through the upload progress bar. The customer thinks the image was sent; the agent never sees it. 5. **Cross-location chat routing is wrong** — A Sydney-based store and a Burleigh Heads–based store share the same Podium org. The webchat widget is embedded on a single corporate site and doesn't pass `location_uid` on the initial message. Every chat lands in the default location's queue regardless of which store the customer was actually browsing. 6. **Opt-out propagation lag** — A customer types STOP in an SMS thread. The opt-out flag is recorded in the SMS subsystem but not propagated to the webchat subsystem. Five minutes later the customer starts a new webchat session; the integration still tries to send an SMS confirmation reply and trips a compliance violation. ## Prerequisites - Python 3.10+ (examples) or Node.js 18+ - `podium-auth` skill installed and a working `PodiumAuth` instance for OAuth token management - `podium-webhook-reliability` skill installed if consuming webchat events via webhook (HMAC + dedup live there) - `phonenumbers` library (Google's libphonenumber port): `pip install phonenumbers` - Podium org with at least one location configured; for multi-location, the full `location_uid` list - A contact store with a unique index on the normalized E.164 phone column (the natural dedup key) ## Instructions Build in this order. Each section neutralizes one production failure mode. ### 1. E.164 phone normalization at the widget edge (neutralizes invalid phone formats) Normalize phone numbers to E.164 **at the widget input boundary** before the message ever reaches your API. The widget knows the customer's locale context; the API does not. Use `phonenumbers` for the parse + validation: ```python import phonenumbers from phonenumbers import NumberParseException, PhoneNumberFormat, is_valid_number class PhoneValidationError(Exception): pass def normalize_phone(raw: str, default_country: str = "AU") -> str: """Parse a raw phone string and return E.164 form. Raises on invalid.""" try: parsed = phonenumbers.parse(raw, default_country) except NumberParseException as e: raise PhoneValidationError(f"unparseable phone {raw!r}: {e}") if not is_valid_number(parsed): raise PhoneValidationError(f"invalid phone for region {default_country}: {raw!r}") return phonenumbers.format_number(parsed, PhoneNumberFormat.E164) # Examples assert normalize_phone("0412 345 678", "AU") == "+61412345678" assert normalize_phone("(415) 555-1234", "US") == "+14155551234" assert normalize_phone("+61 412 345 678", "AU") == "+61412345678" ``` The `default_country` parameter is the location's country (Sydney → AU, Burleigh Heads → AU, San Francisco → US). Pass it from the widget context, never hardcode globally. If the widget runs on a multi-region site and cannot determine the default, fail closed — refuse to accept the message until the customer enters a `+`-prefixed number explicitly. ### 2. Contact auto-creation race (neutralizes duplicate contact records) The naive pattern — `if not contact_exists(phone): create_contact(phone)` — has a TOCTOU race. Under simultaneous webchat arrivals from the same phone, both branches see "no" and both create. The fix is **idempotent upsert keyed on the E.164 phone** with a unique index in the contact store, and retry-on-conflict semantics: ```python import httpx from podium_auth import PodiumAuth async def upsert_contact_by_phone( auth: PodiumAuth, phone_e164: str, location_uid: str, first_name: str | None = None, last_name: str | None = None, ) -> dict: """Idempotent contact creation. Returns the contact record; never creates a duplicate.""" token = await auth.get_token() headers = {"Authorization": f"Bearer {token}"} # Step 1: lookup by phone async with httpx.AsyncClient(timeout=10) as c: r = await c.get( "https://api.podium.com/v4/contacts", headers=headers, params={"phone": phone_e164, "location_uid": location_uid}, ) if r.status_code == 200 and r.json().get("data"): return r.json()["data"][0] # Step 2: create — but tolerate 409 conflict from a racing creator async with httpx.AsyncClient(timeout=10) as c: r = await c.post( "https://api.podium.com/v4/contacts", headers=headers, json={ "phone": phone_e164, "location_uid": location_uid, "first_name": first_name, "last_name": last_name, }, ) if r.status_code in (200, 201): return r.json() if r.status_code == 409: # The race lost — refetch and return the winner's record async with httpx.AsyncClient(timeout=10) as c: r2 = await c.get( "https://api.podium.com/v4/contacts", headers=headers, params={"phone": phone_e164, "location_uid": location_uid}, ) if r2.status_code == 200 and r2.json().get("data"): return r2.json()["data"][0] raise WebchatError(f"contact upsert failed: {r.status_code} {r.text}") class WebchatError(Exception): pass ``` In your local contact mirror (if you maintain one), enforce a database-level unique index on `phone_e164` so a parallel writer hits the constraint instead of silently double-inserting. The deeper mechanics — collision resolution when the same phone owns conflicting first/last names across sources — live in `podium-contact-dedup`. This skill prevents the most common race; that skill handles the harder reconciliation cases. ### 3. Webchat session timeout monitor (neutralizes mid-conversation context loss) Podium webchat sessions have a server-side idle timeout. Detect approaching-expiry on your side and either prompt the customer to confirm they're still there, or buffer the partial answer so the agent picks up the conversation with context preserved: ```python import time from dataclasses import dataclass, field SESSION_IDLE_WARN_SECONDS = 20 * 60 # 20 min — prompt customer SESSION_IDLE_CLOSE_SECONDS = 28 * 60 # 28 min — close cleanly before Podium expires @dataclass class WebchatSession: session_uid: str phone_e164: str location_uid: str last_message_at: float partial_state: dict = field(default_factory=dict) # buffered multi-step answers def idle_seconds(self) -> float: return time.time() - self.last_message_at def status(self) -> str: idle = self.idle_seconds() if idle >= SESSION_IDLE_CLOSE_SECONDS: return "close" if idle >= SESSION_IDLE_WARN_SECONDS: return "warn" return "active" async def scan_sessions(sessions: dict[str, WebchatSession]) -> None: """Run on a 60s loop. Emit prompts and clean closures.""" for uid, s in list(sessions.items()): st = s.status() if st == "warn": await send_keepalive_prompt(s.session_uid) # "still there? type anything to continue" elif st == "close": await persist_partial_state(s) # save the `1` of `1-2-3` answer await close_session_cleanly(s.session_uid) del sessions[uid] ``` The buffered `partial_state` is the load-bearing piece. When the next message arrives on the same `phone_e164 + location_uid`, hydrate the previous partial state so the customer is not asked to start over. ### 4. Attachment size validation client-side (neutralizes 413 surprises) Podium's 25MB attachment limit is documented but the API only returns the 413 after the upload completes. Validate at the widget — before the upload starts — so the customer is told immediately: ```python PODIUM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 # 25 MiB class AttachmentTooLargeError(Exception): pass def validate_attachment_size(size_bytes: int) -> None: if size_bytes > PODIUM_ATTACHMENT_MAX_BYTES: raise AttachmentTooLargeError( f"attachment is {size_bytes / 1024 / 1024:.1f} MiB; " f"Podium accepts up to {PODIUM_ATTACHMENT_MAX_BYTES / 1024 / 1024:.0f} MiB" ) ``` In the widget, wire this to the file-input `change` event. In the API handler, double-check the `Content-Length` of incoming uploads and reject with a 413 of your own before forwarding to Podium — this saves both the egress cost and the user-visible failure when the upload finishes and then dies. ### 5. Multi-location routing (neutralizes wrong-store routing) A multi-location org needs `location_uid` on every webchat-originated request. The widget must know which location it represents — either via a per-location embed snippet or via a URL/cookie hint resolved at chat-open time. The handler must **reject** messages that arrive without a valid `location_uid`: ```python VALID_LOCATION_UIDS: set[str] = set() # populate at startup from Podium /v4/locations async def load_locations(auth: PodiumAuth) -> None: token = await auth.get_token() async with httpx.AsyncClient(timeout=10) as c: r = await c.get( "https://api.podium.com/v4/locations", headers={"Authorization": f"Bearer {token}"}, ) r.raise_for_status() VALID_LOCATION_UIDS.clear() VALID_LOCATION_UIDS.update(loc["uid"] for loc in r.json()["data"]) def validate_location(location_uid: str | None) -> str: if not location_uid: raise WebchatError("location_uid is required — refusing to route to a default") if location_uid not in VALID_LOCATION_UIDS: raise WebchatError(f"unknown location_uid {location_uid!r}") return location_uid ``` The "Sydney store gets a Brisbane customer" failure mode happens specifically when the integration falls back to a default location on missing `location_uid`. Do not have a default. Refuse the request and surface a config error to the widget operator. ### 6. Opt-out propagation across SMS + webchat (neutralizes compliance drift) A STOP message in either channel must propagate to both. Maintain a single opt-out store keyed on E.164 phone, and consult it on every outbound message attempt regardless of channel: ```python OPTOUT_KEYWORDS = {"STOP", "UNSUBSCRIBE", "QUIT", "END", "CANCEL", "OPTOUT"} async def check_optout(phone_e164: str) -> bool: """Returns True if this phone is opted out across ALL channels.""" # Backed by a database table or KV store; this is the unified view. return await optout_store.is_opted_out(phone_e164) async def record_optout(phone_e164: str, source_channel: str) -> None: """Called from BOTH the SMS handler and the webchat handler on STOP keywords.""" await optout_store.set_opted_out(phone_e164, source_channel, recorded_at=time.time()) # Mirror to Podium so their compliance view matches yours await mark_contact_optout_in_podium(phone_e164) async def handle_inbound_webchat(message: dict, auth: PodiumAuth) -> None: phone = normalize_phone(message["from"], message.get("country") or "AU") text = message["body"].strip().upper() if text in OPTOUT_KEYWORDS: await record_optout(phone, source_channel="webchat") return # do NOT send any reply if await check_optout(phone): # Customer previously opted out via SMS; refuse to handle the webchat # outbound side. Log for audit, do not reply. log_optout_blocked(phone, channel="webchat") return await process_webchat_message(message, auth) ``` The opt-out check must run on every outbound attempt — not just at session start — because the opt-out can land between the session opening and a reply being composed. Cache the opt-out lookup for at most 60 seconds; longer caching reintroduces the propagation lag. ## Error Handling | HTTP Status | Podium Error | Root Cause | Action | |---|---|---|---| | `400 Bad Request` | `invalid_phone_format` | Phone not in E.164 | Normalize at the widget before submit | | `400 Bad Request` | `invalid_location_uid` | Unknown or wrong-format location_uid | Reload the locations list; validate before submit | | `409 Conflict` | `contact_already_exists` | Race lost on contact creation | Refetch by phone; return the winner | | `413 Payload Too Large` | `attachment_exceeds_limit` | Attachment > 25 MiB | Validate client-side before upload | | `429 Too Many Requests` | `rate_limited` | Burst exceeded Podium per-location cap | Honor `Retry-After`; see `podium-rate-limit-survival` | | `451 Unavailable For Legal Reasons` | `contact_opted_out` | Outbound to a STOP'd contact | Block at your handler before the call ever reaches Podium | ## Examples ### Normalize a phone at the widget ```bash python3 scripts/phone_normalize.py --phone "0412 345 678" --default-country AU # +61412345678 python3 scripts/phone_normalize.py --phone "(415) 555-1234" --default-country US # +14155551234 ``` ### Wire the ingest handler into a FastAPI webhook ```python from fastapi import FastAPI, Request, HTTPException from podium_auth import PodiumAuth from webchat_ingest import process_inbound_webchat app = FastAPI() auth = PodiumAuth(...) @app.post("/podium/webchat") async def webchat_webhook(req: Request): payload = await req.json() try: await process_inbound_webchat(payload, auth) except WebchatError as e: raise HTTPException(status_code=400, detail=str(e)) return {"status": "ok"} ``` ### Idle-session scan as a background task ```python import asyncio from webchat_ingest import sessions, scan_sessions async def session_loop(): while True: await scan_sessions(sessions) await asyncio.sleep(60) # In your app startup asyncio.create_task(session_loop()) ``` ### Audit opt-out propagation for a phone ```bash python3 scripts/optout_audit.py --phone "+61412345678" ``` Output: ```json { "phone": "+61412345678", "optout_store": {"opted_out": true, "source": "sms", "recorded_at": 1746000000}, "podium_contact": {"opted_out": true}, "consistent": true } ``` ## Output - E.164 normalization helper invoked at the widget input boundary - Idempotent contact upsert with race-tolerant 409 handling - Webchat session timeout monitor with partial-state buffering - Client-side attachment size validation (≤ 25 MiB) - `location_uid` validation against a startup-loaded valid set (no default fallback) - Unified opt-out store consulted on every outbound across SMS + webchat ## Resources - [Podium API docs — Webchat](https://docs.podium.com/reference/webchat) - [Podium API docs — Contacts](https://docs.podium.com/reference/contacts) - [Podium API docs — Locations](https://docs.podium.com/reference/locations) - [Google libphonenumber](https://github.com/google/libphonenumber) — the canonical phone parser/validator - [phonenumbers (Python port)](https://pypi.org/project/phonenumbers/) - [config/settings.yaml](config/settings.yaml) — session timeouts, attachment limits, opt-out keywords, default country - [references/errors.md](references/errors.md) — ERR_WEBCHAT_* codes with cause + solution - [references/examples.md](references/examples.md) — 10 worked examples (ingest, dedup, routing, opt-out) - [references/implementation.md](references/implementation.md) — Node.js equivalents, FastAPI wiring, opt-out store schema - [scripts/phone_normalize.py](scripts/phone_normalize.py) — CLI: normalize a phone to E.164 with carrier metadata - [scripts/webchat_ingest.py](scripts/webchat_ingest.py) — FastAPI handler for webchat events - [scripts/session_timeout_monitor.py](scripts/session_timeout_monitor.py) — CLI: scan in-flight sessions - [scripts/optout_audit.py](scripts/optout_audit.py) — CLI: confirm opt-out flag across all layers