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 ""