Skip to content

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
Hold "Alt" / "Option" to enable pan & zoom

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 --> [*]
Hold "Alt" / "Option" to enable pan & zoom

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

  1. Candidate owner exists (Ashby hiring team member with role "Recruiter") and maps to an Alaner by email
  2. Decision meeting feedback exists (form dac5e81f-...)
  3. Candidate email is present

Actions:

  1. Create a CandidateOffer record (or reuse existing for the same application)
  2. If the candidate was previously interning at Alan (active Alaner with internship/apprenticeship contract), link the existing Alaner
  3. Post an "Offer Review" thread to #team_recruiting with candidate info, position, source, hiring manager, and Ashby profile link
  4. 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 CandidateOfferReadyForReview event
  • 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_oncall in 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_at and approver_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:

  1. Compute compensation from the compensation scheme (level + experience → salary, equity)
  2. Render a country-specific offer email template
  3. Create a Gmail draft in the recruiter's inbox (impersonated via Google service account)
  4. Set sent_at
  5. 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:

  1. Set accepted_at (first time only)
  2. Update owner_id to current recruiter
  3. Create an onboarding email draft in the recruiter's Gmail (threaded with the offer email)
  4. 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:

  1. Open a modal with an external data select (searches active Alaners by name/handle)
  2. On submission:
    • Update CandidateOffer.owner_id in 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

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.