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
| Event | Description |
|---|
score.completed | Scoring finished with results |
score.failed | Scoring failed with error details |
batch.completed | All 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:
| Header | Value |
|---|
X-Webhook-Id | Unique delivery ID (stable across retries) |
X-Webhook-Signature | t=<unix_seconds>,v1=<hex_digest> |
X-Webhook-Event | Event type |
X-Webhook-Timestamp | Unix timestamp (seconds) |
Using the SDK (recommended)
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.