Schedule a Virtual Estimate
How to programmatically schedule a virtual estimate using the MoveRight GraphQL API — the same flow the dashboard and intake form use. Covers setting the job's location and area, retrieving estimator availability, booking the event, and what happens when Yembo is enabled.
Open GraphQL Playground →Overview
A virtual estimate is a calendar event of type virtualEstimate assigned to an Estimator asset. Scheduling one programmatically involves four steps:
- 1.Set the job's start location — the origin address where the move begins. Virtual estimates require a
startlocation on the job. - 2.Set the job's zone / area — the zone determines which estimator pool is available. If the job already has a zone (e.g. set at creation), skip this step.
- 3.Retrieve estimator availability — use the
findquery to get bookable time slots for Estimator-type assets in the job's zone. - 4.Book the event — use the
lockWindowmutation to create (or update) the virtual estimate event with the chosen slot and estimator.
lockWindow and not createCalendarEvent? The lockWindow mutation is the same one the dashboard uses. It validates that the chosen slot is still available, creates or updates the event in one call, assigns it to the job's zone, and — if Yembo is enabled — automatically creates the Smart Consult room. Using createCalendarEvent directly skips availability validation and the Yembo integration hook.
Prerequisites
- A job must already exist. You'll need its
jobIdand its currentzone.id. - Authentication headers and zone context (see API Overview).
- Optionally, an existing location ID to attach as the job's origin. If you don't have one, you can create a new location inline.
Step 1 — Set the Job's Location
Virtual estimate events require a start location on the job. Use updateJobs with addLocations to attach an existing location, or create one inline with newLocationInput.
Option A: Attach an existing location
mutation SetJobLocation($updateJobs: [UpdateJobInput]) {
updateJobs(updateJobs: $updateJobs) {
isSuccess
jobs {
id
locations {
locationType
location {
id
addressLineOne
areaCode
city
serviceArea { id name }
}
}
}
}
}
// Variables:
{
"updateJobs": [{
"jobId": "7af1bc10-e356-11ef-8bf8-7dd6e69e92c7",
"addLocations": [
{
"locationId": "existing-location-uuid",
"locationType": "start"
}
]
}]
} Option B: Create a new location inline
If you don't have a location ID, pass newLocationInput instead. The areaCode field (zip/postal code) is important — it's used to resolve the service area and zone in the next step.
{
"updateJobs": [{
"jobId": "7af1bc10-e356-11ef-8bf8-7dd6e69e92c7",
"addLocations": [
{
"newLocationInput": {
"addressLineOne": "123 Main Street",
"areaCode": "90210",
"city": "Beverly Hills",
"country": "US"
},
"locationType": "start"
}
]
}]
} start (origin), end (destination), dock (storage/warehouse). For virtual estimates, start is required — it represents the pickup/origin address that determines the service area.
Step 2 — Ensure the Job Has the Correct Zone
The job's zone determines which estimator pool is available. If the job was created via the intake form or with an explicit zoneId, it already has a zone — skip to Step 3.
If the job doesn't have a zone, or you need to reassign it, use updateJobs with setZone:
mutation SetJobZone($updateJobs: [UpdateJobInput]) {
updateJobs(updateJobs: $updateJobs) {
isSuccess
jobs { id zone { id name } }
}
}
// Variables:
{
"updateJobs": [{
"jobId": "7af1bc10-e356-11ef-8bf8-7dd6e69e92c7",
"setZone": "zone-uuid-here"
}]
} zoneDir) will appear in the find results. The zone also controls which intake.virtualEstimators config value is used to filter the estimator pool.
Finding the right zone ID
If you know the zip/postal code but not the zone ID, you can resolve it from the location you just added. Query the job's locations and read the serviceArea field:
query GetJobZone($jobId: String) {
jobs(filter: { jobIds: ["7af1bc10-..."] }, resolve: ["locations"]) {
jobs {
zone { id name }
locations {
locationType
location {
areaCode
serviceArea { id name }
}
}
}
}
}
The serviceArea.id is the zone that the location geographically belongs to. Use that as the setZone value, or pass it directly as zoneInput in the find query below.
Step 3 — Retrieve Estimator Availability
The find query returns bookable time windows for assets matching your filter. For virtual estimates, filter by types: ["Estimator"] and scope to the job's zone.
query FindEstimatorSlots(
$timeWindow: WindowInput!
$assetsFilter: AssetsFilter
$zoneInput: String
$excludedEventIds: [String]
) {
find(
timeWindow: $timeWindow
assetsFilter: $assetsFilter
minNumAssets: 1
zoneInput: $zoneInput
excludedEventIds: $excludedEventIds
) {
windows {
start
end
startTimes
hasConflict
conflictingEvents { id title }
assets {
id
name
metadata
}
}
}
}
// Variables:
{
"timeWindow": {
"startDate": "2026-07-20",
"endDate": "2026-07-25",
"minWindowLength": 1800
},
"assetsFilter": {
"types": ["Estimator"]
},
"zoneInput": "zone-uuid-from-step-2",
"excludedEventIds": []
} minWindowLength
Duration of the virtual estimate in seconds. Typically 1800 (30 min). Check calendarEvents.virtualEstimateLength config for your zone's default.
startTimes
Array of Unix timestamps (seconds) representing valid start times within each window. Pick one — that's the start you'll pass to lockWindow.
Filtering by specific estimators
If your zone has intake.virtualEstimators configured (a list of estimator names), pass those names in assetsFilter.names to match the intake form's behavior:
{
"assetsFilter": {
"types": ["Estimator"],
"names": ["Jane Smith", "John Doe"]
}
}
When names is omitted, all Estimator-type assets in the zone are returned. When names is an empty array, all estimators are returned (same as omitting). Only pass names if you want to restrict to a specific subset.
Understanding the response
| Field | Description |
|---|---|
| windows | Array of bookable time windows. Each window represents a span where at least one asset is available. |
| windows.start / .end | Unix timestamps (seconds) bounding the window. |
| windows.startTimes | Valid start-time timestamps within the window. These are pre-rounded to the configured interval (e.g. 15-min slots). Pick one. |
| windows.assets | Estimator assets available during this window. Each has id, name, and metadata (which may include yemboEmail). |
| windows.hasConflict | If true, the window overlaps with an existing event. The conflicting events are listed in conflictingEvents. |
| windows.conflictingEvents | Events that overlap with this window. Use excludedEventIds to ignore specific events (e.g. when rescheduling). |
Step 4 — Book the Virtual Estimate
The lockWindow mutation is the booking call. It validates that the slot is still available, creates or updates the event, assigns it to the job's zone, and triggers the Yembo integration if enabled.
mutation BookVirtualEstimate(
$start: Int!
$end: Int
$type: String!
$title: String
$jobId: String!
$assetIds: [String]
$locations: [CalendarEventLocationInput]
$eventId: String
$updateEventSequentialOrder: Boolean
$restrictionsEnabled: Boolean
) {
lockWindow(
start: $start
end: $end
type: $type
title: $title
jobId: $jobId
assetIds: $assetIds
locations: $locations
eventId: $eventId
updateEventSequentialOrder: $updateEventSequentialOrder
restrictionsEnabled: $restrictionsEnabled
) {
id
status
start
end
type
metadata
assets { id name metadata }
}
}
// Variables:
{
"start": 1737500000,
"end": 1737501800,
"type": "virtualEstimate",
"title": "Virtual Estimate",
"jobId": "7af1bc10-e356-11ef-8bf8-7dd6e69e92c7",
"assetIds": ["estimator-asset-id-from-find"],
"locations": [
{
"locationId": "start-location-id",
"type": "start",
"order": 1,
"estimatedTimeAtLocation": 1800
}
],
"updateEventSequentialOrder": true,
"restrictionsEnabled": true
} start / end
Unix timestamps in seconds. end defaults to start + virtualEstimateLength if omitted. Use a startTimes value from the find response.
assetIds
Array of estimator asset IDs from the find response. Pass one for a single estimator; multiple for a team estimate.
eventId (optional)
If a placeholder event already exists (e.g. created with createCalendarEvent at status: "required"), pass its ID to update it in place. Omit to create a new event.
restrictionsEnabled
When true (default), the server re-validates availability before booking. Set to false with overrideAvailabilities: true to force-book outside availability hours.
What lockWindow does server-side
- 1.Validates the job exists and loads its zone.
- 2.Re-checks the selected
start/endagainst current availability (unlessoverrideAvailabilitiesis true). - 3.If
eventIdis provided, edits the existing event tostatus: "booked". Otherwise creates a new event. - 4.Assigns the event to the job's zone and updates its sequential order on the job timeline.
- 5.If
type === "virtualEstimate"and Yembo is enabled, creates the Yembo Smart Consult room and stores Yembo metadata on the event (see below).
Optional: Create a placeholder first
If you want to create a placeholder event before booking (e.g. to show a "Virtual Estimate needed" item on the job timeline before a slot is chosen), use createCalendarEvent:
mutation CreatePlaceholder {
createCalendarEvent(calendarEvents: [{
title: "Virtual Estimate",
type: "virtualEstimate",
status: "required",
jobId: "7af1bc10-...",
colour: "orange-yellow",
textColour: "black",
revenueGenerating: false
}]) {
events { id status }
}
} Then pass the returned id as eventId in the lockWindow call to convert the placeholder to a booked event.
Yembo Smart Consult — What Happens Automatically
If your zone has yembo-integration.enabled set to true, booking a virtualEstimate event via lockWindow triggers a cascade of automatic actions. You don't need to call any Yembo mutations manually — the server handles everything.
When lockWindow fires with type: "virtualEstimate":
- 1.A Yembo Move is created (if the job doesn't already have one). The move is linked to the job via
yembo.moveKeystored as a job field. - 2.A Smart Consult room is created via the Yembo API. The estimator's
metadata.yemboEmailis used as the employee email (falls back toyembo-integration.defaultEmail). - 3.Yembo metadata is stored on the event. The event's
metadatafield will include:yemboConsultRoomKey,yemboMoveKey,yemboMoveLink,yemboCustomerLink,yemboConfirmationLink, andyemboJoiningLink. - 4.A comment is added to the event noting which Yembo employee email the consult room was booked with.
Reading the Yembo links from the event
After booking, the lockWindow response (or any subsequent event query) will include the Yembo links in metadata:
query GetBookedEvent($jobId: String) {
jobs(filter: { jobIds: ["7af1bc10-..."] }) {
jobs {
events {
id
type
status
start
end
metadata
assets { id name }
}
}
}
}
// metadata will contain:
// {
// "yemboConsultRoomKey": "abc123",
// "yemboMoveKey": "move-xyz",
// "yemboMoveLink": "https://app.yembo.ai/moves/move-xyz",
// "yemboCustomerLink": "https://app.yembo.ai/...",
// "yemboConfirmationLink": "https://app.yembo.ai/...",
// "yemboJoiningLink": "https://app.yembo.ai/consult/abc123"
// } What happens if a Yembo Smart Consult is scheduled from the Yembo side?
Yembo sends a smart_consult_scheduled webhook when a consult is scheduled directly in Yembo (e.g. by a customer self-scheduling). MoveRight handles this as follows:
- If a virtual estimate event already exists for this job and Yembo move key, the webhook is a no-op. The existing event is not modified or replaced.
- If no virtual estimate event exists and
yembo-integration.createVirtualEstimateIfNotExistsis enabled, MoveRight creates a newvirtualEstimateevent withstatus: "booked"using the time from the Yembo webhook'ssmartConsultScheduledAtfield. The event is assigned to the estimator asset with a matching Yembo email. - If the job can't be found, MoveRight attempts to match by customer name, phone, or email within the zone (last 90 days). If still no match, the webhook is logged and dropped.
Cancellation & rescheduling sync
| Direction | Trigger | Effect |
|---|---|---|
| MR → Yembo | Event cancelled in MoveRight | Yembo consult room status set to "canceled". Yembo metadata removed from the event. |
| MR → Yembo | Event deleted in MoveRight | Same as cancellation — Yembo consult room is canceled. |
| MR → Yembo | Event rescheduled in MoveRight | Yembo consult room updated with new scheduledAt and scheduledDuration. |
| MR → Yembo | Estimator asset changed | Yembo consult room employeeEmail updated to the new asset's Yembo email. |
| Yembo → MR | smart_consult_canceled webhook | MoveRight removes the virtual estimate event from the job. |
| Yembo → MR | move_canceled webhook | MoveRight finds and removes the virtualEstimate event for that move. |
Creating a Yembo self-survey separately
A self-survey is a separate Yembo flow where the customer walks through their home inventory on their own. It's independent of the virtual estimate booking:
mutation CreateSelfSurvey($jobId: String!, $sendEmail: Boolean, $sendSms: Boolean) {
createYemboSelfSurvey(jobId: $jobId, sendEmail: $sendEmail, sendSms: $sendSms) {
link
employeeLink
jobId
}
}
// Variables:
{
"jobId": "7af1bc10-...",
"sendEmail": true,
"sendSms": true
} The returned link is the customer-facing survey URL. employeeLink is the internal review link.
Full Example — End to End
Here's the complete flow in a single script, from a job with no location to a booked virtual estimate with Yembo integration.
# 1. Set the job's start location (with inline location creation)
curl -X POST "https://moveright.app/api/graphql?zone=ZONE_ID" \
-H "Content-Type: application/json" \
-H "Authorization: RefreshToken YOUR_TOKEN" \
-d '{
"query": "mutation($u:[UpdateJobInput]){updateJobs(updateJobs:$u){isSuccess jobs{id locations{locationType location{id areaCode serviceArea{id}}}}}}",
"variables": {
"u": [{
"jobId": "7af1bc10-...",
"addLocations": [{
"newLocationInput": {
"addressLineOne": "123 Main Street",
"areaCode": "90210",
"city": "Beverly Hills",
"country": "US"
},
"locationType": "start"
}]
}]
}
}'
# 2. (Optional) Set the zone if the job doesn't have one yet
# Skip if the job was created with a zoneId or via intake
curl -X POST "https://moveright.app/api/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: RefreshToken YOUR_TOKEN" \
-d '{
"query": "mutation($u:[UpdateJobInput]){updateJobs(updateJobs:$u){isSuccess jobs{zone{id name}}}}",
"variables": {
"u": [{
"jobId": "7af1bc10-...",
"setZone": "resolved-zone-id"
}]
}
}'
# 3. Retrieve estimator availability for the next 5 days
curl -X POST "https://moveright.app/api/graphql?zone=ZONE_ID" \
-H "Content-Type: application/json" \
-H "Authorization: RefreshToken YOUR_TOKEN" \
-d '{
"query": "query($tw:WindowInput!,$af:AssetsFilter,$zi:String){find(timeWindow:$tw,assetsFilter:$af,minNumAssets:1,zoneInput:$zi){windows{start end startTimes assets{id name metadata}}}}",
"variables": {
"tw": { "startDate": "2026-07-20", "endDate": "2026-07-25", "minWindowLength": 1800 },
"af": { "types": ["Estimator"] },
"zi": "zone-id"
}
}'
# 4. Book the virtual estimate with the chosen slot and estimator
curl -X POST "https://moveright.app/api/graphql?zone=ZONE_ID" \
-H "Content-Type: application/json" \
-H "Authorization: RefreshToken YOUR_TOKEN" \
-d '{
"query": "mutation($s:Int!,$e:Int,$t:String!,$j:String!,$a:[String],$l:[CalendarEventLocationInput]){lockWindow(start:$s,end:$e,type:$t,title:$t,jobId:$j,assetIds:$a,locations:$l,updateEventSequentialOrder:true,restrictionsEnabled:true){id status start end metadata assets{id name}}}",
"variables": {
"s": 1737500000,
"e": 1737501800,
"t": "virtualEstimate",
"j": "7af1bc10-...",
"a": ["estimator-asset-id-from-step-3"],
"l": [{ "locationId": "start-location-id", "type": "start", "order": 1 }]
}
}'
# The lockWindow response will include:
# - id: the booked event ID
# - status: "booked"
# - metadata: Yembo links (if Yembo is enabled)
# - assets: the assigned estimator(s) Config Values Reference
These zone-level config values control virtual estimate behavior. Query them with getConfigValues:
query {
getConfigValues(keys: [
"calendarEvents.virtualEstimateLength"
"intake.virtualEstimators"
"intake.largeMoveEstimateMethod"
"yembo-integration.enabled"
"yembo-integration.defaultEmail"
"yembo-integration.createVirtualEstimateIfNotExists"
]) {
key
value
zone
}
} | Config key | Type | Description |
|---|---|---|
| calendarEvents.virtualEstimateLength | Int (seconds) | Duration of a virtual estimate event. Default: 1800 (30 min). Used as minWindowLength in the find query and as the event end if not explicitly set in lockWindow. |
| intake.virtualEstimators | String[] | List of estimator asset names to filter for virtual estimates. Empty array = all estimators in the zone. Pass these as assetsFilter.names in the find query to match intake behavior. |
| intake.largeMoveEstimateMethod | String | "virtualEstimate" or "onSiteEstimate". Controls which event type the intake form creates. Does not affect lockWindow — you set the type explicitly. |
| yembo-integration.enabled | Boolean | When true, lockWindow automatically creates a Yembo Smart Consult room for virtualEstimate events. |
| yembo-integration.defaultEmail | String | Fallback Yembo employee email when the assigned estimator asset has no metadata.yemboEmail. |
| yembo-integration.createVirtualEstimateIfNotExists | Boolean | When true, the smart_consult_scheduled Yembo webhook will auto-create a virtualEstimate event if none exists for the job. |
Try it in the playground
Open the GraphQL playground, authenticate, and run these queries live against your zone. Introspection is enabled — explore the full schema for every input type and field.
Open GraphQL Playground →