Skip to main content

How SmartAI delivers results

SmartAI does not push results to a URL. Instead, it stores completed assessment events in a queue. Your server polls the queue — fetching and clearing events on a schedule.
SmartAI platform                   Your server
     │                                  │
     │  Candidate finishes assessment   │
     │  ─────────────────────────────►  │
     │  Event stored in queue           │
     │                                  │
     │          Every 30 seconds        │
     │  ◄─────────────────────────────  │  client.getWebhookEvents()
     │  Returns pending events          │
     │  ─────────────────────────────►  │
     │                                  │  You save to your DB
     │  ◄─────────────────────────────  │  client.acknowledgeWebhookEvents()
     │  Events removed from queue       │
You need to set up two things:
  1. The env vars on your server
  2. A background poller that runs every 30 seconds

Step 1 — Environment variables

Add these two lines to your server .env file:
# Server .env  (never put these in the frontend)
ASSESSMENT_API_KEY=wc_ak_test_your_key_here
ASSESSMENT_SECRET_KEY=wc_sk_test_your_secret_here
VariableWhat it isWhere to get it
ASSESSMENT_API_KEYIdentifies your organisationSmartAI dashboard → API Keys
ASSESSMENT_SECRET_KEYSigns every SDK requestSmartAI dashboard → API Keys
Both keys must be on your server only. Never add them to NEXT_PUBLIC_ variables or commit them to git. If your secret key is ever leaked, rotate it immediately from the dashboard.

Step 2 — Initialise the client

Create one AssessmentClient instance. You can do this once globally or once per poll call — both are fine.
const AssessmentClient = require('@recordorg/smartai-assessment-backend');

const client = new AssessmentClient({
  apiKey:    process.env.ASSESSMENT_API_KEY,
  secretKey: process.env.ASSESSMENT_SECRET_KEY,
});

Step 3 — What to pass to getWebhookEvents()

client.getWebhookEvents(options)
limit
number
default:"50"
How many events to fetch in one call. Maximum is 100.
// Fetch up to 50 events at once (recommended)
client.getWebhookEvents({ limit: 50 })
Start with 50. If you have a very high assessment volume (hundreds per hour), increase to 100.
since
number
A Unix timestamp in milliseconds. Only returns events that were stored after this time.
// Only fetch events from the last hour
const oneHourAgo = Date.now() - 60 * 60 * 1000;
client.getWebhookEvents({ since: oneHourAgo })
Leave this out for normal polling — you don’t need it unless you’re backfilling a time window.

What comes back

const { events, pendingCount } = await client.getWebhookEvents({ limit: 50 });
events
array
Array of assessment result objects. Each one has all the candidate’s result data. See Webhook Event Schema for every field.
pendingCount
number
How many events are still waiting in the queue after this batch.
  • 0 → queue is empty, nothing more to process
  • > 0 → there are more events — call getWebhookEvents again to get the next batch

Step 4 — What to pass to acknowledgeWebhookEvents()

client.acknowledgeWebhookEvents(eventIds)
eventIds
string[]
required
An array of eventId strings — one for each event you successfully saved to your database.
// After saving, pass the IDs you processed
await client.acknowledgeWebhookEvents(['evt_01J9XYZABC', 'evt_01J9XYZDEF'])

// In practice — collect from the events array
await client.acknowledgeWebhookEvents(events.map(e => e.eventId))
Events you do not acknowledge stay in the queue and are returned again on the next poll.

What comes back

acknowledged
number
The number of events that were removed from the queue.
{ "acknowledged": 2 }

Step 5 — Complete poller setup

Two event types come through the same queue. Your poller handles both:
event valueMeaningWhat to do
assessment.sentAssessment was sent to candidatesStore the invitation record, notify your app
assessment.completedCandidate submitted the examStore the result, notify recruiters
Copy this into your server. Place it in a file like src/jobs/pollAssessments.ts and call it when your server starts.
Node.js
// src/jobs/pollAssessments.ts
const AssessmentClient = require('@recordorg/smartai-assessment-backend');

