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! = 1is 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! = 10accepts 10, 25, or 50.sort: ApplicationSortField = createdacceptscreated,applicant(first name, then last name),submitted(submission timestamp), orstatus.direction: SortDirection = descacceptsascordesc. 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: Stringis 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 ofdraft,submitted,acceptedorrejected.submittedalso includes records left in the retiredunder_reviewstate; includingunder_reviewis rejected.from: Stringandto: Stringare optional inclusive submission dates inYYYY-MM-DDformat, 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 returnBAD_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:
valuefor short answer, long answer, and options blocks.firstNameandlastNamefor the required person-name block.address(AddressInput) for the optional applicant-address block:line1,line2,city,postalCode,state, andcountry.line1,city,postalCode, andcountryare required together; an empty address is allowed only when the block itself is optional.planIdfor 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 sameemailand receivedotptoadvanceApplication. Itserrortells 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.emailOtpSentToandemailOtpExpiresAtexpose 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 samephoneand receivedotptoadvanceApplication. The number must include its country code; a number without+or00is 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.phonestores 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 withsetClubPhoneCodeCap. 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 publicphoneVerificationCaptchaSiteKeyquery returns a key, render Cloudflare Turnstile with it and pass the widget's token ascaptchaToken. 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.phoneOtpSentToandphoneOtpExpiresAtexpose the pending challenge;Application.phoneholds the verified number. Pass the completed block'sstepIndexto 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:
| 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
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 afterunder_ageor 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 signedinvoice.paidwebhook. 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 HTTPSsignatureUrland anembeddedflag. 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 issigned; 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 returnsnullwhen the current block is not a booking block, or, after approval, when no person is linked.applicationReservationSlots(applicationId, date, guests)returns the sameReservationSlotDayasreservationSlotsfor the step's category, including categories hidden from members.dateisYYYY-MM-DDin club time andguests1–500; other values receiveBAD_USER_INPUT. It returnsnullunder 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 inerror, 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 returnserror: "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 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.
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).
| 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
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
}
}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 hasid,code(formattedXXXX-XXXX),createdAt, and nullableusedAtandusedBy(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 (no0,O,1orI) and do not expire.revokeSponsorCode(clubId: ID!, id: ID!): Boolean!deletes an unused code and returnsfalsewhen 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: asendingtext 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.