mz_chatbot / app /api /webhooks.py
Shurem's picture
added multiple changes
ecef140
Raw History Blame Contribute Delete
7.41 kB
"""
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": "<uid>", "candidate": {"uid": "<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": "<uid>", "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 ""