# Application operations

Start, resume, advance, list, inspect, and review club applications.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/applications

## Member account readiness [#member-account-readiness]

`Application.memberAccountReady: Boolean` reports whether the linked member already has credentials in this club. It is available through the existing authorized application reads and mutation results; no password or credential material is returned. Null means readiness is unavailable and must not be treated as permission to create a password.

After acceptance and completion of every after-approval step (including `afterApprovalCompletedAt` when steps exist), show password setup only when this field is false. When true, direct the applicant to their club's account sign-in or password recovery. The password mutation still validates completion and rejects replacing an existing credential.

## Reading applications [#reading-applications]

`applications(clubId, page, pageSize, search, statuses, from, to, sort, direction)` returns a page of matching
summaries for that club. The default order is creation date descending and then ID descending. Filters and sorting run before pagination.

* `clubId: ID!` is required and validated as a UUID.
* `page: Int! = 1` is one-based and accepts values from 1 through 1,000,000. Requests beyond the
  final page are clamped to the final available page.
* `pageSize: Int! = 10` accepts 10, 25, or 50.
* `sort: ApplicationSortField = created` accepts `created`, `applicant` (first name, then last name), `submitted` (submission timestamp), or `status`.
* `direction: SortDirection = desc` accepts `asc` or `desc`. Each order ends with ID as a stable tie-breaker. Nullable names and submission timestamps follow database null ordering.
  Applicant names and statuses compare without case or accents (including decomposed Unicode); equivalent names use ID as the final tie-breaker.
* `search: String` is an optional case- and accent-insensitive, literal substring of the combined first
  name, last name and email. Surrounding whitespace is ignored; at most 200 characters and no NUL are allowed.
* `statuses: [ApplicationStatus!]` optionally selects one or more of `draft`, `submitted`, `accepted` or `rejected`.
  `submitted` also includes records left in the retired `under_review` state; including
  `under_review` is rejected.
* `from: String` and `to: String` are optional inclusive submission dates in `YYYY-MM-DD`
  format, interpreted in UTC. Drafts without a submission date are excluded when either
  date is supplied. Invalid dates or a start date after the end date return `BAD_USER_INPUT`.
* Selected statuses combine with OR; the status filter combines with the other filters using AND.
  An empty status list, omitted or null filters do not restrict results.

```graphql
query {
  applications(
    clubId: "51000000-0000-4000-8000-000000000051"
    page: 1
    pageSize: 10
    statuses: [submitted]
    search: "Camille"
    from: "2026-09-01"
    to: "2026-09-30"
  ) {
    total
    page
    pageSize
    items {
      id
      firstName
      lastName
      email
      status
      submittedAt
      createdAt
      revision
    }
  }
}
```

The page returns `items`, `total`, `page`, and `pageSize`. Each item is an
`ApplicationSummary` (identity, club and onboarding references, status, progress, revision and
timestamps). `afterApprovalCurrentStep` and `afterApprovalTotalSteps` give the after-approval
progress, so the back office marks an accepted applicant who still owes a payment or a signature
("Steps 1/2") apart from one who has finished.
`application(id, clubId)` returns
the full snapshot for authorized staff, the linked member, or a caller supplying its scoped `X-Application-Token`; `clubId` is optional.

