Skip to main content

API guide

This page lists every Pulse Connect operation with its scope and the rules it follows. The API reference has each operation's request and response fields and enums, and the OpenAPI document is the machine-readable contract.

Paths are relative to the base URL in the developer quickstart: staging, https://api.staging.maidcentral.ai/api/connect/v1, for external pilot partners. The rules every operation shares (ids, time zones, pagination, idempotency, concurrency, errors and rate limits) are under Conventions. Each family below is enabled per branch; GET /capabilities says which ones your branch has.

Context and usage​

Free: these never count toward the monthly allowance.

Method and pathScopeReturns
GET /meNoneThe branch and key behind the request, granted scopes, and contract version.
GET /branchesNoneThe one branch this key belongs to, with its time zone and currency.
GET /capabilitiesNoneWhich families are on for the branch, and constraints such as maximum page and response size.
GET /usageusage.readThis UTC month's allowance, consumption, remaining operations, reset time, and a breakdown by operation.

A family in /capabilities is true only when it is enabled for the branch and this release serves its operations. max_webhook_subscriptions is 0 while webhooks is false; otherwise it is the branch's limit (-1 means none). webhook_event_types lists the events a subscription can select, and is empty while webhooks is false.

/usage breakdown[].usage_class is core_read, core_write or report_run. See How usage is counted.

Customers​

Method and pathScopeFiltersDescription
GET /customerscustomers.readstatus, created_from, created_to, updated_sinceEvery customer in the branch.
GET /customers/lookupcustomers.readqueryUp to 20 customers matching an email, phone or name.
GET /customers/{customer_id}customers.read—One customer.
POST /customerscustomers.write—Create a customer. See Writes.
PATCH /customers/{customer_id}customers.write—Change names, company, email or phone.
PATCH /customers/{customer_id}/consentcustomers.write—Record an SMS or marketing opt-in with its source, or an SMS or email opt-out. See Consent.
POST /customers/{customer_id}/notescustomer_notes.write—Append a note. See Notes.
GET /tagstags.readstatusThe branch's customer tags, including MaidCentral-wide tags and tags the office has turned off (active: false).
POST /customers/{customer_id}/tags/{tag_id}customers.write—Add an active tag to the customer. No body.
DELETE /customers/{customer_id}/tags/{tag_id}customers.write—Remove a tag from the customer.

Tags. Every customer read and write returns tag_ids: the customer's tags, as ids from GET /tags.

  • Adding or removing a tag needs an Idempotency-Key (see Writes) and returns the customer.
  • Adding a tag the customer already has, or removing one it doesn't have, changes nothing and returns 200.
  • An actual change moves the customer's updated_at, so a later PATCH needs the new value.
  • An inactive tag can't be added (404 resource_not_found) but can be removed. Another company's tag, or a customer of another branch, is 404. Tags are shared across your company's branches: GET /tags lists, and these writes accept, a tag another branch created.
  • When the office saves the customer from Pulse's Edit Customer drawer, any inactive tags are removed from it.
  • Tag changes send no webhook.
# a tag id from GET /tags; no body
curl -X POST "$PULSE_CONNECT_BASE/customers/1042/tags/57" \
-H "X-API-Key: $PULSE_CONNECT_KEY" -H "Idempotency-Key: crm-tag-48213"

query is 2–100 characters and is read one of three ways:

query looks likeMatch
An email address (contains @)Exact email, ignoring case.
A phone number (only digits, spaces and ()+-.)The digits must equal a stored phone's digits exactly, and there must be at least 7. +1 555 123 4567 does not match a phone stored as 5551234567.
Anything elseName or company: exact, then starts-with, then (from 3 characters) contains.

created_from and created_to are branch-local dates. /customers checks only that from is not after to.

Homes​

Method and pathScopeFiltersDescription
GET /homeshomes.readcustomer_id, status, updated_sinceEvery home in the branch.
GET /homes/{home_id}homes.read—One home.
GET /customers/{customer_id}/homescustomers.read + homes.readstatus, updated_sinceOne customer's homes.
POST /customers/{customer_id}/homeshomes.write—Add a service address to a customer. See Writes.
PATCH /homes/{home_id}homes.write—Rename a home or replace its address.

A home id identifies a customer's relationship to an address, not the address itself. A home is active when it has future pending service work, otherwise inactive.

zone_id is the service zone the home's postal code falls in, the id GET /zones lists and GET /availability takes as zone_id. It is null in the rare case the home points at a zone the branch does not own.

Service sets​

Method and pathScopeFiltersDescription
GET /service-setsservices.readcustomer_id, home_id, status, frequency, updated_sinceExport recurring and one-time service definitions.
GET /service-sets/{service_set_id}services.read—Return one branch-owned service set.
GET /customers/{customer_id}/service-setscustomers.read + services.readhome_id, status, frequency, updated_sinceReturn service sets belonging to one customer.

Service sets include approved operational fields such as type, frequency, recurrence, status, and current price. Status is active when the service set has future pending jobs; otherwise it is inactive, and the status filter takes exactly those two values. The frequency filter, and the frequency field on every service set, take exactly weekly, every_two_weeks, every_three_weeks, every_four_weeks, other_recurring, quarterly, yearly, one_time or unknown. They do not include internal instructions or notes.

Jobs​

Method and pathScopeFiltersDescription
GET /jobsjobs.readstart_date, end_date (both required), status, customer_id, home_id, service_set_id, updated_sinceJobs in an inclusive date range.
GET /jobs/{job_id}jobs.read—One job.
GET /customers/{customer_id}/jobsjobs.readstart_date, end_date (both required), status, home_id, service_set_id, updated_sinceOne customer's jobs.

