---
title: "WildPacks Application Form"
slug: wildpacks-application-form
section: Portals
status: draft
description: "The public Camp Counselor and Wild Packs Summer Work and Travel intake form, and the small API behind it."
last-updated: 2026-10-07
---

# WildPacks Application Form

The public Camp Counselor and Wild Packs Summer Work and Travel intake form, and the small API behind it. This page describes the repo as of 2026-10-02, covering the changes made since early August 2026; the branching and hosting sections were updated on 2026-10-06 for the 2026-10-03 move to the sandbox branching model.

## Overview

`Alliance-Strategies/WildPacksApplicationForm` is a standalone application. It is **not** part of Globus, the Applicant Portal (PPPToo) or the Host Portal. A visitor fills in one form on `apply.wildpacks.com`; the API writes a HubSpot Contact and a HubSpot Application record. HubSpot is the only system of record: the service is **stateless** and has no database. The form itself never talks to Globus; eligibility screening happens downstream of the HubSpot records (see [Pre-Qualification Eligibility](/docs/pre-qual-eligibility)).

Two roles can apply, selected by `roleType`: `counselor` (program `CAMP_WP`) and `support_staff` (program `SWT_WP`).

## Architecture

A pnpm monorepo with two deployable artifacts:

| Artifact | Path | Stack |
|---|---|---|
| API server | `artifacts/api-server` | Express 5, bundled to a single `dist/index.cjs`, request bodies validated with zod schemas generated from `lib/api-spec/openapi.yaml` |
| Web frontend | `artifacts/wildpacks` | Vite and React single-page app |

The frontend calls the API with root-relative paths and has no configurable API base URL, so the frontend and `/api/*` must be served from the same origin. The runtime image copies both the bundled API and the built frontend, and a single container on port 8080 serves them.

## API

All routes are mounted under `/api`.

| Method and path | Purpose |
|---|---|
| `GET /api/healthz` | Liveness check, returns `status: ok` |
| `GET /api/countries` | Country list for the form. Source of truth is the HubSpot Country object (`is_active=true`); falls back to a bundled static list if HubSpot is unreachable or the token is unset |
| `POST /api/applications` | Submits an application. Returns `201` with `status: submitted`, plus `campPlacementReceipt` when Camp placement answers are collected |
| `GET /api/camp-placement` | Reports whether the Camp placement questions are enabled (`enabled`), with `Cache-Control: no-store`. Returns `503` if the flow is enabled but not configured |
| `PATCH /api/applications/:applicationId/camp-placement` | Saves corrected Camp placement answers for an existing Application using the receipt's `editToken`. Returns `status: saved`; `404` where the flow is disabled |

### Submission Flow

`POST /api/applications` runs three HubSpot steps:

1. **Contact.** Create the Contact. On a `409` conflict the API searches by email and `PATCH`es the existing Contact, so the same person can apply again for a later season or program.
2. **Application.** Create an Application custom object record and associate it to the Contact. Every submission creates a new Application.
3. **Country associations.** Associate the Contact to its Country records (residence and citizenship). This step is best effort: the ISO codes are already stored on the Contact text properties, so a failed association does not fail the submission.

The call only succeeds when **both** the Contact and the Application were created. Otherwise it returns `502` (`Failed to record submission in HubSpot`) rather than a false success; a Contact created without its Application is an orphan that must be reconciled manually. A missing `HUBSPOT_SANDBOX_TOKEN` returns `500`. Invalid input returns `400`.

Attribution data (GA client and session ids, `gclid`, `fbclid`, `msclkid`, `utm_*`, landing page, referrer, user agent) is written to the Contact; a subset (`ga_client_id`, `gclid`, `utm_source`, `utm_medium`, `utm_campaign`) is also written to the Application.

### Validation Rules

Applied server-side, mirroring the client:

- Names are trimmed; email is trimmed and lower-cased; phone is required; dates are `YYYY-MM-DD`.
- On the live HubSpot portal (`staging` and `prod`), emails containing `+` are rejected so one person cannot fan out several applications. They are allowed in `dev` and `qa`.
- Applicants must be 18 by June 1 of the season year, and no older than 80. The season year rolls over to the next year after June 15.
- `earliestStartDate` cannot be in the past (one day of slack for time zones) or more than 4 years out; `latestEndDate` cannot be more than 4 years out or before `earliestStartDate`.

