SalesLab
ScienceVerticalsEnterprise
Sign inBook a session
SalesLab

The voice training studio for revenue teams. Built in Tampa. Deployed worldwide.

Built by the team that came out of Gong, Mindtickle, Second Nature, and Hyperbound.

Product

  • Live call
  • Scorecard
  • Coach view
  • Certifications
  • Real-call ingestion
  • Real-time assistant
  • Multiplayer
  • Verticals
  • Async channels
  • Tournaments
  • Swap & coach
  • Personal

For your team

  • CRO / VP Sales
  • Enablement / L&D
  • Revenue Operations

Science

  • Methodology
  • Scoring rubric
  • Latency thesis

Enterprise

  • Security
  • Deployment
  • Procurement Q&A
  • Contact founder

Resources

  • Trust center
  • Docs
  • API & webhooks
  • Developer platform
  • BI connectors
  • On-prem
  • Edge
  • Eval harness
  • Consent ledger
  • Hardware
  • Founders calendar
  • Public churn retros
  • Awards
  • Podcast network
  • Celebrity buyer
  • Job board
  • Wage Index
  • Skill insurance
  • Embed your score
  • Status
  • Launch notes

Legal

  • Privacy
  • Terms
  • Security
  • Book a session

© 2026 Sales Lab · saleslab.cloud

SOC 2 Type 1 in progress · GDPR compliant · HIPAA available on Enterprise

Sales Lab · Developer reference

API, webhooks, and MCP.

Pipe every Sales Lab event into your warehouse, your alerting stack, or your AI agent. Stripe-grade HMAC signing. Idempotent delivery IDs. Exponential-backoff retries.

1. Authentication

Mint a tenant-scoped token in /admin/api-tokens. Pass it as Authorization: Bearer sl_token_… on every REST call. Tokens are company-scoped and revocable instantly.

2. Outbound webhooks

Add a subscription in /admin/webhooks. We POST JSON to your URL whenever a matching event fires. The signing secret is shown exactly once on creation — store it in your secret manager before clicking away.

Delivery shape

POST https://your-app.com/saleslab/webhook
content-type: application/json
user-agent: sales-lab-webhooks/1
x-saleslab-event: session.scorecard_finalized
x-saleslab-delivery-id: scorecard.finalized:7c3f…:sub_9e
x-saleslab-signature: sha256=<HMAC>

{
  "event_kind": "session.scorecard_finalized",
  "company_id": "…",
  "scorecard": { … },
  "delivery_id": "scorecard.finalized:7c3f…:sub_9e"
}

Verify the signature

Compute HMAC-SHA256 over the raw request body using your subscription secret. Compare to the value after sha256= in x-saleslab-signature using a constant-time compare.

TypeScript / Node

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(
  secret: string,
  body: string,
  header: string,
): boolean {
  const sig = header.replace(/^sha256=/, '');
  const expected = createHmac('sha256', secret).update(body).digest('hex');
  const a = Buffer.from(sig, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

// In your handler — IMPORTANT: pass the raw body, not the parsed JSON.
export async function POST(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get('x-saleslab-signature') ?? '';
  if (!verify(process.env.SALESLAB_SIGNING_SECRET!, raw, sig)) {
    return new Response('bad signature', { status: 401 });
  }
  const event = JSON.parse(raw);
  // ... handle event.event_kind
  return new Response('ok');
}

Python

import hmac, hashlib
from flask import request, abort

SECRET = os.environ["SALESLAB_SIGNING_SECRET"].encode()

def verify(body: bytes, header: str) -> bool:
    sig = header.removeprefix("sha256=")
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

@app.post("/saleslab/webhook")
def handle():
    if not verify(request.get_data(), request.headers.get("x-saleslab-signature", "")):
        abort(401)
    event = request.get_json(force=True)
    # ... handle event["event_kind"]
    return "ok"

Go

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strings"
)

var secret = []byte(os.Getenv("SALESLAB_SIGNING_SECRET"))

func verify(body []byte, header string) bool {
    sig := strings.TrimPrefix(header, "sha256=")
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(sig), []byte(expected))
}

func Handle(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    if !verify(body, r.Header.Get("x-saleslab-signature")) {
        http.Error(w, "bad signature", 401)
        return
    }
    // ... parse + handle
    w.WriteHeader(200)
}

Retries + idempotency

We retry 5xx + network failures at 1m, 5m, 30m, 2h, 8h, then give up and mark the delivery failed. 4xx responses are not retried — fix the handler and resend via the admin UI’s “Retry” button. Each delivery has a unique delivery_id — store it and ignore repeats during retries.

Your handler should respond within 8 seconds; we’ll abort and retry if you don’t. The minute-cron drains the queue in batches of 50.

Test your endpoint

From /admin/webhooks, hit “Send test event” on your subscription. A synthetic session.scorecard_finalized is queued — your endpoint receives it within ~60s and you see the result (status code, body, attempts) in the same UI’s delivery log.