start_date and end_date are required, branch-local and inclusive, and may span at most 366 days; a missing, inverted or wider range is 400 invalid_request. Split longer history into consecutive ranges.

A job is one visit. Job data excludes payroll, GPS, access instructions and internal notes. A pending job is in_progress once a technician clocks in on it, and completed once every technician on it has clocked out.

Incremental sync​

To pull only what changed since your last run, send updated_since on /customers, /homes, /service-sets, /jobs, /leads, /quotes or the per-customer lists. It is a branch-local date (YYYY-MM-DD): rows whose published fields changed on or after that day in the branch's timezone, in Pulse or in MaidCentral V2. A date before 1754 is 400 invalid_request. Re-read from the day of your last run, not from an instant, and expect some rows again.

Each row's updated_at comes from the same signal:

Rowupdated_at is the latest change to
CustomerThe customer or any of its contacts.
HomeThe home or its address.
Service setThe service set, its price history, or any price adjustment, including an ended one.
JobThe job, its teams, its price adjustments, its service set's price, or its technicians' time. Clocking in or out and a payroll time edit in Pulse both move it, so a job's version changes when work starts.
Lead or quoteThe lead or quote, or any of its contacts.

Jobs still need start_date and end_date: sync a rolling window, for example 180 days back and 180 ahead. A filtered read examines the same rows as the unfiltered list (for jobs, those in the window) and returns the changed ones.

Not reported. These leave no mark the filter can see; reconcile with a periodic full read:

  • A deleted record leaves the list. A recurring reschedule or a cancelled recurring service replaces or removes future jobs; replacements arrive with new ids.
  • Removing or reordering a customer's contacts.
  • Deleting a service-set or job price adjustment.
  • Deleting a technician's time on a job, approving a technician's time-change request in MaidCentral V2, or editing a technician's time on the MaidCentral V2 payroll screen.
  • A price whose effective date MaidCentral V2 moves back to cover a visit.
  • Renaming a service type, which changes service_type on its service sets.
  • status on customers, homes and service sets, which is derived from other records and today's date. Re-read it with the status filter.
  • Changing which price is selected on a quote without adding a service, which changes job_rate and pricing.
  • Deleting a service from a quote in Pulse or MaidCentral V2, which changes job_rate and pricing.
  • Booking a quote through MaidCentral V2's older online quote form.
  • Deleting a customer in MaidCentral V2, which returns their quotes to /leads.
  • Renaming a quote status or customer source, which changes status_name or source_name.
  • Changing the company's quote expiry, which moves expires_at.
  • Tags added to an existing customer when it is booked from a lead or an online quote, which change tag_ids without moving updated_at.

Job actions​

Move, skip or cancel one scheduled visit. Part of the writes family. The recurring service and its other visits are untouched.

Method and pathScopeBodyDescription
POST /jobs/{job_id}/movejobs.changeversion, scheduled_start_at, reason_id, notesMove the visit to a new start.
POST /jobs/{job_id}/skipjobs.changeversion, charge_fee, reason_id, notesThe customer cancelled this one visit.
POST /jobs/{job_id}/canceljobs.changeversion, notesThe company cancelled this one visit.
GET /jobs/move-reasonsjobs.change—The reason_id values a move accepts. Free.
GET /jobs/skip-reasonsjobs.change—The reason_id values a skip accepts. Free.

Each action needs an Idempotency-Key and answers 200 with the job as GET /jobs/{job_id} reads it after the change.

  • version is the job's updated_at from your last read, sent as you received it. A job changed since, including a technician clocking in, answers 409 version_conflict; read it again before deciding.
  • Only a scheduled visit can change. It must be scheduled, start in the future, and have no technician time recorded. Anything else answers 409 job_not_eligible; leave it to the office.
  • Actions on one branch run one at a time. One that waits too long for another answers 503 service_unavailable with Retry-After; retry with the same key.
  • reason_id and notes are for the office. reason_id is optional and must be one of the branch's reasons for that action (otherwise 400 invalid_request): GET /jobs/move-reasons and GET /jobs/skip-reasons list them, each with id and name, by name. notes is optional, up to 500 characters. Both go on the office ticket the action creates; a cancel is always filed as Company Cancel Job.
  • No payment, no direct message. Nothing is charged, deposited or refunded. The branch's automations for a moved, skipped or cancelled job still run once, as for an office change, and can message the customer.
  • Webhooks fire once per action, never for a replay. A move sends job.rescheduled; a skip or cancel sends job.cancelled with reason skipped or cancelled. The office's own changes also send job.cancelled with reason lock_out or bumped (a bump that kept the job and its date); a bump that replaced the job or changed its date sends job.rescheduled.

Move. scheduled_start_at is YYYY-MM-DDTHH:mm on the branch's scheduling day, the frame GET /availability uses. The start must fall inside a staffed arrival window for every team on the job (any team when it has none), checked when the move is written; otherwise 409 slot_unavailable. Availability is not held between your read and your move. A past start, or a time skipped by a daylight-saving change, is 400 invalid_request. The visit keeps its length and teams.

Skip. With charge_fee: true, the branch's cancellation fee is added when the visit is inside the fee window, and billed through the branch's usual invoicing; outside the window no fee is added. A visit with a tip recorded answers 409 job_not_eligible.

Cancel. No fee is added.

curl -X POST "$PULSE_CONNECT_BASE/jobs/48213/move" \
-H "X-API-Key: $PULSE_CONNECT_KEY" \
-H "Idempotency-Key: move-48213-2026-11-05" \
-H "Content-Type: application/json" \
-d '{"scheduled_start_at":"2026-11-05T09:00","version":"2026-10-30T14:12:08.443Z","notes":"Customer asked for Thursday"}'

