Pre-Onboarding¶
Once a candidate accepts an offer (moved to "Hired" in Ashby), they enter the pre-onboarding phase. This phase bridges the hiring decision with the employee's first day by collecting personal information, creating their BambooHR profile, generating an employment contract, and sending it for signature via DocuSign.
Architecture overview¶
flowchart LR
subgraph Inputs
OF[Onboarding Form\nGoogle Sheets]
CO[CandidateOffer\nDB]
end
subgraph Pre-onboard command
Match[Match by email]
Bamboo[Create BambooHR\nemployee]
Contract[Generate contract\nGoogle Docs]
Upload[Upload documents\nGoogle Drive]
end
subgraph Signature
DS[DocuSign\nenvelope]
WH[DocuSign webhook]
Archive[Archive to\nGoogle Drive]
end
OF --> Match
CO --> Match
Match --> Bamboo
Bamboo --> Contract
Contract --> Upload
Contract --> DS
DS --> WH
WH --> Archive
Key files:
| File | Role |
|---|---|
people_ops/commands/pre_onboarding.py |
CLI command and all pre-onboarding business logic |
people_ops/business_logic/onboarding_form.py |
OnboardingFormResult dataclass, reads from Google Sheets |
helpers/bamboohr.py |
BambooHR API client and field mapping |
helpers/docusign.py |
DocuSign API client (JWT auth) |
webhooks/docusign.py |
DocuSign webhook handler for envelope status updates |
alan_home/models/alaner_profile.py |
AlanerProfile model (drive directories, contract document IDs) |
alan_home/models/docusign_envelope.py |
DocusignEnvelope model |
alan_home/models/signed_document.py |
SignedDocument model |
End-to-end flow¶
flowchart TD
A["/onboarding_pre_onboard\nSlack command or cron"] --> B["Fetch pending CandidateOffers\n(accepted, approved, sent,\nnot yet pre-onboarded)"]
B --> C{For each offer}
C --> D{Offer accepted?}
D -->|No| E["Post status\n(not accepted yet)\n+ Expire/Refuse buttons"]
D -->|Yes| F{Returning Alaner?}
F -->|Yes| G["Mark pre_onboarded_at\nAsk HR to update\nBambooHR manually"]
F -->|No| H{Form match\nby email?}
H -->|No| I["Post status\n(form not filled)\n+ Force match dropdown"]
H -->|Yes| J["_pre_onboard_newcomer()"]
J --> K["1. Create BambooHR employee"]
K --> L["2. Create AlanerProfile\nwith restricted Drive dir"]
L --> M{"Permanent\ncontract?"}
M -->|Yes| N["3. Generate contract\n(Google Docs)"]
M -->|No| O["Skip contract"]
N --> P["4. Upload documents\nto Drive"]
O --> P
P --> Q["Post success to Slack\nwith BambooHR + Ashby links"]
Onboarding form¶
The onboarding form is a Google Sheets tab ("Onboarding form") in the salary spreadsheet. Candidates fill it after accepting the offer. The OnboardingFormResult dataclass captures all fields.
Fields collected¶
| Category | Fields |
|---|---|
| Personal | First name, last name, preferred name, gender, birthdate, birthplace (city/country), nationality, SSN |
| Address | Street number, street name, post code, province, city, country |
| Employment | Start date, office, work setup (remote/hybrid/on-site) |
| Banking | IBAN, BIC, bank institution, branch, account |
| FR-specific | Disabled worker status, dependent children count |
| BE-specific | Native language, marital status, income partner, dependent children, other dependents |
| ES-specific | ID number (DNI/NIE) |
| Education | Diploma name, school name, diploma URL |
| CA-specific | SIN card/confirmation letter URL, TD1 (federal + provincial) URL |
Matching logic¶
Candidates are matched to CandidateOffer records by email. If no match is found, HR can manually force-match via a Slack dropdown.
BambooHR integration¶
The _onboard_on_bamboohr() function creates a new employee in BambooHR.
Process¶
- Fetch all employee numbers from BambooHR to determine the next ID (
max + 1) - Build the employee payload (50+ fields) from the
CandidateOfferandOnboardingFormResult - POST to
/employeesendpoint - Extract the BambooHR employee ID from the response
Locationheader - Create an
AlanerProfilewith a restricted Google Drive directory - Set
CandidateOffer.pre_onboarded_atandCandidateOffer.alaner_id
Notable field mappings¶
| Computed field | Logic |
|---|---|
| Work email | {slugified_first}.{slugified_last}@alan.eu |
| Professional category | Derived from level (A1-C1 → 5, D-F → 6, G-I → 7 for FR/BE; "II"/"I" for ES) |
| Probation end date | FR: 3 months (cat. 5) or 4 months (cat. 6-7). ES: 6 months |
| Employment status | Pattern: [{COUNTRY}] Permanent Full-Time, [{COUNTRY}] Intern, [FR] Apprentice |
| Compensation | Computed via the country-specific compensation scheme (level + experience → salary, equity) |
| Dependents | FR: from fr_dependent_children. BE: be_dependent_children + be_other_dependent |
Contract generation¶
The generate_contract() function creates a personalized employment contract from a Google Docs template. It is called:
- Automatically by the
pre_onboardcommand for permanent contracts - Manually via the Slack action
open_generate_contract_modal, which lets HR select a BambooHR employee
Process¶
- Fetch employee data from BambooHR
- Select the appropriate contract template based on country and contract type
- Copy the template to the employee's restricted Google Drive directory (
0. Contract/) - Render template variables (name, address, salary, level, legal entity, etc.)
- Store the
employment_contract_document_idinAlanerProfile
Contract templates¶
| Template key | Contract type | Country |
|---|---|---|
fr |
Permanent | France |
es |
Permanent | Spain |
ca |
Permanent | Canada |
be_fr |
Permanent | Belgium (French) |
be_nl |
Permanent | Belgium (Dutch) |
apprenticeship |
Apprenticeship | France |
internship |
Internship | All |
Template selection for Belgium uses the candidate's native language (be_native_language from the onboarding form).
Template variables¶
The contract template is rendered with variables including:
FULL_NAME,ADDRESS,START_DATE,ROLE,LEVELYEARLY_SALARY,MONTHLY_SALARY,CATEGORY(professional category)IBAN,NATIONALITY,SSN,BIRTH_DATELEGAL_ENTITY,LEGAL_ENTITY_DESCRIPTION,LEGAL_REPRESENTATIVE_TITLEJOB_DESCRIPTION_{FR,EN,ES}(from a gsheet via Turing)FR_PROBATION_PERIOD,ES_EXCLUSIVITY_AGREEMENT
Legal entities¶
| Entity | Description |
|---|---|
| Alan SA | Main French entity |
| Alan Insurance | French regulated insurance entity |
| Alan Services | French services entity |
| Alan Tech | French tech entity |
| Marmot BE | Belgian entity |
| Marmot Iberia | Spanish entity |
| Alan CA Inc. | Canadian entity |
DocuSign integration¶
Sending for signature¶
The send_contract_for_signature() function creates a DocuSign envelope with two documents. It is called:
- Manually via the Slack action
open_send_contract_modal, which lets HR select a BambooHR employee and an HR signer (filtered byeu_tools_manage_hrpermission)
flowchart LR
subgraph Envelope
D1["Employment contract\n(from Google Docs → PDF)"]
D2["Acceptable Use Policy\n(DocuSign template)"]
end
subgraph Signers
S1["1. Newcomer\n(employee)"]
S2["2. People team\n(HR signer)"]
S3["3. Newcomer CC\n(carbon copy)"]
end
Envelope --> S1
Envelope --> S2
D1 --> S3
Process:
- Export the Google Docs contract to PDF
- Create a composite DocuSign envelope:
- Document 1: Employment contract (uploaded PDF + country-specific server template for signature fields)
- Document 2: Acceptable Use Policy (server template, newcomer-signed only)
- Configure recipients: newcomer (signer), People team member (signer), newcomer (CC)
- Envelope is created in "created" status (HR reviews before sending)
- Store
DocusignEnvelopeandSignedDocumentrecords in the database
The email subject follows the pattern: "{NAME} @ Alan {FLAG} - Your employment contract ✨"
DocuSign webhook¶
The POST /webhooks/docusign endpoint handles envelope lifecycle events:
stateDiagram-v2
[*] --> Created: send_contract_for_signature()
Created --> Sent: envelope-sent
Sent --> Delivered: envelope-delivered
Delivered --> Completed: envelope-completed
Delivered --> Declined: envelope-declined
Created --> Voided: envelope-voided
Completed --> Archived: Documents archived\nto Google Drive
| Event | Action |
|---|---|
envelope-sent |
Sets DocusignEnvelope.sent_at |
envelope-delivered |
Sets delivered_at |
envelope-completed |
Sets signed_at, downloads archive, uploads to employee's restricted Drive directory |
envelope-voided |
Sets voided_at |
envelope-declined |
Sets declined_at |
Authentication: SHA-256 base64 signature in x-docusign-signature-1 header.
Slack integration¶
Trigger¶
The pre-onboarding process is triggered by the /onboarding_pre_onboard Slack command (runs in #team_hr, or #eng_sandbox in dry-run mode).
Thread structure¶
The command posts a summary thread tagging @alaner_onboarding_crew:
- Header: count of pending offers and breakdown
- Per-offer messages with status:
- :check: Pre-onboarded successfully (with BambooHR + Ashby links)
- :yellowprogress: Form not filled (with force-match dropdown)
- :paused-red: Not accepted yet (with Expire/Refuse buttons)
- :warning: Error (with Datadog log explorer link)
Interactive actions¶
| Action | Handler | Description |
|---|---|---|
pre_onboarding_expire_offer |
expire_offer() |
Mark offer as expired |
pre_onboarding_refuse_offer |
refuse_offer() |
Mark offer as refused |
pre_onboarding_force_matching |
force_matching() |
Manually match offer to form result |
open_generate_contract_modal |
generate_contract() |
Open modal to select a BambooHR employee and generate their contract |
open_send_contract_modal |
send_contract_for_signature() |
Open modal to select employee + HR signer, creates DocuSign envelope |
Data model¶
erDiagram
CandidateOffer ||--o| Alaner : "owner (recruiter)"
CandidateOffer ||--o| Alaner : "alaner (employee)"
CandidateOffer ||--o| Alaner : "approver"
AlanerProfile ||--|| Alaner : "alaner_id"
AlanerProfile ||--o{ SignedDocument : "signed_documents"
SignedDocument }o--|| DocusignEnvelope : "docusign_envelope"
CandidateOffer {
uuid application_id
uuid candidate_id
int owner_id
int alaner_id
datetime pre_onboarded_at
}
AlanerProfile {
int alaner_id
string restricted_drive_directory_id
string employment_contract_document_id
}
SignedDocument {
enum type "employment_contract | acceptable_use_policy"
}
DocusignEnvelope {
string docusign_envelope_id
datetime sent_at
datetime delivered_at
datetime signed_at
datetime voided_at
datetime declined_at
}