Skip to main content

Configuration

Configure webhook endpoints in the Embed Portal under Webhooks. Up to 5 endpoints per environment, each with its own signing secret.

Test webhooks

Use the Webhook Playground in the Embed Portal to send test webhooks with selectable event types and custom payloads. This verifies both connectivity and your signature verification logic.

Event types

EventDescription
score.completedScoring finished with results
score.failedScoring failed with error details
batch.completedAll jobs in a batch finished (completed or failed)

Payload: score.completed

{
  "event": "score.completed",
  "tenantId": "acme-corp",
  "scoringJobId": "cmlz26dl3001bwp61r8sjf2ka",
  "criteriaVersionId": "cmlz26ck2000awp61q9qcjcne",
  "jobId": "job-123",
  "applicationId": "app-456",
  "result": {
    "score": 7,
    "assessment": {
      "verdict": "Strong candidate with solid backend experience...",
      "strengths": [
        "6 years of backend engineering experience",
        "Strong Go proficiency"
      ],
      "concerns": [
        "No direct Kubernetes experience"
      ],
      "interviewFocus": [
        "Probe depth of distributed systems knowledge"
      ]
    }
  },
  "completedAt": "2025-01-15T10:30:45Z"
}

Payload: score.failed

{
  "event": "score.failed",
  "tenantId": "acme-corp",
  "scoringJobId": "cmlz26dl3001bwp61r8sjf2ka",
  "criteriaVersionId": "cmlz26ck2000awp61q9qcjcne",
  "jobId": "job-123",
  "applicationId": "app-456",
  "error": {
    "code": "RESUME_FETCH_FAILED",
    "message": "Failed to fetch resume: URL expired or inaccessible"
  },
  "failedAt": "2025-01-15T10:30:15Z"
}

Payload: batch.completed

{
  "event": "batch.completed",
  "tenantId": "acme-corp",
  "batchId": "cmlz26em4002cwp61t7ukh3lb",
  "jobId": "job-123",
  "totalJobs": 50,
  "completedJobs": 48,
  "failedJobs": 2,
  "status": "failed",
  "completedAt": "2025-01-15T10:42:21Z"
}
Status values: completed (all succeeded) or failed (at least 1 failed).

Signature verification

Webhooks are signed with HMAC-SHA256. Always verify signatures. Headers:
HeaderValue
X-Webhook-IdUnique delivery ID (stable across retries)
X-Webhook-Signaturet=<unix_seconds>,v1=<hex_digest>
X-Webhook-EventEvent type
X-Webhook-TimestampUnix timestamp (seconds)
npm install @nova-sdk/api
import { Nova, WebhookSignatureVerificationError } from '@nova-sdk/api';
import type { WebhookEvent } from '@nova-sdk/api';

app.post('/webhooks/nova', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event: WebhookEvent = await Nova.webhooks.constructEvent({
      payload: req.body.toString(),
      signatureHeader: req.headers['x-webhook-signature'] as string,
      secret: process.env.NOVA_WEBHOOK_SECRET!,
    });

    switch (event.event) {
      case 'score.completed':
        console.log(`Score: ${event.result.score}`);
        break;
      case 'score.failed':
        console.log(`Error: ${event.error.code}`);
        break;
      case 'batch.completed':
        console.log(`Batch done: ${event.completedJobs}/${event.totalJobs}`);
        break;
    }

    res.status(200).send('OK');
  } catch (err) {
    if (err instanceof WebhookSignatureVerificationError) {
      return res.status(401).send('Invalid signature');
    }
    throw err;
  }
});
The SDK rejects webhooks older than 5 minutes by default. Override with options: { toleranceInSeconds: 600 }.

Manual verification

Signature header format: t=<unix_seconds>,v1=<hex_digest>. The digest covers {timestamp}.{raw_body}.
import crypto from 'crypto';

function verifyWebhookSignature(payload: string, signatureHeader: string, secret: string): boolean {
  const parts: Record<string, string> = {};
  for (const part of signatureHeader.split(',')) {
    const [key, ...rest] = part.split('=');
    parts[key] = rest.join('=');
  }
  if (!parts.t || !parts.v1) return false;

  const signedPayload = `${parts.t}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(parts.v1),
    Buffer.from(expected)
  );
}
Always use timing-safe comparison functions to prevent timing attacks.

IP whitelisting

Signature verification is sufficient for most integrations. If your security policy requires IP whitelisting on top, contact us through the Embed Portal for current webhook source IP ranges.

Retry policy

  • Up to 5 attempts with exponential backoff and jitter
  • Delay range: 1 minute to 30 minutes
After all retries fail, results stay accessible via GET /v1/jobs/{jobId}/applications/{applicationId}/scoring-jobs/{scoringJobId}.
Always implement a polling fallback. Check for applications stuck in “scoring” status and poll after a timeout (5 minutes is a good default).

Manual retry of failed scoring jobs

Portal admins can retry permanently failed scoring runs from the Monitoring page. A successful retry resets the same scoringJobId back to pending, so your webhook consumer may receive score.failed followed by score.completed (or another score.failed) for the same ID.
Don’t treat score.failed as a permanent terminal state per scoringJobId. If you cache results keyed on scoringJobId, update the cache when a later score.completed arrives for the same ID.

Deduplication

Use the X-Webhook-Id header as a deduplication key. This ID is stable across retries. Webhooks may be delivered more than once.