The Problem: Manual Screening Doesn't Scale

A typical engineering role at a growth-stage company generates 200–600 applicants. If you're running a tight recruiting operation, you might have one person doing first-pass review. At 2 minutes per resume, 400 applications = 13 hours of work before anyone has been screened.

The throughput problem compounds a quality problem. Manual screening produces inconsistent outcomes: the first reviewer on Monday morning with fresh eyes makes different calls than the same person on Friday afternoon after reviewing 200 others. Recency bias, credential anchoring, and resume formatting all influence decisions in ways that have nothing to do with whether someone can do the job.

The typical response to this is to set up ATS keyword filters. But keyword matching rewards resume optimization skills, not engineering skills. You end up with a filtered shortlist that's full of people who know the right words — and missing people who have the right skills but expressed them differently.

What you actually need is a scoring system that fires automatically, evaluates every applicant against the role, and surfaces a ranked shortlist for human review. Webhooks are how you trigger it.

How ATS Webhooks Work

All three major ATS platforms — Ashby, Greenhouse, and Lever — support outbound webhooks. When a candidate-related event happens in the ATS (new application, stage change, hire decision), the ATS sends an HTTP POST to a URL you configure. Your server receives the payload and does whatever you want with it.

The general pattern is identical across platforms, though the payload schema differs:

1
Candidate Applies
Candidate submits application in Ashby/Greenhouse/Lever. ATS creates application record with resume text and job description.
2
ATS Fires Webhook
ATS sends POST to your webhook endpoint with application payload. Delivery is usually within 5–30 seconds of the triggering event.
3
Your Server Receives Event
Your endpoint validates the signature (HMAC), extracts resume and job data from the payload, and immediately returns 200 OK.
4
Async Scoring Pipeline
Score is computed by calling the resume scoring API with the extracted data. Scoring runs async so the webhook response is instant.
5
Score Written Back to ATS
Fit score and evaluation written back to the candidate record via ATS API (custom field, note, or tag). Hiring team sees scores directly in the ATS.

The two things that vary per platform are the webhook payload format and the write-back mechanism. The scoring logic in the middle is platform-agnostic.

Platform Comparison

ATS Webhook event Resume access Write-back HMAC auth
Ashby applicationSubmit Custom fields (text) Custom fields API ✓ Required
Greenhouse application.created Resume attachment URL Application notes API ✓ Required
Lever candidateStageChange / candidateCreated Resume file URL Candidate notes API ⚠ Optional
Ashby
Greenhouse
Lever

Building an Automated Scoring Pipeline

The webhook receiver is a standard Express route. Its job is minimal: validate the request, extract what you need, respond 200, and hand off to async processing. The scoring call happens after the response — you never want scoring latency to block the webhook acknowledgment.

The Webhook Receiver

JavaScript
const express = require('express'); const crypto = require('crypto'); const router = express.Router(); // Raw body needed for HMAC signature verification router.use(express.raw({ type: 'application/json' })); // ─── ASHBY WEBHOOK ─── router.post('/webhooks/ashby', async (req, res) => { // 1. Verify HMAC signature const sig = req.headers['x-ashby-signature']; if (!verifyAshbySignature(req.body, sig)) { return res.status(401).json({ error: 'Invalid signature' }); } const payload = JSON.parse(req.body); // 2. Only process applicationSubmit events if (payload.action !== 'applicationSubmit') { return res.status(200).json({ received: true, processed: false }); } // 3. Respond immediately — never let scoring block the webhook res.status(200).json({ received: true }); // 4. Score async after response is sent processAshbyApplication(payload).catch(err => { console.error('[ashby-webhook] scoring failed:', err.message); }); }); function verifyAshbySignature(rawBody, signature) { const expected = crypto .createHmac('sha256', process.env.ASHBY_WEBHOOK_SECRET) .update(rawBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); }
Use raw body for signature verification

If you parse the body with express.json() before signature verification, the HMAC check will fail — JSON serialization isn't always identical to the raw bytes. Mount express.raw() on webhook routes specifically, and parse manually with JSON.parse(req.body) after verification.

Async Scoring with the Stackwright API

The processAshbyApplication function extracts resume and job data from the Ashby payload, calls the scoring API, and writes the result back. Ashby stores resume text in application custom fields — you configure these in your Ashby job form.