Event kinds

  • session.completed

    A rep ended a practice session. Fires when the scorecard finalizes.

    Emit point: Eleven Labs post-call webhook → scorecard insert seam.

    Payload shape
    {
      "event_kind": "session.completed",
      "company_id": "uuid",
      "session": {
        "id": "uuid",
        "user_id": "uuid",
        "scenario_id": "uuid",
        "ended_at": "2026-05-19T13:42:08.123Z"
      },
      "delivery_id": "session.completed:<session_id>:<sub_id>"
    }
  • session.scorecard_finalized

    Scorecard frozen. Full rubric + best/worst line in payload.

    Emit point: Fires alongside session.completed from the same scorecard insert.

    Payload shape
    {
      "event_kind": "session.scorecard_finalized",
      "company_id": "uuid",
      "session_id": "uuid",
      "user_id": "uuid",
      "scorecard": {
        "id": "uuid",
        "overall_score": 87,
        "verdict": "pass",
        "headline": "Strong discovery, clean close.",
        "category_scores": { "discovery": 9, "objection_handling": 8, ... },
        "best_line": "...",
        "worst_line": "...",
        "next_drill": "objection handling under price pressure"
      },
      "delivery_id": "scorecard.finalized:<scorecard_id>:<sub_id>"
    }
  • coaching.brief_delivered

    Weekly 1:1 brief sent. Contains the rep's celebrate + coach bullets.

    Emit point: Sunday-evening coaching-briefs cron after the email send confirms.

    Payload shape
    {
      "event_kind": "coaching.brief_delivered",
      "company_id": "uuid",
      "user_id": "uuid",
      "brief_id": "uuid",
      "week_start": "2026-05-12",
      "week_end": "2026-05-18",
      "summary": "Strong week — held quota mock on three of four scenarios.",
      "celebrate": ["...", "...", "..."],
      "coach": ["...", "...", "..."]
    }
  • coaching.streak_broken

    Rep's daily-practice streak ended. Fires on the check-in that detects the break.

    Emit point: /api/coaching/streak check-in handler when result.brokeStreak is true.

    Payload shape
    {
      "event_kind": "coaching.streak_broken",
      "company_id": "uuid",
      "user_id": "uuid",
      "broke_at_value": 12,
      "broken_at": "2026-05-19T13:42:08.123Z"
    }
  • badge.awarded

    New badge earned. One delivery per badge — five badges in one session = five deliveries.

    Emit point: lib/gamification/awards.ts after the user_badges row inserts.

    Payload shape
    {
      "event_kind": "badge.awarded",
      "company_id": "uuid",
      "user_id": "uuid",
      "badge_slug": "ninety_score",
      "awarded_at": "2026-05-19T13:42:08.123Z"
    }
  • real_call.imported

    Gong/Chorus call ingested and turned into a drill scenario.

    Emit point: /api/integrations/calls POST after the scenario insert succeeds.

    Payload shape
    {
      "event_kind": "real_call.imported",
      "company_id": "uuid",
      "ingest_id": "uuid",
      "source_url": "https://...",
      "source_vendor": "gong",
      "generated_scenario_id": "uuid",
      "failure_moment": {
        "buyer_line": "...",
        "rep_response": "...",
        "diagnosis": "...",
        "better_response": "..."
      }
    }
  • churn_retro.published

    Sales Lab published a public churn retro. Cross-tenant broadcast — every subscriber with this event kind (or no filter) receives it.

    Emit point: Operator publishes via /api/admin/retros; broadcastCustomerWebhookEvent fans out.

    Payload shape
    {
      "event_kind": "churn_retro.published",
      "company_id": "<the recipient tenant's id>",
      "slug": "westwind-q1-2026",
      "company_name": "Westwind Logistics",
      "reason_short": "Tried to roll out to 4 pods in 4 weeks.",
      "what_we_changed": "Mandatory 2-pod cap on rollouts under 60 days.",
      "published_at": "2026-05-19T13:42:08.123Z"
    }
  • realtime.autopop_ready

    A rep generated the CRM autopop for a finished realtime call. Carries the call summary, next step, key topics, and a draft follow-up email — everything a CRM integration needs to drop onto the opportunity record without the rep typing it.

    Emit point: app/api/realtime/sessions/[id]/autopop — after the autopop is generated + stored.

    Payload shape
    {
      "event_kind": "realtime.autopop_ready",
      "company_id": "uuid",
      "assist_id": "uuid",
      "user_id": "uuid",
      "summary": "3-5 sentence call summary.",
      "next_step": "One concrete next action with owner + timing.",
      "key_topics": ["pricing", "security review", "Q3 timeline"],
      "draft_followup_email": "Subject: ...\n\n<ready-to-send email body>",
      "duration_seconds": 540,
      "delivery_id": "realtime_autopop.<assist_id>:<sub_id>"
    }
  • coverage.subscription_activated

    An individual Sales Lab Skill Insurance subscription transitioned from paused/new to active (Stripe activation edge). Fires once per activation, not on every subscription.updated tick. Tenant-scoped to the rep's own company so HR can react to a teammate adding coverage.

    Emit point: app/api/webhooks/stripe — customer.subscription.{created,updated} with metadata.skill_insurance_user_id, paused→active transition.

    Payload shape
    {
      "event_kind": "coverage.subscription_activated",
      "company_id": "uuid",
      "user_id": "uuid",
      "plan": "individual",
      "stripe_subscription_id": "sub_...",
      "activated_at": "2026-05-19T13:42:08.123Z",
      "delivery_id": "coverage_activated:<user_id>:<sub_id>"
    }
  • wage_index.published

    The operator promoted a nightly snapshot to a published quarterly issue. Fired once per publish (unpublishes do not fire). Cross-tenant broadcast — every active subscription receives the event. Payload carries the full snapshot shape so consumers can render the issue without an extra fetch.

    Emit point: app/api/admin/wage-index PATCH — when publish=true.

    Payload shape
    {
      "event_kind": "wage_index.published",
      "company_id": "<the recipient tenant's id>",
      "snapshot_id": "uuid",
      "label": "Q2 2026 · May",
      "population_size": 4218,
      "median_score": 612,
      "median_percentile": 50,
      "per_tier_count": { "bronze": 421, "silver": 1283, "gold": 1604, "platinum": 712, "diamond": 198 },
      "bands": [ /* WageBand[] */ ],
      "generated_at": "2026-05-18T06:45:00.000Z",
      "published_at": "2026-05-19T13:42:08.123Z",
      "public_url": "https://saleslab.cloud/wage-index",
      "delivery_id": "wage_index.published:<snapshot_id>:<published_at>:<sub_id>"
    }
  • tournament.match_completed

    A tournament match was reported and finalized. Both participating tenants (if cross-company) get the event. Single-elim losers are auto-eliminated; the advancement cron picks up the next round on its next tick.

    Emit point: app/api/tournament-matches/[id]/result — after the match update + loser is_eliminated flip.

    Payload shape
    {
      "event_kind": "tournament.match_completed",
      "company_id": "uuid",
      "match_id": "uuid",
      "tournament": { "slug": "spring-2026-cup", "title": "Spring 2026 Cup" },
      "winner_team_id": "uuid",
      "loser_team_id": "uuid",
      "team_a_id": "uuid",
      "team_b_id": "uuid",
      "team_a_score": 87,
      "team_b_score": 71,
      "reported_by_user_id": "uuid",
      "winner_captain_user_id": "uuid",
      "ended_at": "2026-05-19T13:42:08.123Z",
      "delivery_id": "tournament_match.<match_id>.completed"
    }
  • score.tier_changed

    A rep crossed a Sales Lab Score tier boundary on the nightly recompute (bronze ↔ silver ↔ gold ↔ platinum ↔ diamond). Fires once per transition, deduped within a single computed_at.

    Emit point: app/api/cron/recompute-scores — after the user_scores upsert, detectTierTransition() compares prior vs. fresh tier.

    Payload shape
    {
      "event_kind": "score.tier_changed",
      "company_id": "uuid",
      "user_id": "uuid",
      "from_tier": "silver",
      "to_tier": "gold",
      "direction": "up",
      "total_score": 720,
      "percentile": 64,
      "computed_at": "2026-05-19T06:45:00.000Z",
      "delivery_id": "score_tier.<user_id>.<computed_at>"
    }
  • assignment.completed

    An open assignment matched a scored session for its rep + scenario and closed automatically. Fires once per assignment (FIFO when multiple are open).

    Emit point: app/api/webhooks/elevenlabs after scorecard insert — matches pending assignments by (user_id, scenario_id) and closes the oldest.

    Payload shape
    {
      "event_kind": "assignment.completed",
      "company_id": "uuid",
      "assignment_id": "uuid",
      "user_id": "uuid",
      "scenario_id": "uuid",
      "session_id": "uuid",
      "scorecard_id": "uuid",
      "overall_score": 87,
      "completed_at": "2026-05-19T13:42:08.123Z"
    }

3. BI / warehouse connectors

Two read-only Postgres views — point Looker, Tableau, Snowflake-via-FDW, or any JDBC client at them. RLS keeps each customer to their own rows.

  • warehouse_skill_progress

    One row per skill_snapshot. Columns: user_id, company_id, 10 skill score floats, taken_at.

  • warehouse_session_scores

    One row per scorecard. Columns: session_id, user_id, company_id, overall_score, verdict, discovery_score, objection_handling_score, control_score, scored_at.

See /connectors for Looker, Tableau, Snowflake, dbt recipes.

4. MCP server (Claude / GPT)

Point any MCP-compatible AI client at mcp.saleslab.cloud over SSE, or run the binary locally for Claude Desktop / Cursor.

  • · query_scorecard(session_id)
  • · list_recent_sessions(user_id, limit)
  • · create_scenario(prompt)
  • · start_practice_call(scenario_id)
  • · get_coaching_brief(user_id)

5. Rate limits + SLOs

REST API: 600 req/min/token, 10K req/day. Webhooks: batched every 60 seconds, your handler should return within 8 seconds. SLO: P99 delivery within 90s of event time.

Questions? Email dev@saleslab.cloud or book a founder on /founder.