async function pollAssessmentResults() {
  const client = new AssessmentClient({
    apiKey:    process.env.ASSESSMENT_API_KEY,
    secretKey: process.env.ASSESSMENT_SECRET_KEY,
  });

  // ─── 1. Fetch pending events ─────────────────────────────────────
  const { events, pendingCount } = await client.getWebhookEvents({ limit: 50 });

  if (events.length === 0) return;

  console.log(`Fetched ${events.length} events — ${pendingCount} still pending`);

  // ─── 2. Route and save each event ────────────────────────────────
  const processedIds: string[] = [];

  for (const event of events) {
    try {
      if (event.event === 'assessment.sent') {
        // Assessment was sent to candidates — store the invitation
        await saveAssessmentSent(event);

      } else if (event.event === 'assessment.completed') {
        // Candidate submitted — store the result
        await saveAssessmentResult(event);
      }

      processedIds.push(event.eventId);
    } catch (err) {
      console.error(`Failed to save event ${event.eventId}:`, err);
      // Don't push — event stays in queue and retries next poll
    }
  }

  // ─── 3. Acknowledge saved events ─────────────────────────────────
  if (processedIds.length > 0) {
    await client.acknowledgeWebhookEvents(processedIds);
    console.log(`Acknowledged ${processedIds.length} events`);
  }

  // ─── 4. Drain if more events are waiting ─────────────────────────
  if (pendingCount > 0) {
    await pollAssessmentResults();
  }
}

// ─── assessment.sent handler ──────────────────────────────────────────
async function saveAssessmentSent(event: any) {
  // event.candidates is an array — one record per candidate
  for (const candidate of event.candidates) {
    await db.assessmentInvitations.upsert({
      where:  { eventId: event.eventId, candidateEmail: candidate.email },
      create: {
        eventId:       event.eventId,
        assessmentId:  event.assessmentId,
        assessmentUrl: event.assessmentUrl,  // URL to send to the candidate
        jobTitle:      event.jobTitle,
        orgId:         event.orgId,
        candidateId:   candidate.id,
        candidateName: candidate.name,
        candidateEmail:candidate.email,
        totalCandidates: event.totalCandidates,
        storedAt:      new Date(event.storedAt),
      },
      update: {},
    });
  }
}

// ─── Start the poller when your server boots ─────────────────────────
export function startAssessmentPoller() {
  setInterval(async () => {
    try {
      await pollAssessmentResults();
    } catch (err) {
      console.error('Assessment poller error:', err);
    }
  }, 30_000);

  pollAssessmentResults().catch(console.error);
  console.log('✓ Assessment result poller started (every 30s)');
}
Python
# jobs/poll_assessments.py
from smartai_assessment_backend import AssessmentClient
import os
import time
import threading

client = AssessmentClient(
    api_key=os.getenv("ASSESSMENT_API_KEY"),
    secret_key=os.getenv("ASSESSMENT_SECRET_KEY"),
)

def poll_assessment_results():
    result = client.get_webhook_events(limit=50)
    events = result.get("events", [])
    pending_count = result.get("pendingCount", 0)

    if not events:
        return

    print(f"Fetched {len(events)} events — {pending_count} still pending")

    processed_ids = []

    for event in events:
        try:
            if event.get("event") == "assessment.sent":
                save_assessment_sent(event)
            elif event.get("event") == "assessment.completed":
                save_assessment_result(event)

            processed_ids.append(event["eventId"])
        except Exception as err:
            print(f"Failed to save event {event['eventId']}: {err}")

    if processed_ids:
        client.acknowledge_webhook_events(processed_ids)
        print(f"Acknowledged {len(processed_ids)} events")

    if pending_count > 0:
        poll_assessment_results()  # drain remaining


def start_assessment_poller():
    def run():
        while True:
            try:
                poll_assessment_results()
            except Exception as err:
                print(f"Assessment poller error: {err}")
            time.sleep(30)

    thread = threading.Thread(target=run, daemon=True)
    thread.start()
    print("✓ Assessment result poller started (every 30s)")
Call it when your FastAPI server starts:
Python (FastAPI startup)
from fastapi import FastAPI
from jobs.poll_assessments import start_assessment_poller