Cancel a service​

Cancel a customer's recurring service from a date. Part of the writes family.

Method and pathScopeBodyDescription
POST /service-sets/{service_set_id}/cancelservices.cancelversion, effective_date, reason_id, notesCancel the service from a date.
GET /service-sets/cancel-reasonsservices.cancel—The reason_id values the cancel accepts. Free.

It needs an Idempotency-Key and answers 200 with the service set as GET /service-sets/{service_set_id} reads it after the change.

  • It is the office Cancel Service. The first scheduled visit on or after effective_date is cancelled and every later scheduled visit is removed. Earlier visits stay as they are.
  • effective_date is YYYY-MM-DD on the branch's calendar, not before today; anything else answers 400 invalid_request.
  • version is the service set's updated_at from your last read. A service set changed since answers 409 version_conflict.
  • A scheduled visit must remain. The cancel answers 409 service_not_eligible when no scheduled visit remains on or after the date, or when that visit has technician time recorded.
  • reason_id is required; notes is optional. reason_id must be the id of one of the branch's active Cancel Service reasons, as the office must pick one too; missing or any other value is 400 invalid_request. GET /service-sets/cancel-reasons lists them, each with id and name, by name. notes is up to 500 characters. Both go on the one Cancel Service ticket the cancel files, on the first visit cancelled.
  • It all commits together or not at all. If the office ticket can't be filed, nothing changes and the cancel answers 503 service_unavailable with Retry-After; retry with the same key.
  • No fee, payment, refund or direct message. The branch's automations for a cancelled service still run once, as for an office cancel, and can message the customer.
  • Webhooks fire once, never for a replay. A cancel sends service.cancelled, whose effective_date is the date of the first visit cancelled, which can be later than the date you sent.

Operations​

Teams, technicians, zones and bookable availability.

Method and pathScopeFiltersDescription
GET /teamsteams.readstatusThe branch's teams, with a count of active members.
GET /technicianstechnicians.readstatus, team_idThe branch's technicians. A team_id that /teams never lists returns an empty page.
GET /technicians/{technician_id}technicians.read—One technician.
POST /technicians/{technician_id}/notestechnician_notes.write—Append a note. Part of the writes family. See Notes.
GET /zoneszones.readinclude_zipsService zones. With include_zips=true each zone lists its postal codes and a page holds at most 25 zones, whatever page_size says.
GET /availabilityavailability.readstart_date, end_date, duration_hours, zone_id, team_ids, limitBookable arrival windows. Has its own rate class, tighter than the other reads.

A technician carries a display name, status, team and skill ids, and nothing else: no contact details, address, pay or dates. A technician whose termination date is still ahead is listed as active, but availability is never offered on them.

Availability comes from the scheduler the office books against.

  • arrival_window_start and arrival_window_end bound when the job may start. Any start inside the window is bookable and staffed. A window is at most two hours wide and can be a single time (end equals start).
  • Dates and times are the branch's scheduling day. Today's windows start no earlier than now; windows already passed are not returned.
  • start_date defaults to today, and a past start_date is moved up to today. end_date defaults to 13 days after the start (14 days inclusive). A range over 31 days, measured after that adjustment, is 400 invalid_request; an end_date before start_date is too. A range wholly in the past returns an empty list.
  • duration_hours is the visit's length, 0.5 to 12. For a saved quote, use its pricing.first_visit_minutes ÷ 60, rounded up to two decimals: booking re-checks the slot for the first visit's full length. Ask for 0.5 for a shorter visit. A visit past 12 hours for one technician can't be searched at its full length, so it may not fit the slot you find.
  • limit caps the result, and is applied before unstaffed windows are removed, so a response shorter than limit may still leave slots out. There is no cursor: to see more, raise limit or narrow the date range.
  • zone_id ranks slots on teams already working in that zone first, and a slot right after a job in that zone may start sooner because the drive is shorter; it does not filter. team_ids takes at most 50 ids.
  • Availability is not held. Booking and moving check the slot again when they write.
  • A scheduler failure is 503 service_unavailable, sometimes without Retry-After; back off and retry.

Time​

Worked-time totals: per technician per day from the day clock, and per job from the job timesheets.

Method and pathScopeFiltersDescription
GET /time-summaries/dailytime.readstart_date, end_date, technician_idOne row per technician per work date.
GET /time-summaries/jobstime.readstart_date, end_date, technician_idOne row per job, with each technician's total.
  • start_date and end_date are required, inclusive, and at most 31 days apart.
  • work_date is the branch's scheduling day. A day-clock entry belongs to the day the technician clocked in, even past midnight; a job entry belongs to the job's job_date.
  • worked_minutes sums closed entries in whole minutes from the stored instants, so daylight-saving changes don't add or remove an hour.
  • Day totals exclude unpaid breaks (an unpaid break clocks the technician out) and include paid breaks. Job totals never deduct breaks.
  • An open entry counts 0 until closed: worked_minutes: 0 with has_open_entry: true is a shift in progress. A day or job with no entries has no row.
  • A technician_id the branch doesn't own returns an empty page. On /jobs it narrows each job to that technician.

Clock times, locations, devices, pay and correction history are never returned.

Scorecards​

Customer ratings of completed visits, for example to send a review request to happy customers.

Method and pathScopeFiltersDescription
GET /scorecardsscorecards.readstart_date, end_date, page_size, cursorOne row per customer rating.

