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.