app = FastAPI()

@app.on_event("startup")
def startup():
    start_assessment_poller()  # ← add this
Call it when your Express server starts:
// src/index.ts  (your server entry point)
import { startAssessmentPoller } from './jobs/pollAssessments';

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);

  startAssessmentPoller(); // ← add this line
});

Step 6 — Save function (what fields to store)

// The fields you should save from each event
async function saveAssessmentResult(event: any) {
  await db.assessmentResults.upsert({
    // ── Key (MUST be unique — prevents duplicate saves) ───────────
    where: { eventId: event.eventId },

    create: {
      // Identity
      eventId:        event.eventId,         // "evt_01J9XYZABC"   ← primary key
      candidateEmail: event.candidateEmail,  // "priya@example.com" ← match to your user
      candidateName:  event.candidateName,   // "Priya Sharma"

      // Assessment info
      assessmentId:   event.assessmentId,    // "asmt_xyz789"
      assessmentName: event.assessmentName,  // "Full Stack Developer Assessment"
      jobTitle:       event.jobTitle,        // "Senior Software Engineer"
      skills:         event.skills,          // ["JavaScript", "React", "Node.js"]

      // Result
      score:          event.score,           // 78
      totalMarks:     event.totalMarks,      // 100
      passMarks:      event.passMarks,       // 60
      passed:         event.passed,          // true
      status:         event.status,          // "completed" | "timeout" | "abandoned"
      submittedAt:    new Date(event.submittedAt),
      durationMinutes: event.durationMinutes ?? null,

      // AI feedback & links
      aiFeedback:     event.aiFeedback ?? null,
      reportUrl:      event.reportUrl,       // share this with recruiters

      // Proctoring (integrity)
      proctoringScore:          event.proctoring?.score ?? 100,
      proctoringViolationCount: event.proctoring?.violationCount ?? 0,

      // Recording
      recordingUrl:   event.recording?.url ?? null,  // video of the session

      storedAt:       new Date(event.storedAt),
    },

    update: {}, // if already saved — do nothing (idempotent)
  });
}

All options at a glance

getWebhookEvents() options

OptionTypeDefaultRequiredWhat it does
limitnumber50NoMax events to return per call (max 100)
sincenumberNoUnix ms timestamp — only return events after this time

acknowledgeWebhookEvents() options

OptionTypeRequiredWhat it does
eventIdsstring[]YesIDs of events to remove from the queue

Fields returned in each event

FieldTypeWhat you use it for
eventIdstringPrimary key in your DB
candidateEmailstringMatch to your user
candidateNamestringDisplay name
scorenumberThe result
totalMarksnumberAlways 100
passMarksnumberThreshold to pass
passedbooleanQuick pass/fail check
statusstringcompleted / timeout / abandoned
submittedAtstringWhen they finished
reportUrlstringShare with recruiters
proctoring.scorenumberIntegrity score 0–100
proctoring.violationCountnumberNumber of violations
recording.urlstringVideo of the session
aiFeedbackstringAI-written performance summary
questionAnswersarrayPer-question breakdown
See the full Webhook Event Schema for every field with detailed descriptions.

Polling interval guide

Your situationUse this interval
Want results almost instantly10_000 (10 seconds)
Normal production use30_000 (30 seconds) — recommended
Low volume or batch processing300_000 (5 minutes)
Do not poll faster than every 5 seconds. The platform will rate-limit your calls.

Common configuration mistakes

MistakeWhat happensFix
Forgot to call startAssessmentPoller() on server startResults never arrive in your DBAdd the call in your server entry file
Acknowledging before savingIf server crashes mid-save, event is lost foreverAlways save first, acknowledge after
Not using upsert on eventIdDuplicate rows if same event is fetched twiceUse upsert where eventId = ...
ASSESSMENT_API_KEY not set in server .envSDK throws Missing API keyAdd both keys to your server .env
Using NEXT_PUBLIC_ASSESSMENT_SECRET_KEYSecret key exposed in browserSecret key belongs in server .env only
Ignoring pendingCount > 0Misses events during high-volume periodsDrain the queue recursively or wait for next poll