Hiring & Ashby Integration¶
The hiring system bridges Ashby ⧉ (our ATS) with Alan Home, Gmail, and Slack to automate the offer lifecycle — from candidate reaching "Offer Review" to accepting the offer and triggering pre-onboarding.
Architecture overview¶
flowchart LR
Ashby -->|webhooks| API["Ashby webhook\n(api/ashby.py)"]
API -->|enqueues| BL["Business logic\n(hiring.py)"]
BL --> DB[(CandidateOffer)]
BL --> Slack["Slack\n#team_recruiting"]
BL --> Gmail["Gmail drafts"]
Slack -->|interactive actions| SB["Slackbot\n(slackbot.py)"]
SB --> DB
SB --> Ashby
Key files:
| File | Role |
|---|---|
api/ashby.py |
Webhook endpoint (POST /webhooks/ashby), routes events to business logic |
business_logic/hiring.py |
Core hiring logic: offer review, offer sending, candidate hired |
models/candidate_offer.py |
CandidateOffer model and CandidateOfferContractType enum |
slackbot.py |
Slack interactive handlers: modals, approvals, owner updates |
events.py |
CandidateOfferReadyForReview event published when offer details are first submitted |
Ashby webhook¶
The POST /webhooks/ashby endpoint receives events authenticated via SHA-256 signature (ashby-signature header).
Events are enqueued on the default RQ priority queue.
Handled events:
| Ashby event | Stage/action | Handler |
|---|---|---|
candidateStageChange |
→ "Offer Review" | process_candidate_state_change_offer_review() |
candidateStageChange |
→ "Offer" | process_candidate_state_change_offer() |
candidateHire |
— | process_candidate_hired() |
Applications from excluded departments (e.g. External HP) are silently ignored.
CandidateOffer model¶
The CandidateOffer model tracks the full lifecycle of an offer.
Fields¶
| Field | Type | Description |
|---|---|---|
application_id |
UUID (unique) | Ashby application ID |
candidate_id |
UUID | Ashby candidate ID |
owner_id |
FK → Alaner | Recruiter responsible for the offer |
alaner_id |
FK → Alaner (nullable) | Hired employee (set during pre-onboarding or for returning interns) |
approver_id |
FK → Alaner (nullable) | Who approved the offer |
Timestamps (all nullable, set as the offer progresses):
| Timestamp | Meaning |
|---|---|
approved_at |
Offer approved by HR via Slack |
sent_at |
Offer email draft created in Gmail |
accepted_at |
Candidate accepted (moved to "Hired" in Ashby) |
pre_onboarded_at |
Pre-onboarding completed (BambooHR + contract) |
expired_at |
Offer expired |
refused_at |
Candidate refused |
canceled_at |
Offer canceled by HR |
Offer details (filled via Slack modal):
| Field | Type | Description |
|---|---|---|
community |
Text | Engineering, Data, etc. |
area |
Text | Organizational area |
job_title |
Text | Role from cost function |
level |
Text | A1–I |
past_work_experience |
Float | Years of work experience |
past_internship_experience |
Float | Years of internship experience |
part_time_ratio |
Integer | 0–100 (100 = full-time) |
legal_entity |
Text | Alan SA, Marmot BE, etc. |
contract_type |
Enum | permanent, internship, apprenticeship, contractor, employer_of_record |
scope |
Text | Belgium, Canada, France, Global, Spain |
permalink |
Text | Slack thread URL |
Country mapping¶
The contract_country_code property derives the country from legal_entity:
| Legal entity | Country code |
|---|---|
| Marmot BE | be |
| Marmot Iberia | es |
| Alan CA Inc. | ca |
| Everything else | fr |
Candidate offer lifecycle¶
stateDiagram-v2
[*] --> OfferReview: Ashby webhook\ncandidateStageChange → "Offer Review"
OfferReview --> DetailsFilled: Recruiter fills\nSlack modal
DetailsFilled --> Approved: HR approves\nin Slack thread
DetailsFilled --> Canceled: HR rejects\nin Slack thread
Approved --> OfferSent: Ashby webhook\ncandidateStageChange → "Offer"
OfferSent --> Accepted: Ashby webhook\ncandidateHire
OfferSent --> Expired: Manually marked\nin Slack
OfferSent --> Refused: Manually marked\nin Slack
Accepted --> PreOnboarded: pre_onboard command\n(see Pre-Onboarding)
Canceled --> [*]
Expired --> [*]
Refused --> [*]
PreOnboarded --> [*]
Step 1: Offer Review¶
Triggered when a candidate is moved to the "Offer Review" stage in Ashby.
Validation checks (candidate is moved back to the previous stage if any fail):
- Candidate owner exists (Ashby hiring team member with role "Recruiter") and maps to an Alaner by email
- Decision meeting feedback exists (form
dac5e81f-...) - Candidate email is present
Actions:
- Create a
CandidateOfferrecord (or reuse existing for the same application) - If the candidate was previously interning at Alan (active Alaner with internship/apprenticeship contract), link the existing Alaner
- Post an "Offer Review" thread to
#team_recruitingwith candidate info, position, source, hiring manager, and Ashby profile link - Ping the recruiter to fill the offer details form via a Slack button
Step 2: Offer details (Slack modal)¶
The recruiter fills a modal with: community, job title, level, experience, part-time ratio, scope, contract type, legal entity, and area.
On first submission, the system:
- Saves details to
CandidateOffer - Publishes a
CandidateOfferReadyForReviewevent - Posts the decision meeting feedback and offer summary in the Slack thread
- Adds approve/reject/amend buttons
Amending an offer¶
The offer can be amended at any time before the candidate accepts, refuses, or the offer is canceled. The "Amend offer..." button reopens the details modal.
Who can amend: the candidate owner, or any Alaner with eu_tools_manage_hr permission.
Before approval: the amendment updates the offer summary in-place and keeps the approve/reject/amend buttons. Only experience, part-time ratio, legal entity, contract type, scope, and area can be changed — community, job title, and level are locked after approval.
After approval: the amendment additionally:
- Regenerates the offer email draft in the recruiter's Gmail (via
compose_offer_draft()) - Notifies
@talent_oncallin the thread that the offer was amended post-approval
An offer can no longer be amended once accepted_at, canceled_at, or refused_at is set.
Step 3: Approval¶
An Alaner with eu_tools_approve_candidate_offer permission (or eu_tools_manage_hr) clicks "Approve" in the Slack
thread:
- Sets
approved_atandapprover_id - Moves the candidate to the "Offer" stage in Ashby (which triggers Step 4)
Rejection sets canceled_at and posts a cancellation message.
Step 4: Offer email¶
Triggered when the candidate is moved to the "Offer" stage.
Validation:
- CandidateOffer exists, is approved, not canceled, and not already sent
Actions:
- Compute compensation from the compensation scheme (level + experience → salary, equity)
- Render a country-specific offer email template
- Create a Gmail draft in the recruiter's inbox (impersonated via Google service account)
- Set
sent_at - Notify the recruiter in the Slack thread that the draft is ready
Country-specific templates:
| Country | Contract type | Template |
|---|---|---|
| FR | Permanent | mail/offer.html |
| BE | Permanent | mail/offer_be.html |
| CA | Permanent | mail/offer_ca.html |
| FR | Internship | mail/offer_internship.html |
| BE | Internship | mail/offer_internship_be.html |
| ES | Internship | mail/offer_internship_es.html |
Step 5: Candidate hired¶
Triggered by the candidateHire Ashby event.
Actions:
- Set
accepted_at(first time only) - Update
owner_idto current recruiter - Create an onboarding email draft in the recruiter's Gmail (threaded with the offer email)
- Send a Slack DM to the recruiter notifying the draft is ready
Candidate owners¶
The candidate owner is the Recruiter responsible for the hiring process.
Determination¶
The owner is identified from the Ashby hiring team: the team member with role = "Recruiter". Their email is matched to
an Alaner record.
Stored as¶
CandidateOffer.owner_id (FK → Alaner.id).
Update flow¶
Updating the candidate owner (via the overflow menu on the Slack thread) requires the eu_tools_manage_hiring
permission:
- Open a modal with an external data select (searches active Alaners by name/handle)
- On submission:
- Update
CandidateOffer.owner_idin the database - Remove the old "Hiring Manager" from the Ashby application
- Add the new one as "Hiring Manager" via the Ashby API
- Post confirmation in the Slack thread
- Update
Distinction: owner vs alaner¶
| Field | Meaning |
|---|---|
owner_id |
Recruiter managing the process (always set) |
alaner_id |
The hired employee's Alaner record (set during pre-onboarding or for returning interns) |
Slack integration¶
All hiring interactions happen in #team_recruiting (prod) / #eng_sandbox (dev).
Thread structure¶
Each candidate gets a dedicated Slack thread with:
- Header: candidate name, position, source, hiring manager, Ashby/Alan Home profile links
- Overflow menu: update candidate owner
- Offer details button: opens the modal for the recruiter to fill
- Decision meeting feedback: posted after first form submission
- Approve/Reject buttons: for HR review
- Status updates: draft ready, offer sent, etc.
Permissions¶
| Permission | Allows |
|---|---|
eu_tools_manage_hiring |
Update candidate owner |
eu_tools_approve_candidate_offer |
Approve offers (except internships) |
eu_tools_manage_hr |
View/edit all offers, approve any offer |
Ashby API integration¶
The system calls the following Ashby API endpoints:
| Endpoint | Purpose |
|---|---|
application.info |
Fetch full application details |
applicationFeedback.list |
Get decision meeting feedback |
interviewStage.list |
List available pipeline stages |
application.change_stage |
Move candidate between stages |
applicationHiringTeamRole.list |
Get role IDs (for hiring team management) |
application.addHiringTeamMember |
Add new hiring manager |
application.removeHiringTeamMember |
Remove old hiring manager |
user.search |
Find Ashby user by email |
Authentication: HTTP Basic Auth with ASHBY_API_KEY_SECRET_NAME.