If you want real ROAS from Boulevard bookings, you need the money each visit made. The Appointment won't give it to you.
Short answer: revenue lives on the Order referenced by the appointment's orderId, not on the Appointment. The order's summary has no total field: compute currentSubtotal + currentTaxAmount + currentFeeAmount + currentGratuityAmount − currentDiscountAmount. Amounts are in cents. And orderId is null when the appointment is created, so revenue only exists at APPOINTMENT_COMPLETED.
This is how the pipeline in our complete server-side guide sends real values to Meta, Google, TikTok and Snapchat.
Based on our build in 2026, against Boulevard's 2020-01 Admin API. Verify field names in Boulevard's documentation before shipping.
Step 1: the appointment
When a webhook arrives, fetch the appointment with its client. Authentication is covered in Boulevard Admin API authentication.
query GetAppointment($id: ID!) {
appointment(id: $id) {
id
state
startAt
createdAt
cancelled
orderId # null at booking time, set at checkout
appointmentServices { id }
client {
id
email
mobilePhone
appointmentCount # useful to flag new vs returning clients
}
}
}
The schema surprises we hit on this query:
- No
total,currencyor price fields on the Appointment. appointmentServices, notbookedServices.- No
referralSourceon the client. It's a dashboard feature, not a field you can query. customFieldsneeds akeysargument. You can't list them generically.- Notes differ by object:
appointment.notesis a plain string, whileclient.notesis a list that needs sub-fields.
Step 2: the order, only when it exists
query GetOrder($id: ID!) {
order(id: $id) {
id
summary {
currentSubtotal
currentTaxAmount
currentFeeAmount
currentGratuityAmount
currentDiscountAmount
}
}
}
Step 3: compute revenue
export async function getRevenue(env, appointment) {
if (!appointment.orderId) return { value: 0, currency: 'USD' }; // booking: no order yet
const { order } = await blvdQuery(env, ORDER_QUERY, { id: appointment.orderId });
if (!order?.summary) return { value: 0, currency: 'USD' };
const s = order.summary;
const cents = (s.currentSubtotal || 0) + (s.currentTaxAmount || 0) + (s.currentFeeAmount || 0)
+ (s.currentGratuityAmount || 0) - (s.currentDiscountAmount || 0);
return { value: Math.round(cents) / 100, currency: 'USD' }; // cents → dollars
}
How we confirmed the units
We didn't trust our assumption. We completed a real $1 service and logged the raw summary: currentSubtotal came back as 100, and our computed value was $1.02, including a 2-cent fee. Cents confirmed. Without the division, every completed visit would have reported 100 times its real value, and ROAS would have looked spectacular and been meaningless.
Currency
The summary has no currency field. For a US-only business we default to USD. If the business operates in several currencies, take the currency from the location or business settings instead of hardcoding it.
What to include in "revenue"
Our formula includes tax, fees and gratuity, because that's what the client paid. You may prefer a different definition:
- For ROAS against ad spend, many businesses use subtotal minus discounts (service revenue only), excluding tax and tips that don't belong to the business.
- For cash collected, use the full formula.
Pick one, write it down, and use it everywhere. Mixing definitions between platforms and your dashboard makes the numbers impossible to compare.
Booked vs completed: two different signals
Because orderId is null at booking:
| Webhook | Order exists? | What we send |
|---|---|---|
APPOINTMENT_CREATED |
No | Booking event with value 0 (Meta Schedule, TikTok Lead, Snapchat RESERVE + SIGN_UP) |
APPOINTMENT_COMPLETED |
Yes | Purchase event with real revenue |
Don't try to estimate a booking's value from the service menu at booking time. Prices change at checkout (add-ons, discounts, tips, no-shows), and a guessed value teaches the ad platforms the wrong thing. Send the real number when it exists.
Common errors
- Revenue 100× too high: you forgot to divide cents by 100.
Cannot query field "total": compute it from the summary fields.Cannot query field "bookedServices": useappointmentServices.- Revenue always 0: you're reading it on
APPOINTMENT_CREATED, when no order exists yet. - Values don't match the dashboard: different revenue definitions in different places.
Testing checklist
- A completed real low-price service logs the expected value, to the cent.
- A booking (created) sends value 0, with no order query errors.
- Discounts reduce the value; tips and fees follow your chosen definition.
- The value in the ad platforms matches the value in your dashboard.
Related reading: verifying Boulevard webhooks in a Cloudflare Worker.

