Technical

Getting real revenue out of Boulevard GraphQL: Order vs Appointment, and OrderSummary in cents

Appointments have no money fields. Orders do, in cents, with no total. Here's how we compute revenue for ROAS without being off by 100x.

Hamza El Asri Hamza El AsriFounder & lead strategist, RM Click PublishedNovember 10, 2026 Reading time7 min read AudienceDevelopers

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, currency or price fields on the Appointment.
  • appointmentServices, not bookedServices.
  • No referralSource on the client. It's a dashboard feature, not a field you can query.
  • customFields needs a keys argument. You can't list them generically.
  • Notes differ by object: appointment.notes is a plain string, while client.notes is 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": use appointmentServices.
  • 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.

Hamza El Asri
Written by Hamza El Asri

Media buyer and tracking specialist, founder of RM Click. Top Rated on Fiverr with 413 reviews. We built the pipeline this article describes. LinkedIn · Fiverr

FAQ

Frequently asked
questions

Not on the Appointment. Revenue lives on the Order that the appointment's orderId points to, in the order's summary.

Not in our build. We computed it as currentSubtotal + currentTaxAmount + currentFeeAmount + currentGratuityAmount − currentDiscountAmount.

Cents. A real $1 service returned currentSubtotal = 100 in our test. Divide by 100.

The order doesn't exist yet when the booking is made. It's created at checkout, so revenue is only available on APPOINTMENT_COMPLETED.

appointmentServices. bookedServices doesn't exist on the Appointment type in our build.

Free ad audit

Need this set up
for your salon?