Each row has id, job_id, customer_id, rating (1 worst to 5 best) and submitted_at (UTC, to the minute). The customer's comment is not published.

start_date and end_date are both required, inclusive, branch days and at most 31 days apart. A rating the office excluded from scoring is left out.

A customer who re-rates a visit in V2 moves that row to its new time. To hear about ratings as they arrive instead of polling, subscribe to scorecard.submitted (see Webhooks).

Billing​

Invoices, payments and balances. Invoices and payments recorded in MaidCentral V2 (the classic app) are included alongside Pulse's.

Subscribe to invoice.created, invoice.paid and payment.received instead of polling (Webhooks).

Method and pathScopeFiltersDescription
GET /customers/{customer_id}/invoicescustomers.read + billing.readpage, page_sizeOne customer's invoices.
GET /customers/{customer_id}/paymentscustomers.read + billing.readpage, page_sizePayments received from one customer.
GET /customers/{customer_id}/billingcustomers.read + billing.read—Balance, credit, open and overdue counts, last payment.
GET /customers/{customer_id}/invoices/{invoice_id}customers.read + billing.read—One of that customer's invoices.
GET /invoicesbilling.readupdated_sinceEvery invoice in the branch. Report rate class.
GET /paymentsbilling.readupdated_sinceEvery payment received in the branch. Report rate class.

Paging. The per-customer lists page by number (page, page_size, read has_more). The branch-wide lists page by cursor, and a page can hold fewer rows than page_size while has_more is true.

Statuses.

  • An invoice is open, partially_paid, paid, voided or unknown. Overdue is never a status.
  • An invoice created in Pulse and not yet sent is listed as open with its stored balance, not reconciled with QuickBooks, so one paid directly in QuickBooks or V2 stays open and sends no invoice.paid.
  • A payment is settled, received or unknown. received is a payment the business recorded (cash, check or other) that has not been marked settled. Only settled and received payments are listed: a declined, voided or fully refunded payment is absent, so absence is not evidence of failure. A partly refunded payment stays at its original amount while the invoice's amount_paid nets the refund.
  • amount_paid is total − balance: what the invoice no longer owes, including QuickBooks credit memos.
  • unknown means a stored value the API doesn't name yet. Treat it as "none of the above", not as an error.

QuickBooks. For a QuickBooks-synced invoice, the per-customer reads reconcile status, balance and amount_paid with QuickBooks when they read, so an invoice paid in QuickBooks reads paid straight away (its paid_date can stay null for a while). When QuickBooks can't be read, they publish the stored values. The branch-wide lists and the open and overdue counts always use the stored values, which catch up shortly after.

Dates. issued_date and due_date are dates on the branch's scheduling day. paid_date and payment_date are UTC instants.

Overdue. An invoice is overdue when it has a balance and its due_date (or issued_date when it has no due date, as every MaidCentral V2 invoice does) is before today on the branch's scheduling day. open_invoice_count and overdue_invoice_count count only invoices the customer's invoice list publishes.

Balance. balance is the live total owed across the customer's invoices, and includes invoices not yet published (one awaiting tax from the accounting sync, say). So a customer can show a balance with both counts at zero.

Sync. updated_since is a branch-local date: rows changed on or after that day. Invoice and payment rows carry no modification time, so re-read from the day of your last run. Omit it to read everything.

Limits. A customer with more than 20,000 invoices or payments answers 413 result_too_large from the single-invoice read and /billing, and from /invoices or /payments when that customer is on the page.

Identifiers.

  • id is the key. An invoice number is not unique: a batched QuickBooks invoice puts one number on several rows.
  • job_ids are the jobs an invoice bills. A MaidCentral V2 invoice lists exactly one.
  • A payment's invoice_numbers is empty unless the number identifies exactly one published invoice, so an empty list doesn't mean the payment settled nothing.
  • card_brand and card_last_four are null for MaidCentral V2 card charges. No processor ids, cheque numbers, line items, notes or QuickBooks ids are published.

Sales​

Leads, quotes, and everything needed to price and book them.

Method and pathScopeFiltersDescription
GET /leadsleads.readcreated_date_from, created_date_to, updated_sinceProspects who are not customers yet.
GET /leads/{lead_id}leads.read—One prospect.
POST /leadsleads.write—Capture a prospect. Part of the writes family. See Writes.
PATCH /leads/{lead_id}leads.write—Change a prospect's name, contact details or home statistics.
POST /leads/{lead_id}/closeleads.write—Close a prospect as lost with a reason. See Writes.
GET /quotesquotes.readcreated_date_from, created_date_to, customer_id, updated_sinceQuotes, including converted ones.
GET /quotes/{quote_id}quotes.read—One quote.
POST /quotesquotes.write—Save a priced quote. Writes family.
PATCH /quotes/{quote_id}quotes.write—Replace a quote's pricing selections. Writes family.
POST /quotes/{quote_id}/bookbookings.write—Book a saved quote. Writes family. See Booking a saved quote.
POST /quotes/{quote_id}/notesquote_notes.write—Append a note. Writes family. See Notes.
POST /quotes/{quote_id}/payment-linkspayments.write—Mint a save-card link. Payments family. See Payment links.
GET /quotes/configpricing.read—What a customer can choose online. Free.
POST /quotes/calculatepricing.read—Prices for one set of choices. Free.

