Nooks events

Nooks is the dialer human reps use. Its call outcomes are ingested through a dedicated endpoint and a parallel dispatcher — no campaign or target rows are involved.

Endpoint

POST /api/webhooks/nooks

Dispatcher

NooksWebhookDispatcher in lib/events/nooks-event-handler.ts

Persistence

lib/repositories/nooks-repository.ts

Calls are stored with channelType = "nooks_call", campaignId = null and targetId = null — they are rep activity, not campaign activity.

Route flow

  1. Optional secret check — Authorization: Bearer or X-Nooks-Signature against NOOKS_WEBHOOK_SECRET.

  2. Upsert a WebhookEvent row with source = "Nooks", idempotent on eventId.

  3. Dispatch to the registered handler.

Event Handler File

call.logged

CallLoggedHandler

lib/events/handlers/nooks/call-logged.ts

Payload

{
  "event": "call.logged",
  "eventId": "evt_123",
  "occurredAt": "2026-01-01T00:00:00Z",
  "callData": {
    "callId": "nooks_call_123",
    "status": "ended",
    "durationSeconds": 120,
    "disposition": { "id": "disp_1", "name": "Connected" },
    "userData": { "userId": "rep_abc", "email": "rep@co.com", "name": "Jane Rep" },
    "prospectData": { "prospectId": "hs_contact_id", "phoneNumber": "+15555550100", "name": "John Prospect" },
    "recordingUrl": "https://…",
    "transcriptUrl": "https://…"
  }
}

Rep attribution

Nooks field Matched against Priority

userData.userId

SalesRep.nooksUserId

1st — workspace-scoped, then global

userData.email

SalesRep.email

2nd — case-insensitive

SalesRep.nooksUserId must be populated for the primary match; email is a fallback that breaks when a rep uses a different address in Nooks.

Post-processing

After NooksRepository.upsertCallFromCallLogged, two disposition-gated flows run in parallel.

call.logged received
        │
        ▼
NooksRepository.upsertCallFromCallLogged()
        │
        ├── disposition NOT in NOOKS_ANALYSIS_SKIP_DISPOSITIONS
        │        └── emit nooks/call.logged  →  nooksCallAnalysis
        │
        └── disposition IN NOOKS_CLOSED_LOST_DISPOSITIONS
                 └── resolveHubspotContactId() → publishCrmWorkflowEvent(Crm.DealUpdate)

AI analysis

Skipped for NOOKS_ANALYSIS_SKIP_DISPOSITIONS: "No Answer", "Voicemail", "Busy" — the call never connected, so there is nothing to grade.

Otherwise nooksCallAnalysis runs CallAnalysisService.runAndSave(callId, null), persisting a CallAiAgentAnalysisResult with campaignId = null. There is no campaign, so the workspace’s default analysis agent is used, and because there is no caller agent either, the system prompt is DEFAULT_HUMAN_CALL_ANALYSIS_SYSTEM_PROMPT — which reframes the task as sales coaching: does the logged disposition match the conversation, how were objections handled, are there compliance or tone issues.

Closed-lost CRM update

Triggered for NOOKS_CLOSED_LOST_DISPOSITIONS: "Not Interested", "Do Not Call", "Marketplace not supported", "No longer selling/shut down".

NooksRepository.resolveHubspotContactId resolves the contact:

  1. callData.prospectData.prospectId, used directly when Nooks provides it.

  2. Phone registry fallback — TargetPhoneNumbers → Contact.hubspotContactId.

Then publishes to CRM_QUEUE_URL:

{
  "event_type": "Crm.DealUpdate",
  "hubspot_contact_id": "<resolved>",
  "deal_stage": "closedlost",
  "note": "Outreach: Closed deal because of a disposition from Nooks: <disposition>. Call ID: <callId>"
}

If no HubSpot contact id can be resolved, a warning is logged and nothing is sent — an unresolvable CRM payload is worse than none.

Environment

NOOKS_WEBHOOK_SECRET=   # optional; enables Bearer / X-Nooks-Signature verification
CRM_QUEUE_URL=          # required for Crm.DealUpdate
AWS_REGION=             # required for SQS

Adding an event type

  1. Create lib/events/handlers/nooks/<event-name>.ts extending NooksEventHandler.

  2. Export it from lib/events/handlers/nooks/index.ts.

  3. Call this.register("<event.type>", HandlerClass) in the NooksWebhookDispatcher constructor.

Five9

POST /api/webhooks/five9 stores its payload and returns. It writes with source = "Nooks" and eventType = "five_9", generating a random eventId and providerCallId, so its rows are not idempotent on replay and share the Nooks source with genuine Nooks events. Filter on eventType to separate them. No dispatch occurs.