DTC Onboarding Flow
The DtcOnboardingFlow component renders the traditional (non-instant-offer) questionnaire, matching the DTC flow on opendoor.com. It presents 17 pages of questions — one per screen — with a two-column layout on desktop and stacked layout on mobile. It handles navigation, validation, conditional page skipping, and fires onSubmit when the user completes the flow.
Basic usage
import { useState } from 'react';import { DtcOnboardingFlow, OpendoorProvider,} from '@opendoor/partner-sdk-client-react';import { OpendoorClient } from '@opendoor/partner-sdk-client-js-core';import type { Address } from '@opendoor/partner-sdk-client-js-core';
const client = new OpendoorClient({ baseURL: '/api/opendoor/v1' });
function App({ address }: { address: Address }) { const [offerId, setOfferId] = useState<string | null>(null);
// Step 1: Create the offer request BEFORE showing the questionnaire const startFlow = async () => { const offer = await client.createOffer({ address }); if (offer.offerStatus === 'DENIED') { console.log('Denied:', offer.denialInfo?.explanation); return; } setOfferId(offer.opendoorOfferRequestId); };
if (!offerId) { return <button onClick={startFlow}>Get an Offer</button>; }
// Step 2: Submit answers via updateOffer when the questionnaire completes const handleSubmit = async (answers: Record<string, unknown>) => { await client.updateOffer(offerId, answers); // Step 3: Poll client.getOffer(offerId) until status is OFFERED or DENIED };
return ( <OpendoorProvider client={client}> <DtcOnboardingFlow address={address} appearance={{ theme: 'minimal' }} partnerLogoUrl="/your-logo.svg" onSubmit={handleSubmit} onClose={() => console.log('User closed the flow')} /> </OpendoorProvider> );}<script setup lang="ts">import { ref } from 'vue';import { DtcOnboardingFlow, OpendoorClient, OpendoorProvider,} from '@opendoor/partner-sdk-client-vue';import '@opendoor/partner-sdk-client-vue/dist/style.css';import type { Address } from '@opendoor/partner-sdk-client-js-core';
const props = defineProps<{ address: Address }>();const client = new OpendoorClient({ baseURL: '/api/opendoor/v1' });const offerId = ref<string | null>(null);
// Step 1: Create the offer request BEFORE showing the questionnaireasync function startFlow() { const offer = await client.createOffer({ address: props.address }); if (offer.offerStatus === 'DENIED') { console.log('Denied:', offer.denialInfo?.explanation); return; } offerId.value = offer.opendoorOfferRequestId;}
// Step 2: Submit answers via updateOfferconst handleSubmit = async (answers: Record<string, unknown>) => { if (!offerId.value) return; await client.updateOffer(offerId.value, answers); // Step 3: Poll client.getOffer(offerId) until status is OFFERED};
const handleClose = () => console.log('User closed the flow');</script>
<template> <button v-if="!offerId" @click="startFlow">Get an Offer</button> <OpendoorProvider v-else :client="client"> <DtcOnboardingFlow :address="address" :appearance="{ theme: 'minimal' }" partner-logo-url="/your-logo.svg" :on-close="handleClose" @submit="handleSubmit" /> </OpendoorProvider></template>Wrap the component in <OpendoorProvider> to supply the SDK client.
Required BFF endpoints
| Route | Backing server SDK call |
|---|---|
POST /api/opendoor/v1/offer/create | opendoor.createOffer(...) |
POST /api/opendoor/v1/offer/update | opendoor.updateOffer(offerId, ...) |
POST /api/opendoor/v1/offer | opendoor.getOffer(offerId) |
POST /api/opendoor/v1/homebuilders | opendoor.getHomebuilders() |
See Quick Start step 1 for complete Node (Express) and Ruby (Rails) backend examples.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
address | Address | Required | The selected property address |
onSubmit | (answers) => void | Required | Called when user completes all pages |
partnerLogoUrl | string | - | Partner logo URL (shown before the Opendoor logo in header) |
appearance | OpendoorAppearance | { theme: 'minimal' } | Visual theme configuration |
initialAnswers | Record<string, AnswerValue> | - | Prefill data (e.g., from address lookup) |
market | string | - | Market identifier for region-specific questions |
smsConsentText | ReactNode | - | Custom SMS opt-in toggle label on the contact page (below) |
contactConsentText | ReactNode | - | Custom legal disclaimer below the submit button on the contact page (below) |
onClose | () => void | - | Called when close (X) button is clicked |
In Vue, props use kebab-case in templates (e.g., partner-logo-url, initial-answers). The onClose handler is passed as a prop (:on-close="fn"), not an emit. Callbacks that are emits use @event-name syntax — see the events table below.
| Prop | Type | Default | Description |
|---|---|---|---|
address | Address | Required | The selected property address |
partner-logo-url | string | - | Partner logo URL (shown before the Opendoor logo in header) |
appearance | OpendoorAppearance | { theme: 'minimal' } | Visual theme configuration |
initial-answers | Record<string, AnswerValue> | - | Prefill data (e.g., from address lookup) |
market | string | - | Market identifier for region-specific questions |
:on-close | () => void | - | Called when close (X) button is clicked (prop, not emit) |
Slots:
| Slot | Description |
|---|---|
#sms-consent-text | Custom SMS opt-in toggle label on the contact page (below) |
#contact-consent-text | Custom legal disclaimer below the submit button on the contact page (below) |
Callbacks & Events
| Callback | Type | When it fires |
|---|---|---|
onReady | () => void | Component mounted and ready |
onSubmit | (answers) => void | User completed all pages |
onPageChange | (pageIndex, pageId) => void | User navigates between pages |
onAnswerChange | (key, value) => void | Any answer changes |
onError | (error: Error) => void | Validation or internal error |
onClose | () => void | Close button clicked |
In Vue, callbacks are emitted as kebab-case events instead of onCamelCase callback props:
| Vue emit | Payload | When it fires |
|---|---|---|
@submit | Record<string, unknown> | User completed all pages |
@ready | — | Component mounted and ready |
@page-change | pageIndex, pageId | User navigates between pages |
@answer-change | key, value | Any answer changes |
@error | Error | Validation or internal error |
onClose is a prop in Vue (:on-close="fn"), not an emit, because it controls the close button behavior.
Pages
The component renders 17 pages. Pages with conditional visibility are automatically skipped when their conditions are not met (e.g., HOA sub-pages are skipped when HOA = No).
| # | Page | What it collects |
|---|---|---|
| 1 | Confirm Home Details | Dwelling type, beds, baths, sqft, stories, basement, year, pool |
| 2 | Ownership | Relationship to owner (owner, agent, other) |
| 3 | Sale Timeline | When the user needs to sell |
| 4-7 | Room Condition (×4) | Kitchen, bathroom, living room, exterior — 5-level image select |
| 8 | HOA | Is the home part of an HOA? |
| 9 | HOA Type | Age-restricted, gated community (if HOA = Yes) |
| 10 | HOA Guard | Guard at entrance (if gated community) |
| 11 | HOA Fees | Monthly fees (if HOA = Yes, optional) |
| 12 | Eligibility Criteria | Disqualifying property features (solar, foundation, etc.) |
| 13 | Upgrades | Has the home had upgrades? |
| 14 | Homebuilder | Working with a homebuilder? |
| 15 | Homebuilder Name | Which homebuilder (if yes) |
| 16 | Homebuilder Details | Sales associate email + community name (if yes) |
| 17 | Contact Info | Name, email, phone, SMS consent |
Consent language
The contact page (page 17) has two independently configurable consent strings:
smsConsentText/#sms-consent-text— the SMS marketing opt-in toggle label. Defaults to Opendoor’s standard SMS consent copy. Override to add your company name for co-consent. In React, pass aReactNodeprop; in Vue, use the#sms-consent-textslot.contactConsentText/#contact-consent-text— the legal disclaimer rendered below the “See my offer” button. Defaults to Opendoor’s Terms of Service and Privacy Policy disclaimer. In React, pass aReactNodeprop; in Vue, use the#contact-consent-textslot.
Customizing the SMS opt-in label
<DtcOnboardingFlow address={address} smsConsentText="I consent to receive marketing calls and texts from Opendoor and its affiliates to the number provided. I also agree to be contacted by Acme Co. I understand that I may opt out at any time and consent is not a condition of purchase." onSubmit={handleSubmit}/><DtcOnboardingFlow :address="address" @submit="handleSubmit"> <template #sms-consent-text> I consent to receive marketing calls and texts from Opendoor and its affiliates to the number provided. I also agree to be contacted by Acme Co. I understand that I may opt out at any time and consent is not a condition of purchase. </template></DtcOnboardingFlow>Customizing the legal disclaimer
<DtcOnboardingFlow address={address} contactConsentText={ <p> By clicking "See my offer," I agree to Acme Co.’s{‘ ‘} <a href="https://acme.co/terms">Terms of Service</a> and{‘ ‘} <a href="https://acme.co/privacy">Privacy Policy</a>. </p> } onSubmit={handleSubmit}/><DtcOnboardingFlow :address="address" @submit="handleSubmit"> <template #contact-consent-text> <p> By clicking "See my offer," I agree to Acme Co.’s <a href="https://acme.co/terms">Terms of Service</a> and <a href="https://acme.co/privacy">Privacy Policy</a>. </p> </template></DtcOnboardingFlow>Prefilling answers
Pass initialAnswers to pre-populate fields (e.g., from property data). For the full list of keys, config types, and allowed option values, see the field reference below.
<DtcOnboardingFlow address={address} initialAnswers={{ 'home.dwelling_type': 'single_family', 'home.bedrooms': 3, 'home.bathrooms.full': 2, 'home.above_grade_sq_ft': 1800, 'home.year_built': 2010, }} onSubmit={handleSubmit}/><DtcOnboardingFlow :address="address" :initial-answers="{ 'home.dwelling_type': 'single_family', 'home.bedrooms': 3, 'home.bathrooms.full': 2, 'home.above_grade_sq_ft': 1800, 'home.year_built': 2010, }" @submit="handleSubmit"/>Loading prefills from getHomeDetail
Call client.getHomeDetail({ opendoorOfferRequestId }) after createOffer to fetch prefills from MLS and public records. Pass the result as initialAnswers. See the field reference below for available keys.
async function startFlow(address: Address) { const offer = await client.createOffer({ address }); const { answerPrefills } = await client.getHomeDetail({ opendoorOfferRequestId: offer.opendoorOfferRequestId, });
const initialAnswers = Object.fromEntries( Object.entries( answerPrefills as Record<string, { value: unknown }> ).map(([key, entry]) => [key, entry.value]) );
// Render with the loaded prefills: // <OpendoorProvider client={client}> // <DtcOnboardingFlow // address={address} // initialAnswers={initialAnswers} // onSubmit={(answers) => client.updateOffer(offer.opendoorOfferRequestId, answers)} // /> // </OpendoorProvider>}<script setup lang="ts">import { ref } from 'vue';import { DtcOnboardingFlow, OpendoorClient, OpendoorProvider,} from '@opendoor/partner-sdk-client-vue';import '@opendoor/partner-sdk-client-vue/dist/style.css';import type { Address } from '@opendoor/partner-sdk-client-js-core';
const props = defineProps<{ address: Address }>();const client = new OpendoorClient({ baseURL: '/api/opendoor/v1' });const offerId = ref<string | null>(null);const initialAnswers = ref<Record<string, unknown>>({});
async function startFlow() { const offer = await client.createOffer({ address: props.address }); const { answerPrefills } = await client.getHomeDetail({ opendoorOfferRequestId: offer.opendoorOfferRequestId, }); initialAnswers.value = Object.fromEntries( Object.entries( answerPrefills as Record<string, { value: unknown }> ).map(([key, entry]) => [key, entry.value]) ); offerId.value = offer.opendoorOfferRequestId;}
const handleSubmit = (answers: Record<string, unknown>) => client.updateOffer(offerId.value!, answers);</script>
<template> <button v-if="!offerId" @click="startFlow">Get an Offer</button> <OpendoorProvider v-else :client="client"> <DtcOnboardingFlow :address="address" :initial-answers="initialAnswers" @submit="handleSubmit" /> </OpendoorProvider></template>Field reference
Use these dot-notation keys with initialAnswers on DtcOnboardingFlow (React) or :initial-answers (Vue). Shapes match what you pass to updateOffer. Aggregate type for values is AnswerValue: string | number | boolean | string[] | undefined (from @opendoor/partner-sdk-client-js-core/internal/questionnaire).
| Page | Key | Config type | Required | Allowed values / notes |
|---|---|---|---|---|
confirm-home-details | home.dwelling_type | string | yes | single_family, townhouse, apartment, mobile_home |
confirm-home-details | home.bedrooms | number | yes | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 |
confirm-home-details | home.bathrooms.full | number | yes | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 |
confirm-home-details | home.bathrooms.half | number | no | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 |
confirm-home-details | home.exterior_stories | number | yes | 1, 2, 3 |
confirm-home-details | home.above_grade_sq_ft | number | yes | (no fixed enum) |
confirm-home-details | home.has_basement | string | yes | yes, no |
confirm-home-details | home.basement_finished_sq_ft | number | no | (no fixed enum) |
confirm-home-details | home.basement_unfinished_sq_ft | number | no | (no fixed enum) |
confirm-home-details | home.year_built | number | yes | 1877–2026 (150 values) |
confirm-home-details | home.pool_type | string | yes | in_ground, above_ground, community_pool, no_pool |
confirm-home-details | home.covered_parking_type | string | no | garage, carport, none |
confirm-home-details | home.garage_spaces | number | no | 0, 1, 2, 3, 4, 5 |
confirm-home-details | home.entry_type | string | yes | direct_entry, shared_entrance_condo |
ownership | seller.relation_to_owner | string | yes | self, agent |
sale-timeline | seller.sale_timeline | string | yes | ASAP, 2_TO_4_WEEKS, 4_TO_6_WEEKS, 6_PLUS_WEEKS, JUST_BROWSING |
kitchen-condition | home.kitchen_seller_score | string | yes | fixer_upper, dated, standard, high_end, luxury |
bathroom-condition | home.bathroom_seller_score | string | yes | fixer_upper, dated, standard, high_end, luxury |
living-room-condition | home.living_room_seller_score | string | yes | fixer_upper, dated, standard, high_end, luxury |
exterior-condition | home.exterior_seller_score | string | yes | fixer_upper, dated, standard, high_end, luxury |
hoa | home.hoa | string | yes | yes, no |
hoa-type | home.hoa_type | string[] | yes | age_restricted_community, gated_community, none |
hoa-guard | home.hoa_type.guarded_gated_community | string | yes | has_guard, no_guard |
hoa-fees | home.hoa_fees | number | no | (no fixed enum) |
eligibility-criteria | home.eligibility_criteria | string[] | no | leased_solar_panels, known_foundation_issues, fire_damage, well_water, septic, unique_ownership_structure, below_market_rate_ownership, rent_controlled_tenant_occupied, mobile_manufactured_home, none |
upgrades | home.has_upgrades | string | yes | yes, no |
homebuilder | seller.working_with_home_builder | string | yes | true, false |
homebuilder-name | seller.home_builder | string | no | (no fixed enum) |
homebuilder-details | seller.home_builder_email | string | no | (no fixed enum) |
homebuilder-details | seller.home_builder_community | string | no | (no fixed enum) |
contact-info | seller.full_name | string | yes | (no fixed enum) |
contact-info | seller.email | string | yes | (no fixed enum) |
contact-info | seller.phone_number | string | yes | (no fixed enum) |
contact-info | seller.sms_opt_in | boolean | no | — |
Styling
The component uses the appearance prop for theming. It defaults to the minimal theme. All standard tokens apply — colors, fonts, spacing, borders, and radii are shared with other SDK components like AddressEntry and QualificationQuestions:
<DtcOnboardingFlow address={address} appearance={{ theme: 'minimal', variables: { colorPrimary: '#003366', fontFamily: '"Inter", sans-serif', colorCardBorderSelected: '#003366', colorCardBackgroundSelected: '#f0f5ff', }, }} onSubmit={handleSubmit}/><DtcOnboardingFlow :address="address" :appearance="{ theme: 'minimal', variables: { colorPrimary: '#003366', fontFamily: '\"Inter\", sans-serif', colorCardBorderSelected: '#003366', colorCardBackgroundSelected: '#f0f5ff', }, }" @submit="handleSubmit"/>The card selection tokens (colorCardBackground, colorCardBorder, colorCardBorderSelected, colorCardBackgroundSelected) control the selectable card components used for radio and checkbox inputs.
See the Styling & Themes guide for all available tokens.
Resetting the form
To reset the component (e.g., start over with a different address), change the key prop:
<DtcOnboardingFlow key={address.street1} address={address} onSubmit={handleSubmit}/><DtcOnboardingFlow :key="address.street1" :address="address" @submit="handleSubmit"/>