""" webhooks.py — SignalHire contact-reveal callback. This is the only route in the service that accepts an unauthenticated POST from the public internet. SignalHire cannot send an X-API-Key header, so the checks are: 1. an unguessable path token, compared in constant time 2. a candidate uid that must match a reveal WE started and have not yet completed 3. a body size cap, enforced before the body is parsed 4. a strict Pydantic model over the envelope, with the payload treated purely as data Confirmed against a live callback (2026-09-11): the body is a JSON array, one entry per requested candidate, shaped like [{"status": "success", "item": "", "candidate": {"uid": "", ...}}] "item" is the candidate uid this app originally submitted in `items: [uid]` to POST /candidate/search -- NOT the requestId that call returned. So the correlation key back to our own `reveal_requests` row is `signalhire_uid`, not `request_id` (which remains only as this app's own primary key / audit trail for that search call). Everything it writes goes through crud.contacts, which merges additively. A replayed or unrecognized uid is answered 404 and changes nothing. Deliberately NOT mounted behind require_api_key -- see main.py. """ from __future__ import annotations import hmac import json import logging from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from app.api.deps import get_db from app.config import settings from app.crud import contacts as crud_contacts from app.crud import enrichment_log as crud_enrich from app.crud import leads as crud_leads from app.services import contact_ingest, signalhire router = APIRouter(prefix="/webhooks", tags=["webhooks"]) log = logging.getLogger("outreach.webhooks") # SignalHire reveal payloads are small. Anything larger is not a reveal. MAX_CALLBACK_BYTES = 256 * 1024 @router.post("/signalhire/{token}", status_code=status.HTTP_200_OK) async def signalhire_reveal_callback( token: str, request: Request, db: Session = Depends(get_db), ) -> dict: """ Receive revealed contact details and merge them onto the lead that paid. Returns a deliberately bland body: a public endpoint should never echo back what it was sent, nor reveal whether a given request_id exists beyond the status code. """ expected = (settings.signalhire_webhook_token or "").strip() if not expected or not hmac.compare_digest(token, expected): # 404 rather than 401 so probing cannot distinguish "no token configured" # from "wrong token". raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raw = await request.body() if len(raw) > MAX_CALLBACK_BYTES: raise HTTPException( # Literal 413 rather than a starlette constant: the old # HTTP_413_REQUEST_ENTITY_TOO_LARGE name is deprecated and the new # HTTP_413_CONTENT_TOO_LARGE does not exist on older versions. status_code=413, detail="Callback body too large", ) try: payload = json.loads(raw or b"null") except ValueError: log.warning("signalhire callback body is not JSON (%d bytes)", len(raw)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Body is not JSON") from None uid = _uid_from(payload) if not uid: # Don't log the payload itself -- it carries the candidate's full # profile (name, employer, skills, phone...). Shape mismatches are # rare enough now that this app's own kind/keys are enough to debug. kind = type(payload).__name__ keys = list(payload[0].keys()) if isinstance(payload, list) and payload and isinstance(payload[0], dict) else ( list(payload.keys()) if isinstance(payload, dict) else [] ) log.warning("signalhire callback has no recognizable uid (payload=%s, keys=%s)", kind, keys) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No candidate uid in callback") row = crud_contacts.get_reveal_request_by_uid(db, uid) if row is None: # Not a reveal we started (or already completed). Never create state # from an unrecognized uid. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown request") revealed = signalhire.parse_reveal_callback(payload) merged = 0 if revealed: # Attribute the result to the person we asked about when the payload omits it. for item in revealed: if not item.get("uid"): item["uid"] = uid merged = crud_contacts.upsert_contacts(db, row.lead_id, revealed) crud_contacts.promote_primary(db, row.lead_id) else: log.warning("signalhire callback for uid %s carried no email or phone", uid) # SignalHire answered, but the field that actually matters (phone) can # still be blank on its own -- e.g. it delivered only an email. Try # ContactOut for the gap before giving up. See # contact_ingest.fill_contact_via_contactout for the other two call sites # (the initial POST failing outright, and a reveal that never gets a # callback at all). contact = crud_contacts.get_contact(db, row.contact_id) if row.contact_id else None if contact is not None and not (contact.phone or "").strip(): lead = crud_leads.get_lead(db, row.lead_id) if lead is not None and contact_ingest.fill_contact_via_contactout(db, lead, contact): merged += 1 delivered = bool(merged or revealed) got_phone = any((r.get("phone") or "").strip() for r in (revealed or [])) got_email = any((r.get("email") or "").strip() for r in (revealed or [])) crud_enrich.record( db, row.lead_id, "signalhire", "reveal", "hit" if got_phone else "miss", contact_id=row.contact_id, note="" if got_phone else ("email only" if got_email else "callback carried nothing"), ) crud_contacts.complete_reveal( db, row, "delivered" if delivered else "failed", "" if delivered else "callback carried no email or phone", ) return {"ok": True, "contacts": merged} def _uid_from(payload: object) -> str: """ Find the candidate uid wherever SignalHire puts it. Confirmed live shape: a list of `{"item": "", "candidate": {"uid": ...}}`. Still tolerant of a bare dict (single result posted directly) and of a couple of alternate key spellings, in case SignalHire's other endpoints or a future version shape it differently. """ def _from_entry(entry: dict) -> str: for key in ("item", "requestId", "request_id", "id"): val = entry.get(key) if isinstance(val, (str, int)) and str(val).strip(): return str(val).strip()[:64] candidate = entry.get("candidate") if isinstance(candidate, dict): val = candidate.get("uid") if isinstance(val, (str, int)) and str(val).strip(): return str(val).strip()[:64] return "" if isinstance(payload, list): for entry in payload: if isinstance(entry, dict): found = _from_entry(entry) if found: return found elif isinstance(payload, dict): return _from_entry(payload) return ""