> ## Documentation Index
> Fetch the complete documentation index at: https://nova.dweet.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Configure and verify webhook delivery for scoring results

## 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) |

Webhooks are sent when a scoring job finishes. Returning an already `completed` scoring job through idempotency does not send a new `score.completed` webhook. See [Idempotency](/embed-api/concepts/idempotency) for duplicate submission behavior.

## Payload: score.completed

```json theme={null}
{
  "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

```json theme={null}
{
  "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

```json theme={null}
{
  "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)

```bash theme={null}
npm install @nova-sdk/api
```

```typescript theme={null}
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;
  }
});
```

<Tip>
  The SDK rejects webhooks older than 5 minutes by default. Override with `options: { toleranceInSeconds: 600 }`.
</Tip>

### Manual verification

Signature header format: `t=<unix_seconds>,v1=<hex_digest>`. The digest covers `{timestamp}.{raw_body}`.

<CodeGroup>
  ```javascript Node.js theme={null}
  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)
    );
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool:
      parts = {}
      for part in signature_header.split(','):
          key, _, value = part.partition('=')
          parts[key] = value
      if 't' not in parts or 'v1' not in parts:
          return False

      signed_payload = f"{parts['t']}.{payload.decode('utf-8')}"
      expected = hmac.new(
          secret.encode('utf-8'),
          signed_payload.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(parts['v1'], expected)
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "fmt"
      "strings"
  )

  func verifyWebhookSignature(payload []byte, signatureHeader, secret string) bool {
      parts := map[string]string{}
      for _, part := range strings.Split(signatureHeader, ",") {
          kv := strings.SplitN(part, "=", 2)
          if len(kv) == 2 {
              parts[kv[0]] = kv[1]
          }
      }
      ts, ok1 := parts["t"]
      sig, ok2 := parts["v1"]
      if !ok1 || !ok2 {
          return false
      }

      signedPayload := fmt.Sprintf("%s.%s", ts, string(payload))
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(signedPayload))
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(sig), []byte(expected))
  }
  ```
</CodeGroup>

<Warning>
  Always use timing-safe comparison functions to prevent timing attacks.
</Warning>

## 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}`.

<Warning>
  Always implement a polling fallback. Check for applications stuck in "scoring" status and poll after a timeout (5 minutes is a good default).
</Warning>

## 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.

<Warning>
  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.
</Warning>

## Deduplication

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