Skip to main content
The scoring endpoint is idempotent by default, making it safe to retry requests without creating duplicate scoring jobs.

How It Works

We generate an idempotency key from tenantId + jobId + applicationId:
idempotency_key = hash(tenantId + jobId + applicationId)
Since criteria are stored server-side and linked to your jobId, they don’t need to be part of the idempotency key. This simplifies retry logic significantly.

Behavior

ScenarioWhat Happens
First requestCreates new scoring job
Duplicate while processingReturns same scoringJobId, job continues
Duplicate after completionReturns same scoringJobId with completed status

Idempotency Window

30 days. After 30 days, calling with the same IDs creates a new job.
This window is designed to handle retry scenarios while allowing re-scoring if needed over time.

Safe Retry Pattern

async function scoreWithRetry(payload, maxAttempts = 3) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const response = await fetch('https://embed.nova.dweet.com/v1/score', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'X-Tenant-Id': tenantId,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
        await sleep(retryAfter * 1000);
        continue;
      }

      if (!response.ok) {
        const error = await response.json();

        // Only retry on server errors
        if (response.status >= 500) {
          await sleep(1000 * Math.pow(2, attempt));
          continue;
        }

        throw new Error(error.error.message);
      }

      return await response.json();
    } catch (error) {
      if (attempt === maxAttempts - 1) throw error;
      await sleep(1000 * Math.pow(2, attempt));
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

What Changes Create New Jobs?

Field ChangedNew Job Created?
jobIdYes
applicationIdYes
rescore: trueYes
resumeUrlNo (idempotent)
languageNo (idempotent)
candidate.applicationAnswersNo (idempotent)

Re-scoring Candidates

Updating criteria via the criteria endpoints does not automatically re-score existing candidates. This is by design:
  • Scores are immutable audit records tied to the criteria version at scoring time
  • Prevents unexpected billing from automatic re-processing
  • Maintains consistency for candidates already in your pipeline

Using the rescore Parameter

To re-score a candidate, set rescore: true in your scoring request:
await scoreCandidate({
  jobId: 'job-123',
  applicationId: 'app-456',
  candidate: { resumeUrl: '...' },
  rescore: true  // Bypasses idempotency
});
This creates a new scoring job using the current criteria, even if this application was previously scored.
Billing note: Each scoring job counts as one application scored. Idempotent retries (same IDs without rescore: true) are not charged again. Re-scoring with rescore: true counts as an additional billable score.
When you update criteria via PATCH /v1/jobs/{jobId}/criteria/{criterionId}, future scoring requests use the new criteria. To re-score existing candidates with the updated criteria:
// Re-score with updated criteria
await scoreCandidate({
  jobId: 'job-123',
  applicationId: 'app-456',
  candidate: { resumeUrl: '...' },
  rescore: true
});

Use Cases

Automatic Retries on Network Failure

// Safe to retry - won't create duplicate jobs
const result = await scoreWithRetry({
  jobId: 'job-123',
  applicationId: 'app-456',
  candidate: { resumeUrl: '...' }
});

Webhook Missed, Polling Instead

// Submit score request (might be a duplicate)
const { scoringJobId } = await fetch('/v1/score', { ... });

// Safe to poll - returns existing result if already completed
const result = await fetch(`/v1/score/${scoringJobId}`);

Batch Import Recovery

// If your batch import fails halfway, just run it again
for (const application of applications) {
  // Idempotent - already-scored applications return existing results
  await scoreCandidate({
    jobId: application.jobId,
    applicationId: application.id,
    candidate: { resumeUrl: application.resumeUrl }
  });
}

Best Practices

Server errors are transient. Retrying is safe due to idempotency.
Increase delays between retries: 1s, 2s, 4s, etc.
Add random delay to prevent many clients retrying at the same time.
Don’t rely solely on API idempotency - track which jobs you’ve submitted.
In batch processing, track which items succeeded vs failed for retry.

Idempotency Checklist

Implement retry logic with exponential backoff
Handle 429 responses with Retry-After header
Retry only on 5xx errors, not 4xx
Add jitter to retry delays
Track submitted jobs for your own state management