### Program and Cycle Resolution

The program is located by an **exact** `program_code` match, never a prefix match: `counselor` resolves to `CAMP_WP` and `support_staff` to `SWT_WP`. A prefix match once resolved a Wild Packs applicant onto Alliance Abroad's own SWT program (confirmed 2026-09-03). Program code and name are read from the resolved HubSpot Program record and cached for 10 minutes. The program's open Operational Cycle is the one with status `open` and `open_for_applications` true; if several are open, the earliest start date wins. If HubSpot cannot resolve a program, the submission still goes through with a hardcoded fallback code and name, and no program or cycle values are written.

### Camp Placement

For `counselor` submissions the API also collects Camp placement answers when the flow is enabled. It is enabled in `dev` and `qa`. In `staging` and `prod` it is enabled only when `CAMP_PLACEMENT_ENABLED` is `true`.

- **Questions.** `camp_position_secured` asks "Do you already have a position at a camp for this season?". If the answer is yes, two follow-ups are also required. `camp_returning_same_camp` asks "Have you previously worked at this same camp?". `camp_placement_name` asks "What is the name of the camp?"; it is trimmed and must be 1 to 200 characters. If the answer is no, the follow-up answers are discarded. Missing or incomplete answers return `400`.
- **Program check.** The fallback described above does not apply here. If the program cannot be resolved or is not `CAMP_WP`, the submission returns `502` (`The Camp program could not be confirmed`).
- **HubSpot records.** Each answer is a qualifier response record associated to the Application. In `dev` and `qa` it uses object type `2-62092673` with association type `177`; elsewhere it uses `2-66249810` with `298`. Only records whose `qr_qualifier_source` is `form` are reconciled. A repeat save updates them rather than creating duplicates. Answering no deletes the form-owned follow-up records. Other qualifiers and staff entries are untouched.
- **Receipt and correction.** The response includes `campPlacementReceipt`, which holds the Application id and an `editToken`. The token is bound to that Application and is valid for 30 minutes. If saving the Camp answers fails, `POST /api/applications` returns `502` with the receipt. The browser then retries against the same Application rather than creating another. `PATCH /api/applications/:applicationId/camp-placement` takes the token as `Authorization: Bearer <editToken>`. A missing, expired or wrong-Application token returns `403`. A HubSpot failure returns a retryable `502`. The signing key is derived from `HUBSPOT_SANDBOX_TOKEN` and `APP_ENVIRONMENT`, so rotating the HubSpot token revokes outstanding correction sessions. If either is missing, the API returns `503` (`Camp placement is not configured`).

## Environments and HubSpot Configuration

`APP_ENVIRONMENT` (`dev`, `qa`, `staging` or `prod`) is required; the server refuses to boot without a valid value. Two environments share each HubSpot portal: `dev` and `qa` use the sandbox, `staging` and `prod` use the live portal. HubSpot custom object type ids differ per portal and are selected per environment from `config/hubspot/`:

| Object | dev and qa (sandbox) | staging and prod (live) |
|---|---|---|
| Application | `2-59396507` | `2-65515441` |
| Program | `2-62092723` | `2-66243449` |
| Operational Cycle | `2-62709140` | `2-66255157` |
| Country | `2-63632118` | `2-66249648` |

Other per-environment behaviour:

- **Email namespacing.** Outside prod, the Contact email gets an environment subaddress (`jane@x.com` becomes `jane+dev@x.com`) so environments sharing a portal do not collide when Contacts are upserted by email. Prod emails are untouched. Every record carries `source_environment`.
- **Country properties.** The sandbox stores ISO codes on `country_of_citizenship__` and `where_do_you_currently_live_`. The live portal uses `wildpacks_country_of_citizenship_iso`, `wildpacks_country_of_residence_iso` and, on the Application, `applicant_country_of_residence`.
- **Experience enum.** In prod the form value `10+` is sent to HubSpot as `10 or more`.
- **Secret.** The HubSpot token is `HUBSPOT_SANDBOX_TOKEN`. Despite the name it is the live-portal token in `staging` and `prod`.

## Production vs Development Branch