Lead and quote are one record. A lead is a quote whose prospect hasn't become a customer, and a lead id is a quote id. Once converted, the prospect leaves /leads (the detail answers 404) and stays in /quotes.

  • status_name and source_name are the branch's own labels, not enums.
  • first_job_date is a calendar date; booked_date is a UTC instant.
  • created_date_from and created_date_to are branch-local and inclusive, and may span at most 366 days. Either end can be omitted.
  • medium is how the prospect reached the business. attribution is the first-touch marketing attribution captured at creation, or null. It never changes, even after conversion.
  • Neither read carries the street address, access or pet instructions, or payment-processor ids. The postal code is published.
  • updated_at is the later of the record's change and its newest contact change, and is the same on both reads.
  • closed_reason on a lead is the note saved when it was closed, as Pulse stores it (line breaks as <br>), and null while the lead is open. Older MaidCentral app: a lead closed from its Sales Pipeline saved its reason before the close, so its closed_reason is null, or a note added after the close. Texts and emails are never returned.

Saved pricing. pricing on a quote holds the selections it was priced from and the server-calculated price and estimated_minutes for the chosen frequency, or null when it has no saved selections.

  • price is the per-visit price quoted: the base plus every-visit modifiers and any every-visit promotion. job_rate is the base before modifiers.
  • first_visit_minutes is the first visit's length for one technician: the base plus the booked scope's every-visit and first-visit add-on time. estimated_minutes leaves out the first-visit part.
  • expires_at is when the saved price stops being honoured: its last change plus the branch's quote expiry (30 days unless MaidCentral set another). A saved price doesn't follow later pricing changes; a revision reprices at the current configuration and moves expires_at.
  • A first-visit promotion is saved but isn't in the per-visit price, so promotion_applied reads false here even though /quotes/calculate includes it.
  • The promo code isn't stored. A revision that should keep the promotion sends promo_code again.
  • rooms lists every room the price counted, including a default room set to 0, so sending it back unchanged prices the same.

Booking configuration and instant pricing​

GET /quotes/config returns, in one call, the service types, frequencies, rooms, questions and modifiers a customer may choose online. POST /quotes/calculate prices one service type for the customer's choices. Both are free and need no Idempotency-Key; calculate saves nothing and sends nothing. pricing.read does not grant /quotes reads, and quotes.read does not grant these.

customer_sources lists the branch's "How did you hear about us?" choices to offer online: its active sources not hidden from online quoting, by name, each name once. source_name on POST /leads and POST /quotes accepts every one of them.

Each frequency's bookable says whether POST /quotes/{quote_id}/book can schedule it. A frequency that reads false can still be priced and saved, but the office books it. Read the flag rather than going by the frequency name: every_two_weeks can be either, depending on how the branch set it up.

A service type is a scope of work; its id is the service_type_id service sets publish. Only items switched on for online quoting appear, and only their ids are accepted:

  • A service_type_id not offered online answers 404 resource_not_found. Any other id the configuration didn't return, a duplicate id, or an out-of-range quantity is 400 invalid_request, with detail naming the field (for example rooms[0].room_id).
  • Every required pricing question (select_list, multi_select_list, whole_number, decimal) must be answered; a missing one lists the question ids in detail. text, rich_text, date, time and datetime questions are for display; calculate doesn't accept answers to them.
  • select_list takes exactly one answer_ids entry, multi_select_list one or more. whole_number takes a whole value within the question's bounds. decimal takes a value within bounds, or exactly one answer id.
  • square_footage is required for by_square_foot and simplified service types. rooms is accepted only for by_room.
  • An unknown or expired promo_code is not an error: promotion_applied is false and prices exclude it.
  • First-visit modifiers appear in applied_modifiers but not in the per-visit price. A percentage modifier's cost is a fraction (0.1 is 10%) and takes quantity 1 only. required modifiers are always applied.

The configuration never carries hourly rates, time or frequency factors, minimums or allowed time.

Writes​

The customer, customer tag, home, lead and quote writes, booking, notes, job actions and the service cancel are the writes family; the payment-link mint is the payments family. Every write needs an Idempotency-Key; every PATCH needs expected_updated_at (Conventions), except PATCH /customers/{customer_id}/consent, which takes none so an opt-out is never refused for a stale read. A successful write counts one operation.

Validation. Customer writes run Pulse's duplicate check. Lead and quote writes run none, so check GET /customers/lookup first if you need to. Home, lead and quote writes check the postal code against the branch's franchise territory.

Field rules. Lengths and formats are in the API reference. Members not listed are ignored, except attribution on a lead PATCH, which is refused.

RecordRule
Customerfirst_name and last_name are required on create. A PATCH can change but not clear them, or email and phone. company_name: "" clears it. email writes the email contact, phone the cell-phone contact.
Homename and address (line1, city, region, postal_code) are required on create. country_code is US or CA, US when absent.
Leadfirst_name and last_name are required on create. A PATCH can change but not clear them, or email and phone. address, medium, source_name and attribution are create-only. The address has no country: a lead takes the branch's. On PATCH, only the home statistics you send change.
QuotePOST /quotes takes every lead create field plus pricing: the same selections calculate takes, plus frequency_id. pricing is required on create and PATCH. There is no price field.

PATCH. A field you leave out keeps its value. A PATCH that sends no field is 400 invalid_request with detail naming body; one whose values already match the record is accepted and counts one operation. Sending address replaces the whole address, so an omitted line2 clears it; sending it back as you read it is not a change. When the address is shared with another customer, that customer keeps the old one.

Homes.

  • The postal code must fall in one of the branch's zones, on create and whenever a PATCH changes it; otherwise 400 invalid_request naming address.postal_code. There is no zone_id field to send: the zone comes from the postal code, and home reads publish it.
  • A customer at the branch's maximum number of homes is refused with 400 invalid_request naming customer_id.
  • The first home the creating key adds to a customer it created also becomes the customer's billing address.