JavaScript
async function processAshbyApplication(payload) { const { application, candidate } = payload.data; // Extract resume text from custom field const resumeField = application.customFields?.find( f => f.title === 'Resume Text' ); const jobField = application.customFields?.find( f => f.title === 'Job Description' ); if (!resumeField?.value || !jobField?.value) { console.warn(`[ashby] missing resume or job data for candidate ${candidate.id}`); return; } // Call Stackwright scoring API const response = await fetch('https://stackwright.polsia.app/api/v1/score-resume', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.STACKWRIGHT_API_KEY }, body: JSON.stringify({ resume: resumeField.value, job_description: jobField.value, required_skills: application.job?.requiredSkills ?? [] }) }); const score = await response.json(); // Write score back to Ashby custom field await writeScoreToAshby(application.id, score); console.log(`[ashby] scored candidate ${candidate.id}: ${score.fit_score} (${score.hire_signal})`); }

Full Example: Ashby → Score → Dashboard

Step 1: Configure Ashby Webhook

In Ashby, go to Settings → Integrations → Webhooks and add a new endpoint. Set the URL to your server's webhook path and select the applicationSubmit event. Ashby will show you the signing secret — copy it to your environment as ASHBY_WEBHOOK_SECRET.

You'll also need two custom application fields configured in your job form:

  • Resume Text (long text) — candidates paste their resume here, or your sourcing flow populates it
  • Job Description (long text) — populated automatically from the job's description field via Ashby workflow
Structured resume data vs. file uploads

Ashby can store resume text as a custom field, which is what makes webhook-triggered scoring practical. If your candidates upload PDF files instead, you'll need to add a parsing step — extract text from the PDF before scoring. Services like pdf-parse (Node.js) handle this, or you can use the Ashby API to fetch the file URL and parse it server-side.

Step 2: Write Scores Back to Ashby

Ashby's API lets you update custom field values on an application. After scoring, write the fit score, hire signal, and a short summary back to the candidate record so hiring managers see everything in one place.

JavaScript
async function writeScoreToAshby(applicationId, score) { // Ashby uses GraphQL for mutations const mutation = ` mutation UpdateApplicationCustomFields($applicationId: ID!, $fields: [CustomFieldUpdate!]!) { applicationUpdate(applicationId: $applicationId, customFields: $fields) { application { id } } } `; const scoreLabel = { 'strong_yes': '⬆ Strong Yes', 'yes': '✓ Yes', 'maybe': '~ Maybe', 'no': '✗ No' }[score.hire_signal] ?? score.hire_signal; await fetch('https://api.ashbyhq.com/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(process.env.ASHBY_API_KEY + ':').toString('base64')}` }, body: JSON.stringify({ query: mutation, variables: { applicationId, fields: [ { fieldName: 'AI Fit Score', value: String(score.fit_score) }, { fieldName: 'AI Hire Signal', value: scoreLabel }, { fieldName: 'AI Summary', value: score.summary } ] } }) }); }

Step 3: Surface Scores in Your Hiring Dashboard

Once scores are written back to Ashby, you can create a saved view that sorts candidates by AI Fit Score descending. The top of the list is always your best-matched candidates. Recruiters don't need to change their workflow — they work in Ashby exactly as before, except the pile is already sorted.

Typical time-to-score

From candidate submit to score appearing in Ashby: typically 8–15 seconds. Ashby fires the webhook within 5–10 seconds of submission, scoring takes 2–3 seconds, and the write-back is instantaneous. By the time a recruiter opens a new application, the score is already there.

Greenhouse and Lever Variations

Greenhouse Webhook API

Greenhouse fires application.created events when a new application is submitted. The payload includes a resume attachment object with a URL you can fetch to get the resume file. Because Greenhouse sends resume files rather than text, you'll need to parse the PDF.

JavaScript
const pdfParse = require('pdf-parse'); router.post('/webhooks/greenhouse', async (req, res) => { // Greenhouse uses x-greenhouse-signature for verification const sig = req.headers['signature']; const isValid = verifyGreenhouseSignature(req.body, sig); if (!isValid) return res.sendStatus(401); const payload = JSON.parse(req.body); res.sendStatus(200); if (payload.action !== 'application_created') return; processGreenhouseApplication(payload.payload).catch(console.error); }); async function processGreenhouseApplication(data) { const { application, job } = data; // Fetch and parse resume PDF let resumeText = ''; if (application.resume?.url) { const pdfBuffer = await fetch(application.resume.url) .then(r => r.arrayBuffer()) .then(buf => Buffer.from(buf)); const parsed = await pdfParse(pdfBuffer); resumeText = parsed.text; } if (!resumeText) { console.warn(`[greenhouse] no resume text for application ${application.id}`); return; } const score = await callStackwrightAPI(resumeText, job.notes, job.metadata?.required_skills); // Write back as a note on the application await writeGreenhouseNote(application.id, score); }

Lever Webhooks

Lever fires candidateCreated and candidateStageChange events. Lever's webhook format includes a data object with the candidate and opportunity (their term for application). Resume access is via a separate API call to GET /v1/candidates/{id}/resumes.

JavaScript
router.post('/webhooks/lever', async (req, res) => { // Lever signature is in x-lever-signature header const sig = req.headers['x-lever-signature']; if (!verifyLeverSignature(req.body, sig)) return res.sendStatus(401); const event = JSON.parse(req.body); res.sendStatus(200); if (event.event !== 'candidateCreated') return; const { candidateId, opportunityId } = event.data; // Fetch resume via Lever API const resumesResp = await fetch( `https://api.lever.co/v1/candidates/${candidateId}/resumes`, { headers: { 'Authorization': `Basic ${btoa(process.env.LEVER_API_KEY + ':')}` } } ); const { data: resumes } = await resumesResp.json(); if (!resumes?.length) return; const pdfBuf = await fetch(resumes[0].file.downloadUrl) .then(r => r.arrayBuffer()) .then(buf => Buffer.from(buf)); const { text: resumeText } = await pdfParse(pdfBuf); const jobDesc = await fetchLeverJobDescription(opportunityId); const score = await callStackwrightAPI(resumeText, jobDesc); await writeLeverNote(opportunityId, score); });

Production Notes

Reliability: Retries and Idempotency

ATS platforms retry webhook delivery if they don't receive a 2xx within a timeout window (usually 30 seconds). Your handler must be idempotent — scoring the same application twice should produce the same result and not create duplicate records.

Use the application ID as a deduplication key. Check whether you've already scored this application before calling the API:

JavaScript
async function processAshbyApplication(payload) { const applicationId = payload.data.application.id; // Deduplication check — skip if already scored const existing = await pool.query( 'SELECT id FROM scored_applications WHERE ats_application_id = $1', [applicationId] ); if (existing.rowCount > 0) { console.log(`[ashby] already scored ${applicationId}, skipping`); return; } // ... extract, score, write-back // Record that we've scored this application await pool.query( `INSERT INTO scored_applications (ats_application_id, scored_at, fit_score) VALUES ($1, NOW(), $2) ON CONFLICT DO NOTHING`, [applicationId, score.fit_score] ); }

Security: Always Verify Signatures

Webhook endpoints are public URLs. Anyone can POST to them. Always verify the HMAC signature before processing the payload. Use crypto.timingSafeEqual — not string equality — to prevent timing attacks.

Don't log raw payloads in production

Webhook payloads contain candidate PII (name, email, resume text). Don't log the full payload — log only the candidate ID and event type. If you need to debug a specific application, fetch it explicitly.

Monitoring the Pipeline

Track webhook delivery success in a simple table. This gives you a health dashboard and an audit trail for compliance:

SQL
CREATE TABLE scored_applications ( id SERIAL PRIMARY KEY, ats_platform VARCHAR(20) NOT NULL, -- 'ashby' | 'greenhouse' | 'lever' ats_application_id VARCHAR(100) UNIQUE NOT NULL, candidate_id VARCHAR(100), job_id VARCHAR(100), fit_score INTEGER, hire_signal VARCHAR(20), scoring_error TEXT, scored_at TIMESTAMP DEFAULT NOW() ); -- Quick health check: scoring success rate last 7 days SELECT ats_platform, COUNT(*) AS total, COUNT(fit_score) AS scored, COUNT(scoring_error) AS failed FROM scored_applications WHERE scored_at >= NOW() - INTERVAL '7 days' GROUP BY ats_platform;

What the Scoring API Returns

When the scoring call completes, you get a structured JSON object with everything you need to populate the ATS record and build a hiring dashboard. Here's an example response for a senior engineer applying to a distributed systems role:

{
  "success": true,
  "fit_score": 84,
  "hire_signal": "strong_yes",
  "summary": "Deep distributed systems background; 4 years on Kafka + Cassandra at scale; strong Kubernetes signal.",
  "strengths": [
    "Designed Kafka-based event pipeline processing 2M msgs/sec at previous role",
    "Led Cassandra schema design for multi-region replication across 4 DCs",
    "Kubernetes: managed 60-node cluster, wrote custom admission controllers"
  ],
  "gaps": [
    "No direct Go experience; primarily Scala/Java background"
  ],
  "skill_matches": {
    "Kafka": "strong",
    "Kubernetes": "strong",
    "Cassandra": "strong",
    "Go": "missing"
  },
  "processing_time_ms": 2187,
  "cached": false
}

Try It with the Demo Key

The Stackwright webhook endpoint is live and handles Ashby's applicationSubmit event natively. Point your Ashby webhook at https://stackwright.polsia.app/api/v1/webhooks/ashby?api_key=YOUR_KEY and scores appear in your ATS within seconds of each application.

Start scoring candidates automatically

Demo key gives you 10 scoring calls/day. No signup. Test the pipeline before going Pro.

X-API-Key: sk-sw-demo-stackwright2025

What's Next

The webhook pipeline described here gives you fully automated first-pass screening. Natural extensions from here:

  • Stage-triggered re-scoring — Fire on candidateStageChange to re-score against the updated role criteria when a candidate moves to phone screen.
  • Interview question generation — After scoring, call POST /api/v1/generate-questions to auto-generate role-specific interview questions based on the candidate's gaps. Stackwright's question API is already live.
  • Batch import — Score your existing backlog by exporting candidates from your ATS and running them through the scoring endpoint in a script. Cache means repeated calls on the same candidate are free.
  • Score drift monitoring — If your job description changes materially (new tech stack, different seniority), re-score open applications against the updated JD to keep rankings current.

The whole pipeline — webhook receiver, scoring API call, write-back — is about 80 lines of Express code. The complexity is in the ATS configuration, not the code.

Ready to integrate?

Start scoring resumes in minutes. Free tier ships immediately — no credit card. Pro starts at $49/mo for production scale.

Try the API Free → See Pricing →