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".
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
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".
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:
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.
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.
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.
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.
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.
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)
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:
import phonenumbers
from phonenumbers import NumberParseException, PhoneNumberFormat, is_valid_number
classPhoneValidationError(Exception):
passdefnormalize_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}")
ifnot is_valid_number(parsed):
raise PhoneValidationError(f"invalid phone for region {default_country}: {raw!r}")
return phonenumbers.format_number(parsed, PhoneNumberFormat.E164)
# Examplesassert 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.
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:
import httpx
from podium_auth import PodiumAuth
asyncdefupsert_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 phoneasyncwith 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 == 200and r.json().get("data"):
return r.json()["data"][0]
# Step 2: create — but tolerate 409 conflict from a racing creatorasyncwith 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 recordasyncwith 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 == 200and r2.json().get("data"):
return r2.json()["data"][0]
raise WebchatError(f"contact upsert failed: {r.status_code}{r.text}")
classWebchatError(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.
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:
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@dataclassclassWebchatSession:
session_uid: str
phone_e164: str
location_uid: str
last_message_at: float
partial_state: dict = field(default_factory=dict) # buffered multi-step answersdefidle_seconds(self) -> float:
return time.time() - self.last_message_at
defstatus(self) -> str:
idle = self.idle_seconds()
if idle >= SESSION_IDLE_CLOSE_SECONDS: return"close"if idle >= SESSION_IDLE_WARN_SECONDS: return"warn"return"active"asyncdefscan_sessions(sessions: dict[str, WebchatSession]) -> None:
"""Run on a 60s loop. Emit prompts and clean closures."""for uid, s inlist(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` answerawait 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.
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:
PODIUM_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024# 25 MiBclassAttachmentTooLargeError(Exception):
passdefvalidate_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.
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:
VALID_LOCATION_UIDS: set[str] = set() # populate at startup from Podium /v4/locationsasyncdefload_locations(auth: PodiumAuth) -> None:
token = await auth.get_token()
asyncwith 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"])
defvalidate_location(location_uid: str | None) -> str:
ifnot location_uid:
raise WebchatError("location_uid is required — refusing to route to a default")
if location_uid notin 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:
OPTOUT_KEYWORDS = {"STOP", "UNSUBSCRIBE", "QUIT", "END", "CANCEL", "OPTOUT"}
asyncdefcheck_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.returnawait optout_store.is_opted_out(phone_e164)
asyncdefrecord_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 yoursawait mark_contact_optout_in_podium(phone_e164)
asyncdefhandle_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 replyifawait 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")
returnawait 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
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
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")asyncdefwebchat_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
import asyncio
from webchat_ingest import sessions, scan_sessions
asyncdefsession_loop():
whileTrue:
await scan_sessions(sessions)
await asyncio.sleep(60)
# In your app startup
asyncio.create_task(session_loop())