Leads.

  • A postal code outside the franchise territory is refused. One in none of the branch's zones is accepted: the lead is saved without a zone and may be tagged Out of Service Area.
  • source_name must name one of the customer sources your company uses in Pulse (the "How did you hear about us?" list). customer_sources from GET /quotes/config lists the ones to offer. With no source_name, or Other, UTM auto-source may set the source from utm_source and utm_medium.
  • POST /leads sends lead.created; a lead PATCH sends nothing.
  • Closing a lead as lost. POST /leads/{lead_id}/close takes a required plain-text reason (at most 500 characters), optional plain-text notes (at most 4000) and the required expected_updated_at. Both are saved as one note on the lead, the reason then a blank line then the notes, which closed_reason returns. The lead leaves the sales pipeline, and its unsent drip-campaign emails and texts are cancelled. Closing a closed lead answers 200 with the lead and changes nothing; a booked lead answers 409 lead_booked. A close sends lead.closed.

Quotes.

  • A quote is saved the way a lead is, plus the pricing rows the office quote form writes. POST /quotes sends lead.created; a revision sends quote.revised; a replay sends nothing.
  • A lead from POST /leads can be priced with PATCH /quotes/{lead_id}, using the updated_at from either read.
  • A PATCH replaces every pricing selection, including answers to the service type's online questions; anything you leave out is removed. Other answers on the quote (a gate code, an office-only question) stay.
  • A saved quote can hold selections no longer offered online. A PATCH that sends one is 400 invalid_request, so build each revision from GET /quotes/config.
  • A converted quote can't be revised: 409 quote_converted.

Contact fields. primary_email and primary_phone follow the customer's preferred contact; after a PATCH they show the new value when it is the preferred one or the first of its kind.

Statuserror_codeWhen
400invalid_requestA field breaks a rule; detail names it and never repeats what you sent. Also: postal code outside the territory or (homes) any zone, unknown source_name, attribution on a lead PATCH, or pricing that is not offered online.
404resource_not_foundThe record isn't in your branch, or the lead has become a customer.
409duplicate_customerCustomer writes: another customer has this email or phone. The response doesn't say which; find it with GET /customers/lookup.
409version_conflictexpected_updated_at isn't the record's current updated_at.
409contact_opted_outConsent: an opt-in for a customer opted out on a channel it covers. Nothing is written.
409quote_convertedThe quote has been booked.
409lead_bookedLead close: the lead has been booked, so it can't be closed.
409payments_not_configuredPayment links: the branch has no payment provider turned on. Nothing is minted.
409idempotency_conflictThe key was used on this route with a different body.
curl -X POST "$PULSE_CONNECT_BASE/quotes" \
-H "X-API-Key: $PULSE_CONNECT_KEY" -H "Idempotency-Key: web-quote-77121" \
-H "Content-Type: application/json" \
-d '{"first_name":"Ada","last_name":"Lovelace","email":"[email protected]","medium":"web","attribution":{"utm_source":"google","utm_medium":"cpc"},"pricing":{"service_type_id":"12","frequency_id":"31","rooms":[{"room_id":"5","quantity":2}],"answers":[{"question_id":"9","answer_ids":["91"]}],"modifiers":[{"modifier_id":"44","quantity":1}]}}'

# updated_at from the quote you just read or saved
curl -X PATCH "$PULSE_CONNECT_BASE/quotes/5532" \
-H "X-API-Key: $PULSE_CONNECT_KEY" -H "Idempotency-Key: web-quote-77121-v2" \
-H "Content-Type: application/json" \
-d '{"expected_updated_at":"2026-10-22T15:04:05.123Z","pricing":{"service_type_id":"12","frequency_id":"32","rooms":[{"room_id":"5","quantity":3}],"answers":[{"question_id":"9","answer_ids":["91"]}]}}'

Can I text or email this customer, and how do I record their choice? Every customer read carries consent, four separate facts, each {status, source, updated_at}:

FactstatusBuilt from
sms_opt_inopted_in or not_setConsent to texts (appointment and other transactional messages), on the customer's first cell phone.
marketing_opt_inopted_in or not_setConsent to marketing by text and email. One per customer.
sms_opt_outopted_out or not_setAny of the customer's phone numbers unsubscribed, including one who replied STOP.
email_opt_outopted_out or not_setAny of the customer's email addresses unsubscribed.
  • An opt-out stops its channel whatever the opt-ins say. Send a text only when sms_opt_out is not_set; a marketing text also needs marketing_opt_in opted_in. A marketing email needs marketing_opt_in opted_in and email_opt_out not_set.
  • source and updated_at are when and where an opt-in was given. For an opt-out, updated_at is when the unsubscribed contact last changed, because no opt-out time is stored, and source is null.

PATCH /customers/{customer_id}/consent takes sms_opt_in, marketing_opt_in, sms_opt_out and email_opt_out, each true or left out, and source (up to 100 characters).

  • An opt-in needs source, such as the form the customer ticked. sms_opt_in needs a cell phone on the customer.
  • sms_opt_out marks every phone number unsubscribed (cell, home, business and any other), email_opt_out every email address. The office sees the contact as Unsubscribed with an "Opt-out via Pulse Connect" note. A contact already unsubscribed keeps its note.
  • An opted-out customer can't be opted back in through the API: an opt-in on a channel they are opted out of is 409 contact_opted_out, and marketing_opt_in covers both channels. Only the customer texting START, or the office, can opt them back in. The same number written back by PATCH /customers/{customer_id} stays opted out.
  • false is 400 invalid_request: an opt-in is withdrawn with an opt-out. A request is all or nothing.
  • Email unsubscribes taken through a link in an email are held by the email provider and are never lifted by the API.
  • Consent changes send no webhook. Read the customer again before you send: its updated_at moves whenever consent changes, including an office change or a STOP.
