Spaces:
Running
Running
File size: 7,413 Bytes
ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 63cdee8 ff3c2e9 ecef140 3166bf7 ff3c2e9 63cdee8 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 3166bf7 2dc84e2 ff3c2e9 3166bf7 ecef140 3166bf7 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 2dc84e2 ff3c2e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """
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 ""
|