Draft and submitted applications contain no person or account reference. An accepted
application has a nullable `personId` populated by the acceptance transaction. Knowing its ID alone does not grant access.
`bookings` lists the reservations made through its [booking step](#booking-a-visit) that are not
canceled, earliest first, each with `blockId`, `reservationId`, `categoryId`, `categoryName`,
`resourceName`, `startsAt`, `endsAt`, `guests`, `status` and the club `timezone`. It reflects later staff changes to
the reservation. It is always a list on `application`; request it on one application at a time.

## Starting and advancing [#starting-and-advancing]

`startApplication(onboardingId)` always creates a new draft from that exact onboarding.
The new application's snapshot keeps the onboarding's configured block order, including the
position of the required email verification block. An existing dossier or member account for the
verified email is detected when that block is completed, again on submission and on acceptance. The onboarding's club and configuration
and previously created applications are unchanged.
The application stores the onboarding ID and revision plus immutable copies of its application and
after-approval blocks. `Application.onboardingId` and `onboardingRevision` (also on
`ApplicationSummary`) name the onboarding and the revision that snapshot was taken from; later
edits to the onboarding never change them. Save the returned ID in the application URL to resume
that draft.

Saved answers are returned as `ApplicationAnswer { blockId value plan completedAt }`. `blockId` is
the `id` of the snapshot block it answers, so clients match answers to blocks by ID rather than by
position; `value` is the display value, `plan` the plan snapshot of a plan-selection block, and
`completedAt` the time the answer was saved.

`advanceApplication(input)` completes the current block when `stepIndex` is omitted: the current
question while the application is a `draft`, the current after-approval step once it is `accepted`.
Address, phone verification, identity verification and booking blocks can sit on either side and
complete the same way. Supply the application ID plus the latest `revision`, then the fields
required by that block:

* `value` for short answer, long answer, and options blocks.
* `firstName` and `lastName` for the required person-name block.
* `address` (`AddressInput`) for the optional applicant-address block: `line1`, `line2`,
  `city`, `postalCode`, `state`, and `country`. `line1`, `city`, `postalCode`, and
  `country` are required together; an empty address is allowed only when the block
  itself is optional.
* `planId` for plan selection. It must be one of the plan IDs captured in that onboarding block;
  legacy snapshots with an empty list allow any currently available club plan.
* For email verification, first call
  `requestApplicationEmailOtp(applicationId, revision, email)`. It stores a salted challenge,
  advances the optimistic revision, and sends a random six-digit code that expires after ten
  minutes. Then pass the returned revision plus the same `email` and received `otp` to
  `advanceApplication`. Its `error` tells a malformed code ("Enter the six-digit code sent to
  your email."), an expired or replaced challenge ("This code has expired. Send a new code.")
  and a wrong code ("This code is incorrect. Check the latest email or send a new code.") apart.
  A challenge allows five incorrect attempts; after that every code, including the right one,
  returns "Too many incorrect codes. Send a new code." A new code can be requested one minute
  after the previous one ("Please wait one minute before requesting another code."); it resets
  the attempt count.
  `Application.emailOtpSentTo` and `emailOtpExpiresAt` expose the pending challenge's address
  and expiry, so the consumer reopens code entry for that address after a reload until the code
  expires. Verifying the email clears both.
* For the phone-verification block, call
  `requestApplicationPhoneOtp(applicationId, revision, phone)` while it is the current block
  (otherwise "Phone verification is not the current step."), then pass the returned revision
  plus the same `phone` and received `otp` to `advanceApplication`. The number must include its
  country code; a number without `+` or `00` is read as French. Numbers are validated against
  the full libphonenumber metadata, and only numbers that can be mobiles receive a code
  (landlines return "Enter a mobile number: landlines cannot receive texts."). `Application.phone`
  stores E.164 (`+33612345678`); the block answer and the accepted person's phone number use the
  spaced international form (`+33 6 12 34 56 78`) that the back office saves. The code is
  sent by text message and follows the email rules: ten-minute lifetime, one minute between
  codes, five incorrect attempts. Errors name the phone instead of the email ("Enter a valid
  phone number with its country code.", "Send a code to this phone number first.", "Enter the
  six-digit code sent to your phone.", "This code is incorrect. Check the latest text message or
  send a new code."). A text that cannot be sent returns the updated application with "The code
  could not be sent. Please try again.". Because texts cost money and the mutation is public,
  only numbers from the allowed countries receive codes (the EU and EEA, Switzerland, the United
  Kingdom, Monaco and the French overseas departments unless the deployment configures others);
  other numbers return "Codes cannot be texted to this country.". By default each number
  receives at most five codes per hour across all clubs ("Too many codes were sent to this
  number. Try again in an hour."), each connection (client IP address) requests at most ten per
  hour ("Too many codes were requested from this connection. Try again in an hour."), each club
  sends at most one hundred per hour ("Phone verification is busy. Try again later.") and the
  platform sends at most five hundred per hour ("Phone verification is busy right now. Try again
  later."). The deployment can change each default, and a platform admin can give a club its own
  hourly limit with `setClubPhoneCodeCap`. Windows slide: a code counts for the hour after it was
  sent. Only an HMAC of the IP address, keyed by a server secret, is stored, and it is cleared
  once the code is older than an hour. Requests for the same number, connection or club wait for each other, so those caps hold under concurrent requests; the
  platform cap is a ceiling that concurrent requests for other numbers may pass by a few codes.
  When the public `phoneVerificationCaptchaSiteKey` query returns a key, render Cloudflare
  Turnstile with it and pass the widget's token as `captchaToken`. Each token works once. A
  missing, failed or expired token returns "Complete the security check before requesting a
  code." and no text is sent. A null key means the check is off. `Application.phoneOtpSentTo` and `phoneOtpExpiresAt`
  expose the pending challenge; `Application.phone` holds the verified number. Pass the completed
  block's `stepIndex` to both mutations to replace a verified number in a draft.

Required answers, option membership, onboarding plan inclusion, plan existence, email format, OTP, block order,
status and optimistic revision are validated before progress is saved.
The final block saves its answer and leaves the status `draft`, with `currentStep`
equal to the question count and `submittedAt: null`. The consumer then shows a review
of all answers. A refresh resumes this review, and `stepIndex` edits remain available.

To send the reviewed dossier, call `advanceApplication(input: { applicationId, revision,
submit: true })` without `stepIndex`. All blocks must have saved answers. This explicit
action changes `draft` to `submitted`, sets `submittedAt`, increments the revision and
records exactly one applicant transition. Incomplete dossiers, stale revisions and
repeat submissions are rejected without changing answers or creating another transition.

For a draft, an optional zero-based `stepIndex` can instead edit an already completed
name, text, options, or plan-selection block. The name becomes read-only once an identity check
before approval has passed; identity and booking answers are never editable. The same answer
validation and revision checks apply. The answer is replaced in place, with a new `completedAt`; later answers,
verified email, current progress, and submission state are preserved. Negative or future
indexes are rejected. Completed payment and signature blocks are read-only;
previous answers also become read-only once a signature request exists or a signature
or payment block has completed. Submitted and accepted applications cannot use this
editing mode. Merely navigating backwards in the consumer performs no mutation.

To replace a verified email in a draft, pass the completed email block’s `stepIndex`
to `requestApplicationEmailOtp`, then to `advanceApplication` with the new email and
code. Sending a code leaves the previous verified address and answer intact. Only a
valid unexpired code replaces them and clears the challenge. Invalid codes, stale
revisions, submitted applications and signature-locked drafts cannot change the email.
Consumer Back/Continue revisit the email step, displaying the verified address and
an Edit email action only on that step. Other questions do not repeat the email panel.

The normalized first and last names are stored directly on the application and displayed
as **Applicant** in the back office. The verified email is also stored directly on the
application for acceptance, as is the verified phone number when the onboarding contains a
phone-verification block. When the onboarding contains an applicant-address block, its
normalized `Address` object is stored on the application and its formatted text
(`line1, line2, postal code + city, state, country`) is kept as the block's answer
for back-office display. Address and phone answered before approval are copied to the person on
acceptance; answered as after-approval steps, they are also saved on the accepted person's record
at once.

## Identity verification [#identity-verification]

An `IDENTITY_VERIFICATION` block, before or after approval, is completed through
[Stripe Identity](https://docs.stripe.com/identity) document checks. Sessions are created on
the platform Stripe account, never on a club's connected account, so the step works for clubs
without a merchant account and with billing turned off. `advanceApplication` refuses the step with
"Verify your identity with Stripe before continuing."

`startApplicationIdentityVerification(applicationId: ID!, revision: Int!)` returns
`ApplicationIdentityVerificationStart { application verificationUrl error }`. It requires the
applicant's access token (or staff access), an application whose current block is the identity
step (the current question of a draft, or the current after-approval step of an accepted
application), and its latest revision; otherwise it returns "Identity verification is not the
current step." The server creates a `document`
VerificationSession with `require_matching_selfie` set from the step, the applicant's email as
`provided_details.email`, and a `return_url` of `/applications/<id>?identity=returned` on the
application's site (the club's hosted subdomain when the onboarding is published there). The
create request carries an idempotency key per application, step and attempt, so concurrent
clicks share one session. An open session is reused: Stripe's fresh single-use `url` is returned
instead of creating another session. Open `verificationUrl` immediately; it is never stored and
expires after 48 hours. `error` explains refusals, for example "Your identity document shows you
are under the club's minimum age. Contact the club." or "Stripe is still checking your document.
This page updates when it is done."

The consumer uses Stripe's hosted redirect instead of the `stripe.verifyIdentity` modal: it needs
no publishable key or Stripe.js, works the same on every hosted subdomain and on phones, and
returns to the same application page as Checkout and Yousign.

`refreshApplicationIdentityVerification(applicationId: ID!)` re-reads the open session from Stripe
and applies its outcome. The consumer calls it on return from Stripe and every three seconds while
the status is `processing`. It returns `ApplicationResult`.

`Application.identityVerification` describes the latest attempt, or null before the first one:

| Field           | Description                                                                                       |
| --------------- | ------------------------------------------------------------------------------------------------- |
| `status`        | `pending`, `processing`, `requires_input`, `verified`, `under_age`, `name_mismatch` or `canceled` |
| `reason`        | English explanation of a refused attempt, translated by clients; null otherwise                   |
| `errorCode`     | Stripe `last_error.code`, or `date_of_birth_missing`                                              |
| `documentCheck` | `verified` or `unverified` from Stripe's verification report                                      |
| `selfieCheck`   | Same, when the step asks for a selfie                                                             |
| `dateOfBirth`   | `YYYY-MM-DD`, when the step has a minimum age                                                     |
| `minimumAgeMet` | Whether the date of birth reaches the step's minimum age                                          |
| `nameMatches`   | Whether the document's name contains the applicant's name, when the step requires it              |
| `canRetry`      | Whether the applicant may start another attempt                                                   |
| `retryAllowed`  | Whether staff allowed a new attempt after this one                                                |
| `updatedAt`     | Last time Champagne applied a Stripe outcome                                                      |

The step completes only when Stripe reports the session as `verified` and every configured check
passes. A failed Stripe check (`requires_input`) keeps the same session, so the applicant tries
again with Stripe's new link. A name mismatch or an unreadable date of birth ends that session and
lets the applicant start a new one. A date of birth under the minimum age (`under_age`) blocks the
applicant until staff act.

### Webhooks [#webhooks]

Stripe sends `identity.verification_session.processing`, `.verified`, `.requires_input` and
`.canceled` to `POST /api/webhooks/stripe/identity`, an endpoint for events on the platform
account itself ("Your account" in Workbench). The route verifies the signature with
`STRIPE_IDENTITY_WEBHOOK_SECRET` and refuses live and connected-account events. It never trusts the
payload: it re-reads the session from Stripe with `verified_outputs` and `last_verification_report`
expanded, then applies the decision under a lock on the application row. Applying the same session
again changes nothing, so redeliveries, out-of-order events and the applicant's own refresh are all
idempotent; the step advances only while it is still the current step, moving `currentStep`
before approval or `afterApprovalCurrentStep` after. A failed delivery returns
HTTP 500 and Stripe redelivers it.

### Stored data [#stored-data]

Each attempt is a row of `application_identity_verification` holding only the Stripe session ID,
Stripe's status, Champagne's result, Stripe's error code, the document and selfie check results,
the date of birth (for the age check) and whether the name matched. Names, addresses, document
numbers, document images and selfies are never stored by Champagne; they stay in Stripe, where
they can be redacted from the Identity Dashboard. Rows are deleted with their application.

### Staff actions [#staff-actions]

Staff with `applications_manage` see `identityVerification` on the application and can act while
the identity step is current, before or after approval:

* `allowApplicationIdentityRetry(clubId, id, revision)` lets the applicant start a new attempt,
  for example after `under_age` or a mismatched name. An open Stripe session is canceled.
* `completeApplicationIdentityStep(clubId, id, revision)` completes the step without a passing
  check. Like other staff overrides, the back office warns before confirming. The step's answer
  records the staff member's name.

Both return `ApplicationResult` and refuse stale revisions.

### Configuration [#configuration]

Identity uses the platform `STRIPE_SECRET_KEY` and is enabled by
`STRIPE_IDENTITY_WEBHOOK_SECRET` (`whsec_`). Stripe returns the date of birth only to a restricted
key with the **Identity verification results** and **Recent detailed verification results** read
permissions: set it as `STRIPE_IDENTITY_RESULTS_KEY` (`rk_test_`). Without it, a step with a
minimum age ends with `date_of_birth_missing`. Startup only checks that the Identity API answers
for the platform key and leaves the step unavailable otherwise; Identity must also be activated in
the Stripe Dashboard before it offers the restricted key's Identity permissions. Local stacks forward the events
through the Stripe CLI listener with the same signing secret.

## Review [#review]

`setApplicationStatus(clubId, id, revision, status, rejectionNote)` accepts
`accepted` or `rejected` only when allowed by the current state,
as detailed in [Status transitions and history](#status-transitions-and-history). It uses optimistic revision checks. Accepting atomically inserts an individual person with the application's name,
verified email, verified phone number (which takes precedence over a phone typed in a question
saved to the same person field), collected address, and club, writes answers whose question saves to a person field
or member attribute as typed values (see [Member attributes](/en/graphql/person-attributes#save-onboarding-answers)),
and stores the new person ID on the application, attaching visits booked before approval to that
person. Rejecting cancels the application's active visits. A duplicate person email in the same
club returns a
readable error and leaves the application unchanged. The same email may already exist in
other clubs. An accepted linked application is
terminal. Payment, verification, and signature results remain part of the immutable answer
history. Acceptance and rejection send applicant emails. The acceptance email always links to the
application: "Complete your remaining steps" when after-approval steps exist, otherwise "Choose
your password", since the applicant still sets a password before opening the account. The link uses the configured custom URL with an
`applicationId` query parameter, or the built-in application URL when no custom URL is configured.
The built-in URL, like the Stripe and Yousign return URLs below, uses the application's origin:
the club's hosted site (`https://<hostedSlug>.<base domain>`, the base domain being
`CONSUMER_ORIGIN` without its `app`, `consumer`, `staging`, or `www` label) when the club has a
hosted slug and the application's onboarding is published there, otherwise `CONSUMER_ORIGIN`.
Applicants therefore return to the site where they applied.
`rejectionNote` is optional, limited
to 2,000 characters, stored as the current note only while rejected, and preserved on
the corresponding history event. It is exposed on the full application and history,
not on list summaries. The note is sent to the applicant: the rejection email includes it as the
reason, and the applicant's own view of the application (through its scoped token) shows it.

After acceptance, `advanceApplication(input)` cannot bypass a Plan payment step. Payment uses two
dedicated operations:

* `createApplicationCheckout(applicationId, revision, requestId)` derives the linked person, club,
  and snapshotted selected plan from the accepted application and opens hosted Stripe Checkout.
  The attempt is bound to the application and returns to `/applications/<id>` on the
  application's origin.
* `completeApplicationPayment(applicationId, revision, sessionId)` verifies the exact stored
  application attempt and advances only after its subscription has been projected from the signed
  `invoice.paid` webhook. Pending or expired sessions leave the application unchanged and return a
  readable error. The webhook also advances the step on its own, so payment completes even when the
  applicant returns from Checkout in another browser; a later call for the same confirmed session
  returns the application without an error.

Document signatures use two dedicated operations:

* `prepareApplicationSignature(applicationId, revision)` creates or resumes the Yousign request
  for the current signature block. It uses the application's snapshotted PDF, verified name and
  email, club ID, application ID, and block ID. The result includes the updated application and a
  short-lived HTTPS `signatureUrl` and an `embedded` flag. Sandbox requests are embedded on a
  dedicated consumer route because Yousign trial accounts reject custom redirect URLs. Production
  requests redirect to Yousign and use its success, error, and decline return URLs.
  Only one preparation runs at a time per application: a concurrent call (a double click or a
  second tab) returns the error "The document signature is already being prepared. Try again in a
  moment." instead of creating a second Yousign request.
* `completeApplicationSignature(applicationId)` retrieves the stored request and signer directly
  from Yousign. It advances only when the signer status is `signed`; callback query parameters are
  not trusted as proof. Declined, expired, unfinished, missing, stale, and unconfigured requests
  return readable errors without completing the block.

Signer names are sent to Yousign with plain apostrophes and hyphens, since its API refuses the
typographic ones that mobile keyboards insert. When Yousign still refuses a name, preparation
returns "The signature service does not accept some characters in your name. Contact the club to
complete your signature." A failed preparation deletes the draft request it created (or cancels
it once activated), and the server log records the provider status and the rejected parameters.

Preparing a request stores its provider IDs on the application, so retries resume the same active
signing link rather than creating another request. The provider `external_id` includes the club,
application, and block IDs, keeping requests traceable across clubs. Progress is exposed through `afterApprovalAnswers`,
`afterApprovalCurrentStep`, and `afterApprovalCompletedAt`; the application remains `accepted`.

Accepted applications with unfinished after-approval steps receive up to three automatic
finalization reminders: 3 days after acceptance, then 4 and 7 days after the preceding successful
delivery (approximately days 3, 7, and 14). Each reminder links to the same custom or built-in
continuation URL as the acceptance email. The schedule stops as soon as the final step is completed.
Failed deliveries retry after one hour without consuming one of the three reminders. On startup,
the worker also schedules the first reminder for older accepted, unfinished applications that do
not yet have one.

Configure `YOUSIGN_API_KEY` as a secret environment variable. The optional `YOUSIGN_API_URL` is
restricted to `https://api-sandbox.yousign.app/v3` (the default) or
`https://api.yousign.app/v3`. `CONSUMER_ORIGIN` must be an HTTPS origin, except for local
development `.localhost` origins, and is used to construct success, error, and decline redirects
on the application's origin.
Yousign's custom redirects are unavailable on trial accounts. Sandbox mode therefore omits
`redirect_urls`, enables Yousign's sandbox iframe-domain bypass on the signing link, and relies on
the documented `signature.done` browser event. That event still triggers a server-side status
check; it is not accepted as proof by itself.

Private application data requires a scoped application token or an authorized session.

## Booking a visit [#booking-a-visit]

An applicant books the current [booking step](/en/graphql/onboarding#booking-step), either a question
of a draft or an after-approval step of an accepted application, with these operations. Each takes `applicationId`, so the scoped `X-Application-Token`, staff with
application access, or the linked member can call it.

```graphql
query BookingStep($applicationId: ID!, $date: String!) {
  applicationBookingCategory(applicationId: $applicationId) {
    id
    name
    description
    durationMinutes
    maxGuests
    timezone
  }
  applicationReservationSlots(applicationId: $applicationId, date: $date, guests: 1) {
    date
    partyFits
    slots {
      startsAt
      endsAt
      available
    }
  }
}

mutation Book($applicationId: ID!, $revision: Int!, $startsAt: String!) {
  bookApplicationReservation(
    applicationId: $applicationId
    revision: $revision
    startsAt: $startsAt
  ) {
    application {
      revision
      bookings {
        reservationId
        startsAt
        endsAt
      }
    }
    error
  }
}
```

* `applicationBookingCategory(applicationId)` returns the step's category with its booking length,
  the largest party one resource holds and the club time zone. It returns `null` when the current
  block is not a booking block, or, after approval, when no person is linked.
* `applicationReservationSlots(applicationId, date, guests)` returns the same `ReservationSlotDay`
  as [`reservationSlots`](/en/graphql/reservations#booking-rules-and-time-slots) for the step's
  category, including categories hidden from members. `date` is `YYYY-MM-DD` in club time and
  `guests` 1–500; other values receive `BAD_USER_INPUT`. It returns `null` under the same
  conditions as the category.
* `bookApplicationReservation(applicationId, revision, startsAt, guests = 1)` books a slot start
  for the linked person, or, before approval, under the applicant's name with no person (it needs a
  verified name and email first: `"A verified name and email are required before booking."`). The booking follows the category's member rules (opening hours, notice,
  booking window, fixed length, party size and resource availability) but does not need a
  membership, since the applicant has none until the steps are done. Staff overrides do not apply.
  A booking already made for the step is canceled in the same transaction, so this also moves the
  visit; when the new time is refused, the previous booking is kept and the refusal is returned
  in `error`, for example `"The club is closed at this time."` or
  `"Nothing is available at this time for this party."`. A price on the category returns
  `"Onboarding visits are free. Ask the club to remove this price."`.
* `cancelApplicationReservation(applicationId, revision)` cancels the step's booking, or returns
  `error: "There is no booking to cancel."`.

Both mutations require the current `revision`, bump it on success and return the application.
Outside the booking step they return `error: "Booking a visit is not the current step."`, and a
stale revision returns `"This application changed. Reload it before continuing."`. Reservations
are created with `source: member`, linked to the application and block, and confirmed at once, so
the confirmation email and reminders go to the applicant (the application's email before approval)
and staff see them in planning like other bookings. Accepting the application attaches its visits
to the new person; rejecting it cancels its active visits.

`advanceApplication({ applicationId, revision })` completes the step once a booking exists; its
answer value is the booked start. A required step without a booking returns
`error: "Book a visit to continue."`; an optional one completes with a `null` answer. After the
step is done, the reservation can only be changed like any other reservation.

## Status transitions and history [#status-transitions-and-history]

The server enforces these transitions:

| Current status | Allowed next status    | Entry point                                                   |
| -------------- | ---------------------- | ------------------------------------------------------------- |
| `draft`        | `submitted`            | Explicit confirmation with `advanceApplication(submit: true)` |
| `submitted`    | `accepted`, `rejected` | `setApplicationStatus`                                        |
| `under_review` | `accepted`, `rejected` | `setApplicationStatus`                                        |
| `rejected`     | `accepted`             | `setApplicationStatus`                                        |
| `accepted`     | None                   | After-approval steps retain `accepted`                        |

`under_review` is a retired state retained only for existing records and historical events.
No operation can move a candidature into it; existing records can still be accepted or rejected.
Review cannot move a record to `draft`, `submitted`, or `under_review`. An unchanged status is not a new
transition and is rejected. Stale revisions, duplicate requests and concurrent decisions
cannot append another event or send another decision email. After an uncertain response,
reload the application before deciding whether another action is required. Accepting a
rejected application clears the current rejection note while preserving it on the historical event.
Acceptance remains final; automatic rejection of accepted, unfinished applications is not
part of this increment.

```graphql
query ApplicationHistory($clubId: ID!, $id: ID!) {
  applicationHistory(clubId: $clubId, id: $id) {
    id
    revision
    fromStatus
    toStatus
    occurredAt
    actorType
    actorId
    reason
  }
}
```

Both arguments are required UUIDs. The result is `null` for an unknown application or a
club mismatch, and an empty list for a known application without recorded transitions.
Events are returned in ascending application revision order, without filters or pagination;
revision is authoritative when timestamps coincide. There is no history update/delete API.
History is restricted to staff with `applications_manage` in the application’s club
(club administrators included; see [staff permissions](/en/graphql/auth#individual-staff-permissions)).

| Field        | Type                    | Meaning                                                                     |
| ------------ | ----------------------- | --------------------------------------------------------------------------- |
| `id`         | `ID!`                   | Unique event UUID.                                                          |
| `revision`   | `Int!`                  | Application revision resulting from the transition; unique per application. |
| `fromStatus` | `ApplicationStatus`     | Previous status; null only for recorded creation.                           |
| `toStatus`   | `ApplicationStatus!`    | Resulting status.                                                           |
| `occurredAt` | `String!`               | Server timestamp with time zone, fixed when recorded.                       |
| `actorType`  | `ApplicationActorType!` | Origin: `applicant`, `staff`, or `system`.                                  |
| `actorId`    | `String`                | Authenticated staff ID for new review decisions; nullable for older events. |
| `reason`     | `String`                | Trimmed rejection reason at decision time, otherwise null.                  |

Creation records `null → draft`; final submission records `draft → submitted`.
Applicant-origin actions are not proof of an authenticated identity. Review records `staff`, `actorId`, and `actorName` from the authenticated administrator. Clients cannot supply an actor or timestamp.
The `system` value is reserved for server automation; no automated transition is introduced.

Application changes, person creation on acceptance and the event insert commit in one
transaction. A failed event insert rolls all of them back. History records decisions,
not mail delivery; decision emails are sent after the transaction. Updating answers,
sending an OTP, payment and signature completion do not rewrite decision history.

Existing and seeded applications are not backfilled: their earlier transitions and actors
are unknown. The first subsequent transition records its actual previous state and current
server time, without fabricating dates from `updatedAt`. The back office explicitly reports
incomplete history and displays event dates in the viewer's local time zone.

## Finding an application by verified email [#finding-an-application-by-verified-email]

The consumer entry page links to `/applications/recover`. Recovery is scoped to one
club: both mutations take a required `clubId`. Hosted onboarding sites use their own
club; the shared consumer asks the applicant to choose a club (preselected from a
`?club=` query parameter). Recovery does not create an application or change its
status. Only the saved, verified `application.email` of that club's applications is
matched, across statuses; an unfinished email verification is not a match.

```graphql
mutation SendRecoveryCode($clubId: ID!, $email: String!) {
  requestApplicationRecovery(clubId: $clubId, email: $email) {
    error
  }
}
mutation FindApplications($clubId: ID!, $email: String!, $code: String!) {
  recoverApplications(clubId: $clubId, email: $email, code: $code) {
    error
    applications {
      id
      clubName
      status
      createdAt
      submittedAt
    }
  }
}
```

Addresses are trimmed and lowercased. A syntactically valid address receives a
six-digit code whether or not a matching dossier exists. The send response has no
application identifiers, counts, or existence signal. Invalid addresses, unknown clubs
and mail failures return an error. Sending is limited to once per minute per club and
normalized address.

Challenges persist per club and address as salted hashes, expire after ten minutes,
allow five incorrect attempts, and are consumed atomically on success. A code issued
for one club never verifies the same address at another club, and deleting a club
removes its challenges. Resending after the cooldown
replaces the previous code. Failed, expired, or already-used codes return an error
and an empty applications array. Successful verification returns up to the 100 newest
matching dossiers of the club (creation date descending, then ID descending), with the club name,
status and creation/submission dates. No matching dossier is a successful empty array.
There is no pagination or user-supplied sorting. Recovery results remain in memory in
the consumer; refreshing the recovery page requires a new verification.

Verified recovery returns a scoped `accessToken` with each application; send it as `X-Application-Token` for subsequent access.

### Existing email addresses [#existing-email-addresses]

`advanceApplication` verifies the email code before checking for an existing dossier or person.
Its result now includes an optional `continuation` with `applicationId`, `personId`, `message`,
and `label`. Request it alongside `application` and `error`:

```graphql
mutation VerifyApplicationEmail($input: AdvanceApplicationInput!) {
  advanceApplication(input: $input) {
    application {
      id
      revision
      currentStep
    }
    continuation {
      applicationId
      personId
      message
      label
    }
    error
  }
}
```

An invalid code exposes no continuation. A valid code can return a continuation instead of
advancing the new draft: resume a draft in the same club, view a submitted or rejected dossier,
finish an accepted dossier, or open an existing person's account. A person in another club
is asked to contact the club; no transfer or new membership is performed. A rejected dossier
is retained and no automatic reapplication is permitted by this flow.

When a continuation is returned, `application` and `error` are null and the existing records
are not modified. The new draft is not assigned the duplicate email. Submission rechecks
existing records, and staff acceptance refuses conflicting records. Transaction-scoped
email locks serialize simultaneous verification and acceptance. Among existing dossiers
for a club, non-drafts take priority, then the oldest creation date and ID. Anonymous drafts
without a verified email can still coexist. Account access separately requires member or administrator authentication.

### Applicant photos [#applicant-photos]

`Application.avatar` and `ApplicationSummary.avatar` return the current photo of
the linked person, or `null` when there is no linked person or no photo.
The photo is shared with People rather than copied into the candidature snapshot.
Changing or removing it in People updates the linked application's photo on its next read.

## Sponsorship [#sponsorship]

A club may add one required `SPONSORS` question with `sponsorCount` (1–5, default 2).
The count is snapshotted with the application. Applicants answer with that many
**sponsor codes**, one per line. Members generate these one-time codes from their
account (see [sponsor codes](#sponsor-codes)) and hand them to the applicant.

Codes are case-insensitive and may be typed with or without the dash or spaces
(`abcd efgh`, `ABCD-EFGH` and `ABCDEFGH` are equivalent). Each answer must contain
exactly `sponsorCount` distinct codes. When the step is saved, every code is checked
and redeemed in the same transaction:

* the code exists and belongs to the application's club;
* it is unused, or already redeemed by this same application;
* its author is still an active individual member of the club with a current
  membership (effective person status on today's UTC date, as elsewhere);
* its author is not the applicant (same person or same email);
* no two codes come from the same member.

The first failing check is returned as the step error, a fixed sentence that names no code
(for example `A sponsor code you entered has already been used.`) so clients can translate it. The stored answer value lists the
sponsors' names, one per line; the codes themselves are not stored in the answer.
Editing the step while the application is a draft releases the codes that are no
longer part of the answer, so their authors can give them to someone else. Codes stay
bound to an application once it is submitted, including after a rejection.

`sponsors(applicationId: ID!, clubId: ID!): [SponsorPerson!]!` returns the members
whose codes the application redeemed, each with `id`, `name` and `email`. Wrong club
scope returns an empty list. It requires the `applications_manage` permission.

When staff accept the application, its sponsors are copied onto the new person
record. They then appear in [`personRelations`](/en/graphql/people#sponsors-and-invited-guests)
and are notified (`sponsorship` category) when the person receives their first
membership. Sponsorship never accepts an application by itself; the decision stays
with staff.

```graphql
query ApplicationSponsors($applicationId: ID!, $clubId: ID!) {
  sponsors(applicationId: $applicationId, clubId: $clubId) {
    id
    name
    email
  }
}
```

### Sponsor codes [#sponsor-codes]

Signed-in members manage their codes with three operations. Each requires a member
session for the given club; staff sessions are refused.

* `mySponsorCodes(clubId: ID!): [SponsorCode!]!` lists the member's codes, newest
  first. Each has `id`, `code` (formatted `XXXX-XXXX`), `createdAt`, and nullable
  `usedAt` and `usedBy` (the name given on the redeeming application).
* `createSponsorCode(clubId: ID!): SponsorCodeResult!` returns `{ code, error }`.
  Only active individual members with a current membership of the club can create
  codes, and at most 5 unused codes may exist at a time. Codes use 8 characters from
  an alphabet without ambiguous characters (no `0`, `O`, `1` or `I`) and do not expire.
* `revokeSponsorCode(clubId: ID!, id: ID!): Boolean!` deletes an unused code and
  returns `false` when the code was already used or does not belong to the member.

```graphql
mutation CreateSponsorCode($clubId: ID!) {
  createSponsorCode(clubId: $clubId) {
    code {
      id
      code
      createdAt
    }
    error
  }
}
```

## Text message history [#text-message-history]

`applicationSmsHistory(id: ID!, clubId: ID!, page: Int! = 1)` has the same access rules,
paging and null result as `applicationMailHistory` below. Each item is one phone
verification code texted for the application: `id`, `phone` (spaced international form),
`status`, `sentAt` and `statusAt` (time of the latest status). The code itself is never
returned. Statuses mean:

* `sending`: the text was reserved and is being handed to the SMS provider.
* `sent`: the provider accepted the text; delivery to the phone is not yet confirmed.
* `delivered`: the provider reported delivery to the phone.
* `undelivered`: the provider reported a bounce or refusal.
* `failed`: the provider or connection rejected the request; the applicant was told to retry.
* `unknown`: a `sending` text older than one minute whose outcome was never saved.

Delivery reports arrive from Brevo at `POST /api/webhooks/brevo/sms`, on a per-text URL
signed with HMAC-SHA256. A report with a wrong signature returns 403. A delivered or
undelivered text is final: later reports do not change it. Reports require a public HTTPS
consumer origin, so local stacks keep texts at `sent`.

## Email history and reminder reliability [#email-history-and-reminder-reliability]

`applicationMailHistory(id: ID!, clubId: ID!, page: Int! = 1)` is restricted to
staff with `applications_manage` in the selected club (club administrators included). It returns `{ items, page, hasMore }` or null
for an application outside that club. Pages contain at most 20 attempts, ordered by
`startedAt` then `id`, newest first; `page` is bounded to 1–100,000. Concurrent sends
can shift offset pages.

Each item contains `id`, `kind` (`accepted`, `rejected`, `reminder`),
`recipient`, `attempt`, `status`, `startedAt`, and nullable `completedAt`. The API
never returns the message body, personal links, tokens or raw provider diagnostics.
Statuses mean:

* `sending`: a durable attempt was claimed; completion has not yet been recorded.
* `accepted`: SMTP/API accepted the message for sending, not proof of inbox delivery.
* `failed`: the provider or connection definitively rejected the attempt.
* `unknown`: delivery may have occurred; automatic resending is blocked.

An unfinished attempt older than one minute is displayed as `unknown`. Attempts
use a separate database pool and transaction context so a business rollback cannot
erase evidence that an email was sent. The same decision revision or reminder number
cannot automatically resend after acceptance or uncertainty. Confirmed decision and reminder
failures retain their attempt and retry after one hour. Uncertain reminders are
rechecked but never automatically resent; investigate the provider before any manual
recovery. Exactly-once inbox delivery is not guaranteed by SMTP or the current API.

New acceptance and rejection decisions persist a pending notification in the same transaction
as the decision. Sending starts after commit; the worker recovers pending notifications after
restart. The delivery key retains the decision revision even if later application steps change
the application revision. A newer decision replaces an older pending notification. Older
applications without a pending notification are not re-notified.

An uncertain decision attempt pauses automatic retries and remains visible in email history.
A successfully accepted attempt is not resent if saving queue completion fails. A delayed
acceptance email omits the continuation prompt when all steps are already completed.
The mutation can return the saved application together with an email warning; the decision
is already committed and should not be repeated to retry delivery.

The existing schedule is unchanged: first reminder three days after acceptance,
then four and seven days after the preceding successful send, at most three in total.
The worker locks the application while checking and sending, serializing with other
workers and finalization. Completed applications are not selected. A finalization
that occurs after a send has already started cannot retract that email. No automatic
rejection or sponsor reminder policy is introduced.

Email history is available in the back-office application detail, refreshed every
15 seconds while visible. Earlier emails are not reconstructed. Only operational
metadata is retained; a future application/person erasure workflow must also handle
this journal, which intentionally has no cascading foreign key to the business transaction.