curl -X PATCH "$PULSE_CONNECT_BASE/customers/1042/consent" \
-H "X-API-Key: $PULSE_CONNECT_KEY" -H "Idempotency-Key: consent-1042-sms" \
-H "Content-Type: application/json" \
-d '{"sms_opt_in":true,"marketing_opt_in":true,"source":"Spring web form"}'

Booking a saved quote​

POST /quotes/{quote_id}/book turns a saved quote into a confirmed first visit and the service behind it, at the saved price. The website booking recipe shows the full call order.

FieldRule
expected_updated_atThe quote's updated_at the customer accepted. A quote revised since is 409 version_conflict, and adding a note usually counts as a revision.
customer_id, home_idA customer in your branch and one of its homes. A home of another customer is 400 invalid_request naming home_id.
date, arrival_window_start, team_idOne slot from GET /availability: its date, a start (HH:mm, scheduling day) inside its window, and its team.
  • Price. The saved quote's base, minutes, rate and modifiers become the service's fees exactly as saved. Every-visit modifiers and promotions go on the service; first-visit ones on the first visit only.
  • Schedule. The slot is checked again for the first visit's full length, add-on time included, divided across the team's scheduled technicians; a slot no longer offered for that team is 409 slot_unavailable and nothing is booked. A team with more than one technician may take shorter slots than first_visit_minutes finds. A slot found at that length with the home's own zone_id fits, unless the office set the team's size below one technician, which stretches the visit past first_visit_minutes and can answer 409 slot_unavailable. Availability read with a different zone can offer a start the booking refuses the same way. One-time books one visit. Weekly and every-two, -three or -four-weeks book the first visit and the following year of visits on the same team and start; only the first is checked. The first visit gets the quote's job tags.
  • Rooms. For a by-room service, the counted rooms become the rooms the service cleans, reusing the home's rooms where it can.
  • Records. Each booking adds one unsigned service agreement. The quote is marked Booked and linked to the customer and home; its attribution is unchanged.
  • No payment. Booking charges nothing and saves no card.
  • Webhooks. A booking sends lead.converted once; a replay sends nothing. It sends no job.scheduled.
Statuserror_codeWhen
409quote_not_pricedThe quote has no saved pricing.
409quote_expiredPast expires_at. Recalculate, revise, and book with the new updated_at.
409quote_not_bookableThe service type or frequency is no longer offered online, or the frequency has no fixed rotation (bookable: false in GET /quotes/config). Revise the quote.
409quote_convertedAlready booked.
409slot_unavailableThe start is not inside a window offered for that team on that date.

A past start is 400 invalid_request naming arrival_window_start. The response is the booked quote, the new service_set_id, and the confirmed first_job.

Notes​

POST /customers/{customer_id}/notes, POST /quotes/{quote_id}/notes and POST /technicians/{technician_id}/notes append one internal note to that record's history. Each needs its own scope; customers.write does not grant customer notes.

  • The body is {"text": "..."}: plain text, 1–5,000 characters, line breaks kept. It becomes a closed ordinary note, as if typed in Pulse with no reminder; it never reaches the ticket board.
  • Anything that could render as HTML is refused with 400 invalid_request: a < followed by a letter, /, ! or ?, even when written as an escaped character reference. 1 < 2, a <3 b and R&D are fine.
  • A note moves a lead forward one stage, as a note added in MaidCentral V2 does: Needs Contacted becomes Contacted, and Quoted becomes Follow-up. Other statuses stay as they are. The API key is recorded as the author.
  • A note normally moves the quote's updated_at, whether it's added here, in Pulse or in MaidCentral V2. Read the quote again before a PATCH or a booking.
  • The response is the note's id, the record's id and created_at. Notes can't be read back through Connect.

POST /quotes/{quote_id}/payment-links mints a single-use hosted page where the customer saves a card. Nothing is charged. On Authorize.Net the processor runs a $0.01 verification that is never captured, as for any card saved in Pulse.

  • Target is fixed at mint. Before booking, the card is saved to the prospect; after booking, to the customer (customer_id says which). Booking doesn't move a card saved on an earlier link, so mint a new link after booking.
  • You deliver the url. Connect sends no email or SMS. Treat the URL as a secret until it is used or expires.
  • expiry_days (1–30) is optional; the default is the branch's setting, 7 days unless MaidCentral set another.
  • A replay returns the same link; two different keys mint two links.
  • Saving a card never books, and booking never saves a card. Saved cards and links can't be read through Connect.
  • payment_method.saved goes only to the minting key's subscriptions.

Reports​

The office reports, computed by the calculations the Pulse screens use. Each successful call is one report run; all seven share the report rate class.

Method and pathScopeFiltersOffice report
GET /reports/sales-kpisreports.readdate_from, date_to, date_basis, as_of_dateSales KPIs
GET /reports/operations-kpisreports.readas_of_date, weeksCore KPIs
GET /reports/week-in-reviewreports.readas_of_date, weeksWeek in Review
GET /reports/schedule-efficiencyreports.readdate, team_idsSchedule Efficiency
GET /reports/open-invoicesreports.readdate_from, date_toOpen Invoices
GET /reports/cancelled-customersreports.readdate_from, date_toCancelled Customers
GET /reports/employee-statsreports.readdate_from, date_to, include_terminated, include_productivityEmployee Stats

