A public, shareable apply form for every position — moving Utkrushta one step earlier in the hiring funnel: collect candidates before you assess them.
Locked: pool-only post-apply · one application per email per position · predefined fields + custom text questions · opaque token URL. Canonical text spec: Utkrushta/docs/applications-link-design.md · Live/commentable: html-docs.com/s/0967d77bc4.
Today a recruiter creates a position only once they already have candidates to assess. The Applications Link gives every position a public apply form (Ashby/Lever/Tally-simple). A submission creates a users row + an applications row, lands in the position's candidate pool, bumps the dashboard Candidates count automatically, and emails the candidate a confirmation. The recruiter invites pooled applicants to the assessment exactly as they do today — nothing about the assessment flow changes.
| Use case | How the link is used |
|---|---|
| LinkedIn job post | Paste the link as the job's apply URL — candidates land on the branded form. |
| Direct sends | Recruiter keeps the link handy for WhatsApp/email one-offs. |
| Careers page embed | v1: copy-paste button snippet. v2: WordPress / Framer / Webflow native integrations (§12). |
| In-person events | QR code download (print-ready PNG/SVG) for standees. |
applications rows (getApplicationMetricsForPositions()). Apply-link submissions appear in it with zero frontend work.One new table (position_apply_links), one new public API router, one new candidate page, and two recruiter surfaces. Everything downstream of the applications row is existing machinery.
End-to-end flow. Green boxes are new; white boxes are existing surfaces gaining a small addition.
applications.source_hashcode — apply-link rows must carry the org's source hash or recruiters never see them. 2) The dashboard count reads applications rows, but the legacy assessment testmaker still reads the positions.applications array — so the dual-write stays and must become atomic. 3) There is no public backend candidate endpoint today, and FastAPI has no rate limiter wired — abuse controls are v1 scope, not hardening.position_apply_links — chosen for an extensible future (multiple links/position, per-link metrics, form variants) and a clearer name; (2) channel → added_method; (3) applicant_email dropped — users.email is the store, dedup is keyed there.Entity changes. One new table (position_apply_links), four columns on applications, one apply_submit RPC. Everything else reused.
positions — the trade-offSeparate position_apply_links ✅ (chosen) | Columns on positions | |
|---|---|---|
| Extensibility | ✅ 1:N links, per-link metrics/variants without touching positions | ✗ each addition is a positions migration |
| Public GET | +1 JOIN (org JOIN happens anyway) | no extra JOIN |
| Token isolation | ✅ naturally isolated from position serializers | ⚠️ serializer must omit the token |
| positions width | ✅ stays lean | ⚠️ +6 cols on a ~40-col table |
| Cost | FK/cascade + one RLS-deviation block | none |
Decision: separate table, for extensibility. Columns would be simpler for a strict 1:1 v1 (the alt_ids precedent), but a dedicated table keeps the door open cheaply for things that would each otherwise need a positions migration: multiple links per position (campaign/source-specific, each with its own token — just drop the UNIQUE), per-link metrics (views→submits), form A/B variants, vanity slugs as an alias table, embed configs. Worth the one JOIN and the FK/RLS ceremony. (positions.public_link already exists — check its use; it may relate.)
{
"fields": { "linkedin": true, "resume": true, "ctc": true,
"availability": true, "location": true },
"custom_questions": [
{ "id": "srv-generated-uuid", "label": "Why this role?", "required": false }
]
}
Name, email, phone are always on and not represented in config. government_id / cover_video keys are reserved but not built in v1 (decisions D1/D2, §9). Custom-question ids are server-generated and immutable.
{
"ctc": { "current_lpa": 12.5, "expected_lpa": 18 },
"availability": { "bucket": "serving_notice", "last_working_day": "2026-08-14" },
"location": "Pune",
"custom_answers": [ { "id": "…", "label": "Why this role?", "answer": "…" } ],
"files": { "resume_key": "apply/{position_id}/{app_id}/resume.pdf" }
}
Labels are snapshotted at submit time — a question edited or removed later still renders correctly on historical applications, no tombstones. File refs under a distinct files key. Server validates against form_config, drops unknown keys, caps ~16 KB.
source_codes row per link; every application inserts with it (user row gets the existing ORGANIZATION→UTKRUSHT rewrite). Without it, the row is invisible on the recruiter's org-scoped candidates page. added_method='apply_link' is only the UI/reporting discriminator.users.email, checked globally. CI lookup (list_by_email_ci) across ALL user rows — a returning candidate reuses their user_id even on a new position. Then a source-agnostic check "any application for this user on this position?" 409s repeats. Race safety without a denormalized column: Turnstile tokens are single-use (kills accidental double-submit) + the existing (user_id, position_id, source_hashcode) index as DB backstop + an optional apply_submit RPC with a pg_advisory_xact_lock on (email, position) for bulletproof concurrency.New public FastAPI router fastapi_service/routers/v2/apply.py — FastAPI because the application-creation core (v2_add_application: canonicalization, ambiguity handling, backfill, dedup, insert_application) already lives there. The apply POST is a thin public wrapper around that core, not a parallel path.
| Endpoint | Behavior |
|---|---|
GET /v2/apply/{token} | Returns {state, position, organization, form_config, contact_email?}. Server computes a single state: open|closed from the lifecycle matrix (§10). 404 for unknown token AND for DRAFT positions (identical shape — unpublished roles don't leak). Compensation omitted server-side when is_salary_public=false; contact email only when enabled. Rate-limited. |
POST /v2/apply/{token} | Turnstile verify → re-check state (410 if closed mid-form) → canonicalize email/phone → CI lookup across all users → source-agnostic applied-check → create user if needed (no login cookie) → apply_submit RPC (added_method='apply_link', state='PENDING', org source hash, consent) → atomic array append → email only on first success → 201. Duplicate → uniform success, no email. |
POST /v2/apply/{token}/files | Multipart resume. Server derives key + pins bucket + forces private ACL — the existing upload endpoint's client-controlled bucket/key/ACL shape must never go anonymous. Content-Length cap before reading, stream to disk, magic-byte sniff, PDF/DOC/DOCX ≤10 MB. Temp prefix with ~7-day S3 lifecycle; submit verifies + copies refs to the permanent private prefix. Never touches the TUS post-finish hook (it sets public-read). |
position create/edit POST + GET /v2/positions/{id}/apply-link (testmaker JWT) | The POST payload grows an apply_form{} object; the handler upserts a position_apply_links row keyed on position_id (mints token on insert). One path for create AND edit; the frontend never invents tokens. The GET is get-or-create, so legacy positions gain a link lazily the first time the share modal opens. Duplicate-position route copies config into a fresh row + regenerates the token. |
Submit sequence. Resume uploads ride their own early request (progress + retry, never wedging submit). The users.email check + single-use Turnstile token — not a denormalized column — carry the duplicate guard.
Where the link is managed (create and edit — position-edit reuses the same review UI). It slots as a sibling top-level section between Skills and the assessment sections, using the page's exact idiom: flat sections, text-h3 heading + the local Toggle, config panels on bg-surface-light with 16px radius, and the Preliminary-Questions add/remove row pattern. The config rides the existing position POST payload as apply_form{}; the token is minted server-side when the row is created — the frontend never invents tokens.
A public link where candidates apply to this position. Applications land in your candidate pool — invite them to the assessment whenever you're ready.
Review-step section, rendered with the page's real tokens: text-h3 headings, the local 44×24 Toggle, bg-surface-light panels, "+ Add more" / "Remove Question" row idiom from Preliminary Questions.
Context: the card's right rail today is [Add candidates] [Share assessment ▾] [⋮] — and "Share assessment" is already a dropdown (Test / Task / How they are different?), with a quirk: task-only orgs bypass the menu entirely and jump straight into the task dialog.
Full-time • 5-10 years • Pune, India
Baseline — today's card. The Candidates metric (left tile) is the number apply-link submissions will grow automatically.
checkShareLink billing gating and the org-profile gate — easy to miss in implementation.?modal= deep links). Highest-risk option.Built on StandardModal, mirroring the ShareAssessmentDialog header pattern (icon + title + headerAction). New dependency: qrcode.react (SVG-rendered, crisp at any zoom).
Apply-link modal. QR: on-screen QRCodeSVG; downloads PNG 1024×1024 (≈8.7 cm at 300 dpi — desk standees) + SVG for poster-size print vendors; error-correction M; filename apply-<position-slug>.png. When the link is paused/closed, the body shows a banner + "Manage in position edit".
added_method). Kept a plain modal, not an over-built drawer. Forward-compat: when the candidates/positions merge lands (every card becomes a candidate card; "View Details" → "Candidate Details"), these answers move there — so it's built as a thin reader over form_responses that folds in later.app.utkrusht.ai/apply/<token>, in the candidate app (which already leaves /apply/* public). Server-component fetch, so first paint carries branding + fields with no spinner. Rendered below with the candidate app's real tokens: Poppins body, primary green #186844, shadcn Input (40px, 6px radius, #E5E5E5 border), the react-dropzone resume uploader, the green Header with the Playfair logo. Look-and-feel target: Ashby/Lever/Tally — single column, one field per row, section-grouped.
The apply form — candidate-app tokens (Poppins, #186844 primary, shadcn Input/dropzone). Contact email row appears only when the recruiter enabled it. Government ID and cover video are absent by design in v1 (§9, D1/D2).
Closed state (branded, never a dead 410 — these URLs live on in WhatsApp forwards) and the honest pool-only success screen (echoes the submitted email as a typo self-check; no assessment is implied to start now).
| Field | Input | Notes |
|---|---|---|
| Full name | single text — never first/last | autocomplete="name"; Indian names break first/last splits |
| lowercase+trim server-side; microcopy pre-legitimizes the confirmation email | ||
| Phone | react-phone-number-input, +91 default | inputmode="tel"; normalize "+91 / 0 / spaces" variants server-side, don't reject |
| Resume | dropzone, PDF/DOC/DOCX ≤10 MB | uploads on select (own request, progress + retry chip — never wedges submit); parse-and-prefill empty fields with "filled from your resume — please check"; degrade silently if slow |
| text | accept bare linkedin.com/in/x or handle, normalize to URL | |
| Location | city text | autocomplete="address-level2" |
| Notice period | picker: Immediate / ≤15d / 1m / 2m / 3m / Serving notice | Naukri's buckets — every Indian recruiter filters in these; "Serving notice" reveals a "Last working day" date field |
| CTC | numeric + "LPA" suffix, inputmode="decimal" | current optional, expected required-if-enabled; stored as numbers so recruiters can sort/filter the pool |
| Custom questions | short-answer textarea(s) | answers snapshot {id, label, answer} |
| Consent | checkbox, unticked, blocking | names org as controller + Utkrusht as processor; 18+ folded in; consented_at + notice_version stored |
Cross-cutting v1 requirements: explicit "(optional)" in labels · real <label for> + aria-describedby errors · validate on blur (mode:"onTouched"), scroll-to-first-error on submit · 16px input font (iOS zoom) · failed POST keeps all form state with inline retry · tested at 360px width on throttled 3G.
Sender name "Acme Corp via Utkrusht" — this email primes deliverability for the later assessment invite; it IS the product handoff. Content: position, org, the same "watch this inbox" next-steps copy, privacy-notice link (doubles as DPDP notice delivery), "reply to withdraw/delete", and "if this wasn't you, contact us". The address is unverified public input: never echo form responses, CTC, phone, or attachments. Sent strictly after a successful deduped insert — never on error branches. v1 = code template via render_email_v2(logo_url=org.logo); org-editable EmailTemplateType.application_received later. Flag: Google/Gmail must be added to the sub-processor list (legal #184).
Five adversarial reviews ran against this design (security/abuse, data-integrity, candidate UX, DPDP/GDPR, recruiter workflow). Everything they insisted on is folded into §§3-4 above. The three product decisions they surfaced were resolved with Naman on 2026-07-07 — all three recommendations accepted:
added_method='apply_link' is the exclusion selector).consented_at + notice_version stored (the burden of proving consent is ours) and a per-position org_privacy_policy_url.Reputation-isolated mail domain (a poisoned apply flow must not blacklist assessment invites — flagged loudly, accepted for v1) · token rotation · double-opt-in email verification (kills misdirected email at the root; revisit on abuse signals) · access logging on form_responses reads · multilingual consent text (DPDP language right) · automated retention purge.
| position.status | is_open | GET / POST result |
|---|---|---|
| DRAFT | any | 404 — indistinguishable from an unknown token (unpublished roles don't leak) |
| OPEN | true | live form |
| OPEN | false | branded closed page |
| CLOSED | any | branded closed page (positions can't be edited once CLOSED — closing the position permanently kills the link; is_open is the reversible dial) |
| deleted | — | 404 (FK cascade removed the link) |
is_open reset true. Duplicates are created OPEN — the copied link is live immediately (recruiter should know).| # | Module | Repo | Depends |
|---|---|---|---|
| M1 | Schema: position_apply_links table (RLS anon-deny) + 4 applications columns + apply_submit/array RPC + models/DAOs | Utkrushta | — |
| M2 | Public apply API: GET/POST/files, Turnstile, Postgres rate limit (first FastAPI limiter wiring), email hook | Utkrushta | M1 |
| M3 | Recruiter config API: apply_form{} in the position payload upsert, get-or-create endpoint, duplicate-route carry | Utkrushta | M1 |
| M4 | Review-step section (create + edit) | recruiter-utkrusht | M3 |
| M5 | Share surfaces: Share-menu rework (Option A) + apply-link modal (copy/QR/embed, qrcode.react) + success-screen row | recruiter-utkrusht | M3 |
| M6 | Candidate apply page: /apply/[token], form, anon resume upload, states, consent, parse-prefill slice | utkrushta-assessment | M2 |
| M7 | Confirmation email template + send hook | Utkrushta | with M2 |
| M8 | "View Application" row action + modal (thin reader over form_responses) + "Applied via link" tag on the candidates page | recruiter-utkrusht | M1 |
| M9 | Legal/doc updates: candidate notice, DPA, ToS clause, sub-processors, retention row | legal | launch-blocking, parallel |
| M10 | QA: race/dup tests, abuse drill (flood, oversized files, closed mid-form), 360px + 3G pass | all | M4-M8 |
Sequencing. M1 first; backend pair in parallel; then three frontend modules in parallel; QA gate; legal in parallel throughout.
| Item | Shape |
|---|---|
| WordPress / Framer / Webflow integrations | v1 ships the copy-paste button snippet. v2: build one hosted embed.js widget (styled Apply button / inline form), then wrap it three times — WP plugin (shortcode [utkrusht_apply token=…]), Framer code component, Webflow app/embed block. Iframe variant needs frame-ancestors CSP decisions, and the consent block must load inside the embed with no tracking cookies. |
| Vanity slugs | /apply/{org}/{position} layered over tokens (tokens stay canonical — shorter QR payloads scan better). |
| Org-editable confirmation email | New EmailTemplateType.application_received + per-org seed; merge fields constrained so orgs can't echo collected data into an unverified inbox. |
| Auto-invite toggle | Per-position "send assessment invite on application" — the pool-only default stays. |
| Application analytics | Views → starts → submits funnel; per-field drop-off shown next to the recruiter's config toggles (nudges away from greedy forms). |
| Government ID | Offer-stage only, via the Identity Continuity / Didit flow. Never back on the apply form. |
| Cover video | Returns when it rides TUS resumable upload, with its own consent. |
| Hardening ladder | Double-opt-in email · localStorage draft persistence · multilingual consent · automated retention purge · reputation-isolated mail domain. |
Applications Link design · 2026-07-07 · canonical text spec at Utkrushta/docs/applications-link-design.md · grounded in code by 3 exploration + 6 extraction agents, stress-tested by 5 adversarial reviews (security, data-integrity, candidate UX, DPDP/GDPR, recruiter workflow).