Who is this guide for?
This guide is intended for developers building external checkout experiences. If you are using the standard Checkout Widget without a custom checkout flow, this validation is performed automatically by Checkout and no additional implementation is required.
Before taking payment, your application should verify that the order contains all of the information required to complete every booking.
If required information is missing, you should return the customer to the appropriate Checkout page instead of attempting payment.
This guide explains how to inspect an order and determine the first incomplete step before payment in an external implementation. This validation can be used by:
hybrid cart implementations that use the Ventrata Checkout Widget for part of the customer journey
fully custom checkout implementations
Why Validate Before Payment?
Customers may leave Checkout before completing every required field.
📒 NOTE
Client implementations are not required to use the same framework concept, but it should return some equivalent remediation target so the UI knows what to display next.
Completion Step | Checkout view | Definition |
|
| availability still needs to be selected |
|
| a required booking, unit, or package booking question needs an answer |
| Contact page | a required contact field still needs a value |
|
| a booking or package booking requires pickup selection |
|
| a booking or package booking requires dropoff selection |
|
| a required order-level question still needs an answer |
Attempting payment before these requirements are satisfied can result in an incomplete booking experience.
Instead, validate the order first, then send the customer back to Checkout only when necessary.
Type proposal
Type proposal
```ts
type CompletionStep =
| "package-availability"
| "booking-questions"
| "contact-details"
| "pickup"
| "dropoff"
| "order-questions";
type UnfinishedBookingResult = {
step: CompletionStep;
booking: Booking | null;
reason: string;
};
type Order = {
bookings: Booking[];
questionAnswers?: QuestionAnswer[];
};
type Booking = {
id: string;
cancellation?: unknown | null;
availability?: Availability | null;
questionAnswers?: QuestionAnswer[];
unitItems: UnitItem[];
packageBookings?: PackageBooking[];
pickupPointId?: string | null;
pickupRequested?: boolean;
dropoffPointId?: string | null;
dropoffRequested?: boolean;
};
type PackageBooking = Booking & {
packageInclude?: {
availabilityRequired: boolean;
} | null;
};
type UnitItem = {
id: string;
unit: {
questions: Question[];
requiredContactFields: string[];
};
contact?: Record<string, unknown>;
questionAnswers?: QuestionAnswer[];
};
type QuestionAnswer = {
questionId: string;
question: Question;
// Checkout receives unanswered questions as null. If another API uses
// undefined or empty strings for missing answers, normalize them before
// running this check or update isMissingAnswer().
value: string | null | undefined;
};
type Question = {
id: string;
required: boolean;
dependentAnswers?: Array<{
questionId: string;
values: string[];
}>;
};
type Availability = {
id: string;
pickupRequired?: boolean;
dropoffRequired?: boolean;
};
```
Order Validation
Checkout currently validates incomplete data in the following order:
Active bookings only. Cancelled bookings are ignored.
Package bookings that require availability but do not have availability.
Visible required booking-level questions with
`value === null`.Visible required unit-level questions with
`value === null`.Required unit contact fields, but only for units that have questions.
Required package-unit contact fields, but only for units that have questions.
Order-level visible required questions with
`value === null`.
The first incomplete step becomes the page that should be shown when Checkout is reopened.
📒 NOTE
The booking-level checks win over order-level checks. If a booking is incomplete and order-level questions are also incomplete, the booking is returned first.
Checkout's current data model represents unanswered question values as
`null`. The pseudocode also treats`undefined`as missing so client integrations that omit unanswered values can reuse the same structure after normalizing their API response.Checkout's current internal helper returns the
`questions`view for missing required contact fields. This document intentionally separates those into`contact-details`for a clearer client implementation.
Determining the Next Step
Your validation logic should inspect each active booking until it finds the first missing requirement.
The result should identify:
the booking (if applicable)
the required completion step
the reason validation failed
For example:
type CompletionStep =
| "package-availability"
| "booking-questions"
| "contact-details"
| "pickup"
| "dropoff"
| "order-questions";
type UnfinishedBookingResult = {
step: CompletionStep;
booking: Booking | null;
reason: string;
};
If every booking is complete, the helper should return null.
View pseudocode
View pseudocode
```ts
function findUnfinishedBooking(
order: Order | null,
): UnfinishedBookingResult | null {
if (!order) {
return null;
}
const activeBookings = order.bookings.filter(
(booking) => !booking.cancellation,
);
for (const booking of activeBookings) {
const missingBookingStep = findMissingBookingStep(booking);
if (missingBookingStep) {
return {
...missingBookingStep,
booking,
};
}
}
const missingOrderQuestion = findMissingRequiredVisibleQuestion(
order.questionAnswers ?? [],
);
if (missingOrderQuestion) {
return {
step: "order-questions",
booking: null,
reason: `Required order question "${missingOrderQuestion.questionId}" is missing`,
};
}
return null;
}
function findMissingBookingStep(
booking: Booking,
): Omit<UnfinishedBookingResult, "booking"> | null {
const packageBookings = booking.packageBookings ?? [];
for (const packageBooking of packageBookings) {
if (
packageBooking.packageInclude?.availabilityRequired &&
!packageBooking.availability
) {
return {
step: "package-availability",
reason: `Package booking "${packageBooking.id}" requires availability`,
};
}
}
const missingBookingQuestion = findMissingRequiredVisibleQuestion(
booking.questionAnswers ?? [],
);
if (missingBookingQuestion) {
return {
step: "booking-questions",
reason: `Required booking question "${missingBookingQuestion.questionId}" is missing`,
};
}
for (const unitItem of booking.unitItems) {
const missingUnitQuestion = findMissingRequiredVisibleQuestion(
unitItem.questionAnswers ?? [],
);
if (missingUnitQuestion) {
return {
step: "booking-questions",
reason: `Required unit question "${missingUnitQuestion.questionId}" is missing`,
};
}
}
for (const unitItem of booking.unitItems) {
if (unitItem.unit.questions.length === 0) {
continue;
}
for (const fieldName of unitItem.unit.requiredContactFields) {
if (!unitItem.contact?.[fieldName]) {
return {
step: "contact-details",
reason: `Required unit contact field "${fieldName}" is missing`,
};
}
}
}
for (const packageBooking of packageBookings) {
for (const unitItem of packageBooking.unitItems) {
if (unitItem.unit.questions.length === 0) {
continue;
}
for (const fieldName of unitItem.unit.requiredContactFields) {
if (!unitItem.contact?.[fieldName]) {
return {
step: "contact-details",
reason: `Required package unit contact field "${fieldName}" is missing`,
};
}
}
}
}
const missingPickup = findMissingPickup(booking);
if (missingPickup) {
return missingPickup;
}
const missingDropoff = findMissingDropoff(booking);
if (missingDropoff) {
return missingDropoff;
}
return null;
}
```
Required Question Validation
Only visible required questions should prevent payment.
Questions hidden by dependency rules should be ignored.
For example:
Question B depends on Question A = "Yes"
Question A = "No"
Because Question B is not visible, it should not be treated as missing.
View execution code
View execution code
```ts
function findMissingRequiredVisibleQuestion(
questionAnswers: QuestionAnswer[],
): QuestionAnswer | null {
const answersByQuestionId = Object.fromEntries(
questionAnswers.map((answer) => [answer.questionId, answer.value]),
);
for (const answer of questionAnswers) {
if (
!isQuestionVisible(answer.question, answersByQuestionId, questionAnswers)
) {
continue;
}
if (answer.question.required && isMissingAnswer(answer.value)) {
return answer;
}
}
return null;
}
function isMissingAnswer(value: string | null | undefined): boolean {
return value === null || value === undefined;
}
function isQuestionVisible(
question: Question,
answersByQuestionId: Record<string, string | null | undefined>,
allQuestionAnswers: QuestionAnswer[],
visitingQuestionIds = new Set<string>(),
): boolean {
const dependencies = question.dependentAnswers ?? [];
if (dependencies.length === 0) {
return true;
}
if (visitingQuestionIds.has(question.id)) {
return false;
}
try {
visitingQuestionIds.add(question.id);
for (const dependency of dependencies) {
const parentAnswer = allQuestionAnswers.find(
(answer) => answer.questionId === dependency.questionId,
);
if (!parentAnswer) {
return false;
}
const parentIsVisible = isQuestionVisible(
parentAnswer.question,
answersByQuestionId,
allQuestionAnswers,
visitingQuestionIds,
);
if (!parentIsVisible) {
return false;
}
const parentValue = answersByQuestionId[dependency.questionId];
const parentHasAnswer =
typeof parentValue === "string" && parentValue.trim().length > 0;
if (!parentHasAnswer || !dependency.values.includes(parentValue)) {
return false;
}
}
return true;
} finally {
visitingQuestionIds.delete(question.id);
}
}
```
Contact Details
Required contact fields should be validated after booking and unit questions.
If required contact information is missing, return the customer to the Contact Details page rather than the Questions page.
This provides a clearer experience for customers completing passenger or participant information.
Pickup and Dropoff
If the selected availability requires pickup or dropoff selection, verify that a pickup or dropoff point has been chosen.
If not, return the corresponding completion step.
View execution code
View execution code
```ts
function findMissingPickup(
booking: Booking,
): Omit<UnfinishedBookingResult, "booking"> | null {
if (booking.availability?.pickupRequired && !booking.pickupPointId) {
return {
step: "pickup",
reason: `Booking "${booking.id}" requires pickup selection`,
};
}
for (const packageBooking of booking.packageBookings ?? []) {
if (
packageBooking.availability?.pickupRequired &&
!packageBooking.pickupPointId
) {
return {
step: "pickup",
reason: `Package booking "${packageBooking.id}" requires pickup selection`,
};
}
}
return null;
}
function findMissingDropoff(
booking: Booking,
): Omit<UnfinishedBookingResult, "booking"> | null {
if (booking.availability?.dropoffRequired && !booking.dropoffPointId) {
return {
step: "dropoff",
reason: `Booking "${booking.id}" requires dropoff selection`,
};
}
for (const packageBooking of booking.packageBookings ?? []) {
if (
packageBooking.availability?.dropoffRequired &&
!packageBooking.dropoffPointId
) {
return {
step: "dropoff",
reason: `Package booking "${packageBooking.id}" requires dropoff selection`,
};
}
}
return null;
}
```
Recommended Flow
Before payment:
Retrieve the latest order.
Run the validation pseudocode
`findUnfinishedBooking(order)`before starting payment confirmation.If it returns
`null`, the order is ready for payment from this validation perspective.If it returns
`{ booking }`, restore or open that booking and send the customer to the returned completion step.If it returns
`{ booking: null, step: "order-questions" }`, keep the order context and send the customer to the order-level questions step.Listen for Checkout update events.
Repeat validation until the order is complete.
📒 NOTE
Route `contact-details` to a standalone contact page instead of showing it as part of booking or order questions. The customer should understand that they are filling contact details, not answering configurable product questions.
This validation should be performed every time the customer attempts to continue to payment, ensuring that newly discovered requirements are handled before payment is confirmed.