Common rules.

  • No report pages; a response over the size limit is 413 result_too_large. Ranges are inclusive and at most 366 days.
  • Read data_available before any number. When it is false the counts are placeholders, not measurements.
  • Ids join to the other reads: customer_id, job_ids, technician_id, team ids. The holding teams for unassigned work (Not Scheduled, "0", and Needs to be placed) appear in reports but not in GET /teams; leave them out of team totals. team_ids refuses 0.
  • No report publishes payroll figures, pay rates, photos or office notes. Employee Stats is the only one that names people.

Operations KPIs and Week in Review read payroll weeks. as_of_date (default today) picks the newest weeks starting on or before it; weeks (1–104) sets how many, default 26 and 52. data_available is false when the branch has no payroll weeks, or (Week in Review) no week with completed statistics on or before as_of_date.

Operations KPI keys (value rounded to decimal_places; unit is number, currency or percent, where 12.5 means 12.5%):

KeyMeaning
avg-bill-per-cleaningRevenue ÷ jobs.
avg-quotes-per-weekQuotes ÷ weeks.
avg-close-rateRecurring sales ÷ quotes.
cleanings-per-homeCleanings per recurring home per month.
revenue-per-techRevenue per technician day worked.
rate-increaseChange in revenue per allowed hour, oldest week to newest.
customer-attritionMonthly churn of recurring homes.
skipped-cleaningsSkipped and locked-out cleanings as a share of completed, skipped and locked-out.
absentee-rateExcused, unexcused and time-off days as a share of all technician days.
tech-turnoverAnnualized technician turnover.
techs-hiredTechnicians hired per month.
recurring-homesRecurring homes in the oldest week.
fte-techsAverage full-time-equivalent cleaning technicians (days worked ÷ weeks ÷ 5).

Direct Payroll % to Revenue and Monthly Cost Less Direct Payroll are not published.

Week in Review reports the newest week with completed statistics. revenue, cleaning_hourly_rate (revenue ÷ job hours), recurring_customer_count and quality_score (average scorecard score as the screen shows it; 0 with none) each carry value, average (over weeks_compared, including the selected week; 0 with one week), direction (up, down, none) and trend (positive, negative, neutral). job_count_other is closed completed jobs that are not weekly, biweekly or other recurring. emails_sent and texts_sent include quote messages; total_automations is emails, texts, notifications and scorecards together.

Sales KPIs. date_from and date_to are required. date_basis is lead_created (default: leads created in the range, followed to as_of_date, which must not be before date_to) or event (activity dated in the range; as_of_date not accepted). total_leads is quotes created in the range; quotes_generated is quotes prepared (for those leads, or inside the range under event); booked_leads is leads that booked. conversion_rate_percent is booked_leads ÷ quotes_generated × 100, one decimal, 0 with no quotes. loss_revenue is positive and net_revenue = sales_revenue − loss_revenue; by_source splits the counts by lead source, highest revenue first. Revenue is weekly: a biweekly rate halved, a monthly rate divided by four, a one-time service at full amount.

Schedule Efficiency. date is required, one scheduling day. efficiency_percent is job hours ÷ clock hours (0–100); efficiency_target_percent is the branch goal, or null. Times are local HH:mm:ss, and start_time/end_time are null for a team with no planned day. job_count counts job stops; stop_count counts every stop, including start, end and events. optimization_pending is true while a recent change holds the optimized route back. Distances and drive minutes come from the route recorded for the team and day; a read never computes one. Check routing_evidence:

routing_evidenceMeaning
recordedA current route is recorded. Only these distances are measurements.
staleA route is recorded but the schedule changed since, or an optimized route is still owed.
missingStops to route and no recorded route; distances read as stored, usually 0.
not_routedNo jobs or events that day.

Optimized figures and savings are null without an optimized route; a negative saving means the optimized order is longer.

Open Invoices reads the branch's QuickBooks Online company live: invoices with a balance dated in the range that have a Pulse job. Overdue means a due_date before today on the scheduling day. At most 1,000 invoices are read, and is_truncated is true when 1,000 were reached; narrow the range. Without a QuickBooks connection the report answers 200 with data_available: false and unavailable_reason accounting_not_connected or accounting_blocked. A failed QuickBooks read on a connected branch is 503.

Cancelled Customers lists Cancel Service tickets opened in the range. tenure_days runs from the service's first closed job to the cancellation. tenure_bucket groups it: under 84 days, 84–167, 168–335, 336–671, 672–1,679, and 1,680 or more. cancelled_within_60_days is true for 1–59 days. average_scorecard is on the 1–5 scale, or null. by_team counts by default team with percent (0–100) and can include the holding teams.

Employee Stats. Send date_from and date_to together or neither (default: the last 180 days). Technicians with no termination date are listed; include_terminated=true adds those whose employment overlapped the range, including anyone serving notice. is_terminated is true whenever a termination date is recorded, including a future one. Open clock-ins and timesheets add no hours; their jobs still count.

FieldMeaning
days_absentUnexcused + excused + time-off days. Tardies the branch converts to unexcused days are in days_unexcused, not here.
attendance_score1 − (days_absent + 0.5 × days_late) ÷ days_worked, 0–1; null with no days worked.
efficiencyjob_hours ÷ clock_hours, at most 1; 0 with no clock hours.
non_productive_time_factor1 − (actual − planned non-productive time) ÷ planned clock time. Above 1 used less than planned. Null without planned time.
scorecard_scoreAverage rating on a 0–4 scale (4 = all five stars), or null. scorecard_response_rate is ratings ÷ jobs.
productivityWith include_productivity=true: allowed ÷ actual labor hours per service type (above 1 is faster). Otherwise null.