Until 2026-10-03 production deployed from `master` while `development` and `qa` carried newer and different work. Since the move to the sandbox branching model on 2026-10-03, production deploys from `main` through `deploy.yml`'s blue-green cutover, and `development`, `qa` and `master` are frozen legacy branches. The table records the divergence as of 2026-10-02; check `main` before relying on a behaviour:

| Area | `master` (production until 2026-10-03) | `development` (frozen) |
|---|---|---|
| Application associations | Contact only (type `41`). No Application to Program or Operational Cycle association, because production has no such definition; program and cycle live in Application properties | Also associates Program (type `183`) and Operational Cycle (type `275`) |
| Contact to Country association type | Hardcoded `224` | Per-environment value in `config/hubspot` (`224` sandbox, `104` live) |
| Replay privacy | Blanket masking of text, inputs and attributes | Adds CSS sanitisation, drops CSSOM replay events, sanitises exception events |
| Tag Manager | Both containers in prod (see below) | Replaces the Alliance container with the Wild Packs one in prod |
| Infrastructure | `k8s/prod` manifests | None |

Reconciling these is tracked separately; this page will be updated when they converge.

## Analytics and Session Recording

**Tag Manager.** At build time the Vite config rewrites the page for prod. On `master` the Alliance Abroad container `GTM-5HCNZD62` stays first and the Wild Packs container `GTM-MJVGD6W2` is added after it, script and noscript. The build fails if the Alliance snippets are not found. Non-prod builds keep only the Alliance container.

**PostHog session recording.** Recording is production only and starts **automatically** (the earlier consent banner was removed on 2026-09-30). It does not start for visitors with Do Not Track set, for visitors who previously declined under the old consent key (`wp_session_recording_consent`), or for anyone who has opted out of the SDK. Configuration keeps `person_profiles` set to `never`, autocapture off, performance capture off, network headers and bodies off, and masks all input and text; query strings and hashes are stripped from captured URLs. The ingestion key in the source is the public project key, not an administrative key. Detail on the privacy boundary of the `development` replay work is in the repo under `docs/posthog-session-recording-deployment.md`.

## Hosting and Promotion

Since 2026-10-03 the repo is on the [sandbox branching model](/docs/git-branching-release-model): `feature/*` pushes deploy to a development pool slot, `release/*` pushes to a QA pool slot, and a `release/*` → `main` merge deploys production through `deploy.yml`'s blue-green cutover; `development`, `qa` and `master` are frozen. Before that, promotion followed `development` to `qa` to `master`, and each branch push built an image and deployed as follows (kept for the record):

| Environment | Branch | Target |
|---|---|---|
| Development | `development` | Cloud Run service `wildpacks-development` in `skilled-script-469819-f2` |
| QA | `qa` | Cloud Run service `wildpacks-qa` in `quality-assurance-496022` |
| Production | `master` | GKE cluster `globus-cluster-prod`, applied from `k8s/prod` with `kubectl apply -k` |

Production serves `apply.wildpacks.com` and `apply.allianceabroad.com` through a GCE ingress with managed certificates and a reserved static IP, two replicas, and the secret `wildpacks-prod-secrets`. CI on `development` runs a workspace typecheck and the API server unit tests; a separate workflow posts deploy results to Platform Pulse.

## Sources

- Repo: [WildPacksApplicationForm](https://github.com/Alliance-Strategies/WildPacksApplicationForm)
- Routes: `artifacts/api-server/src/routes/applications.ts`, `campPlacement.ts`, `countries.ts`, `health.ts`
- Camp placement: `artifacts/api-server/src/lib/campPlacement.ts`, `hubspotCampPlacement.ts`
- Environment and HubSpot configuration: `artifacts/api-server/src/lib/env.ts`, `hubspotObjectTypes.ts`, `hubspotCountryProperties.ts`, `hubspotExperience.ts`, `hubspotProgram.ts`, `artifacts/api-server/src/config/hubspot/`
- Analytics: `artifacts/wildpacks/vite.config.ts`, `artifacts/wildpacks/src/lib/sessionRecording.ts`
- Deployment (sandbox branching model since 2026-10-03): `.github/workflows/deploy.yml`, `.github/workflows/trusted-deploy-dev.yml`, `cloudbuild-dev.yaml`, `cloudbuild-qa.yaml`, `cloudbuild-prod.yaml`, `DEPLOYMENT.md`; `k8s/prod/ingress.yaml` is the retired GKE manifest set
