Champagne

Application operations

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

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

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.
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 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

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

An IDENTITY_VERIFICATION block, before or after approval, is completed through Stripe 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:

FieldDescription
statuspending, processing, requires_input, verified, under_age, name_mismatch or canceled
reasonEnglish explanation of a refused attempt, translated by clients; null otherwise
errorCodeStripe last_error.code, or date_of_birth_missing
documentCheckverified or unverified from Stripe's verification report
selfieCheckSame, when the step asks for a selfie
dateOfBirthYYYY-MM-DD, when the step has a minimum age
minimumAgeMetWhether the date of birth reaches the step's minimum age
nameMatchesWhether the document's name contains the applicant's name, when the step requires it
canRetryWhether the applicant may start another attempt
retryAllowedWhether staff allowed a new attempt after this one
updatedAtLast 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

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

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

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

setApplicationStatus(clubId, id, revision, status, rejectionNote) accepts accepted or rejected only when allowed by the current state, as detailed in 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), 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

An applicant books the current 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.

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

The server enforces these transitions:

Current statusAllowed next statusEntry point
draftsubmittedExplicit confirmation with advanceApplication(submit: true)
submittedaccepted, rejectedsetApplicationStatus
under_reviewaccepted, rejectedsetApplicationStatus
rejectedacceptedsetApplicationStatus
acceptedNoneAfter-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.

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

FieldTypeMeaning
idID!Unique event UUID.
revisionInt!Application revision resulting from the transition; unique per application.
fromStatusApplicationStatusPrevious status; null only for recorded creation.
toStatusApplicationStatus!Resulting status.
occurredAtString!Server timestamp with time zone, fixed when recorded.
actorTypeApplicationActorType!Origin: applicant, staff, or system.
actorIdStringAuthenticated staff ID for new review decisions; nullable for older events.
reasonStringTrimmed 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

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.

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

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:

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

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

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) 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 and are notified (sponsorship category) when the person receives their first membership. Sponsorship never accepts an application by itself; the decision stays with staff.

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

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.
mutation CreateSponsorCode($clubId: ID!) {
  createSponsorCode(clubId: $clubId) {
    code {
      id
      code
      createdAt
    }
    error
  }
}

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

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.

Sur cette page