Skip to main content

Webhooks

Pulse Connect sends a signed JSON POST to a URL you own when something happens on your key's branch. Use webhooks instead of polling.

Webhooks need the webhooks capability, which MaidCentral enables per branch. Every management route needs the webhooks.manage scope, and subscribing to an event also needs that event's scope below.

What webhooks don't cover​

  • Changes made in MaidCentral V2 (the classic app) send no webhooks, for any event. Re-read periodically to catch them.
  • Delivery starts with your first subscription. A branch's first subscription receives matching events from up to 48 hours before it was created; older events never arrive.
  • Adding an event later doesn't backfill it. Events of that type that occurred before you added it are not delivered.
  • Dead-lettered events can't be redelivered. Reconcile by re-reading the resource.

Event catalog​

EventResourceScope to subscribe
customer.createdcustomercustomers.read
customer.status_changedcustomercustomers.read
job.scheduledjobjobs.read
job.startedjobjobs.read
job.completedjobjobs.read
job.cancelledjobjobs.read
job.rescheduledjobjobs.read
job.on_the_wayjobjobs.read
service.cancelledserviceservices.read
lead.createdleadleads.read
lead.convertedleadquotes.read
lead.closedleadleads.read
quote.revisedquotequotes.read
payment_method.savedpayment_linkpayments.write
scorecard.submittedscorecardscorecards.read
invoice.createdinvoicebilling.read
invoice.paidinvoicebilling.read
payment.receivedpaymentbilling.read
connect.test—Sent only by POST /webhooks/{id}/test; never subscribed to

GET /webhook-events lists the events your key can subscribe to.

Jobs.

  • job.cancelled means the visit will not happen, and data.reason says why: "cancelled", "skipped", "lock_out" (the team could not get in) or "bumped" (the job was bumped in place, keeping its date).
  • A move through Connect (POST /jobs/{job_id}/move) changes the job in place and sends only job.rescheduled.
  • A move made in Pulse by the office or the MaidSense receptionist replaces the job: you receive job.scheduled for the new job and job.rescheduled for the old one, with different resource.id values. The old id stops being live.
  • A bump that replaces the job works like a replacing move: job.scheduled for the new job and job.rescheduled for the old one. A bump that keeps the job and its date sends job.cancelled with data.reason "bumped"; one that keeps the job but changes its date sends only job.rescheduled.
  • job.scheduled data.scheduled_start_time is the local time of day on scheduled_date, HH:mm:ss, and 00:00:00 when the visit has no start time.
  • Booking a quote sends lead.converted, not job.scheduled.
  • job.on_the_way fires when a technician or the office marks a job on the way, even when no message goes out: a contact normally gets the on-the-way message once per job (an updated arrival notice the office sends goes to every contact again), and a customer with no on-the-way contact gets none. A second tap while a send for the same job is still in progress fires nothing.

Scorecards. scorecard.submitted fires when a customer rates a visit in Pulse: from the office, the customer portal, or the emailed scorecard link. resource.id is the id GET /scorecards lists, and data carries job_id, customer_id and rating (1 to 5). Comments are never sent. Ratings recorded in V2 don't fire it, and excluding a rating later sends nothing.

Leads and quotes.

  • lead.created fires for every new lead captured in Pulse: the office creating a lead or saving a quote for a non-customer, Pulse's web quoting widget and lead forms (a repeat submission fires again), the MaidSense receptionist, and POST /leads or POST /quotes from any integration, including yours. Data imports don't fire it. resource.id is the lead id.
  • lead.converted fires when a quote not yet linked to a customer is booked: by POST /quotes/{id}/book (always, since a linked quote can't be booked), by the office, by the customer on Pulse's online quote, or by the MaidSense receptionist. data.booked_amount is the new service's per-visit price in the branch currency, or null.
  • lead.closed fires when the office or an integration closes a lead as lost in Pulse, including your own POST /leads/{lead_id}/close. resource.id is the lead id and data is empty; read why with GET /leads/{lead_id}, which returns closed_reason. Closing an already-closed lead sends nothing, and leads closed in the older MaidCentral app don't fire it.
  • quote.revised fires for a PATCH /quotes/{id} from any integration and for a MaidSense re-quote, and may fire for office edits. data.revised_total is not stored and is null for office edits: re-read the quote for current pricing.

Payment methods. payment_method.saved fires when a customer saves a card on a link minted by your key, and reaches only that key's subscriptions. A declined card sends nothing. A subscription another key has taken over stops receiving saves on the old key's links.

Billing.

  • invoice.created fires when office invoicing creates an invoice in Pulse, including a grouped invoice. resource.id is the invoice id GET /invoices returns; data carries customer_id and total. Invoices created in V2, by a data import, by POST /accounting/invoices or imported from QuickBooks send nothing.
  • invoice.paid fires when a payment or credit recorded in Pulse pays an invoice in full; data carries customer_id and total. A payment recorded directly in QuickBooks or V2, or a paid invoice payment link, sends nothing.
  • payment.received fires once per payment when Pulse takes a card or bank charge or the office records a cash, check or other payment. resource.id is the payment id GET /payments returns; data carries customer_id and amount, never card details. Charges taken in V2, and a charge Pulse recovers after an interrupted attempt, send nothing.

job.on_the_way, lead.converted, lead.closed, quote.revised, payment_method.saved, invoice.created, invoice.paid, payment.received and scorecard.submitted reach an existing subscription only after you add them to its event_types.

Payload​

Every delivery has the same envelope. The data object holds a small, fixed set of fields per event type and never carries names, addresses, phone numbers, email addresses, notes, coordinates, or payment details. The only amounts are lead.converted's booked_amount, quote.revised's revised_total, the invoice events' total and payment.received's amount. Follow the resource to read those with your key's own scopes.

{
"id": "evt_9f1c2b8a4d6e4f0b8c1a2d3e4f5a6b7c",
"type": "job.completed",
"version": 1,
"occurred_at": "2026-09-04T11:30:00.0000000Z",
"service_company_id": "42",
"resource": { "type": "job", "id": "918273" },
"data": { "completed_at": "2026-09-04T11:30:00.0000000Z" }
}

The API reference lists every event's exact data fields under Webhook events.

Eventdata fields
customer.createdsource: manual, quick_book, lead_conversion, connect_api, online_quote or unknown
customer.status_changedprevious_status, status
job.scheduledscheduled_date, scheduled_start_time
job.startedstarted_at
job.completedcompleted_at
job.cancelledcancelled_date, reason
job.rescheduledprevious_scheduled_date, scheduled_date
service.cancelledeffective_date
lead.created, lead.closed, connect.testnone (data is {})
lead.convertedcustomer_id, service_set_id, first_job_id, first_job_date, booked_amount
quote.revisedrevised_total
payment_method.savedquote_id, customer_id
job.on_the_waycustomer_id, scheduled_date
scorecard.submittedjob_id, customer_id, rating
invoice.created, invoice.paidcustomer_id, total
payment.receivedcustomer_id, amount

New source values may be added, so treat one you don't recognise as unknown.

A field with no value is left out, except on customer.created, lead.converted, quote.revised, payment_method.saved, job.on_the_way, scorecard.submitted and the three billing events, which always carry every field (null when unknown).

All timestamps are UTC.

Headers​

HeaderMeaning
Pulse-Connect-Event-IdSame on every retry of one event
Pulse-Connect-Delivery-IdOne subscription's copy of the event
Pulse-Connect-AttemptAttempt number, from 1
Pulse-Connect-TimestampUnix seconds; part of the signed value
Pulse-Connect-Signaturev1=<hex>, or two comma-separated values during a secret rotation

Verifying the signature​

Compute HMAC-SHA256(secret, "<timestamp>.<raw body>") over the raw body bytes, before any JSON parsing, and compare it in constant time with each value in the header. Accept the request if any matches; that is what makes rotation seamless. Reject timestamps more than five minutes old.

import hashlib, hmac, time

def verify(secret, headers, raw_body):
timestamp = headers["Pulse-Connect-Timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(
hmac.compare_digest(expected, part.removeprefix("v1="))
for part in headers["Pulse-Connect-Signature"].split(",")
)
const crypto = require("node:crypto");

function verify(secret, headers, rawBody /* Buffer */) {
const timestamp = headers["pulse-connect-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest();
return headers["pulse-connect-signature"].split(",").some((part) => {
const given = Buffer.from(part.trim().replace(/^v1=/, ""), "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}

Responding and retries​

Return any 2xx as soon as you have stored the event, then do the work. Each attempt has 10 seconds, including DNS lookup. Redirects are not followed.

Your responseWhat happens
2xxDelivered
408, 429, 5xx, 3xx, DNS failure, timeout, connection errorRetried after 1m, 5m, 30m, 2h and 24h, then dead-lettered
Any other 4xxDead-lettered immediately

Deliveries are at least once and unordered. Deduplicate on Pulse-Connect-Event-Id and order by occurred_at.

Pauses​

A paused subscription keeps queueing events; nothing is sent until you resume it, then the whole backlog arrives at once and in no order, so size your endpoint for a burst. Deliveries older than 30 days expire while paused. GET /webhooks/{id} shows status and paused_reason:

paused_reasonCauseTo resume
operatorSomeone sent PATCH /webhooks/{id} {"paused": true}{"paused": false} from any key with the scopes
autoTen consecutive dead lettersFix the endpoint, then {"paused": false}
credentialThe key the subscription is checked against was revoked, expired, or lost a scope one of its events needs{"paused": false} from a key holding every subscribed event's scope

Every subscription is checked against one key: the one that created it, or that last resumed it or added an event to it. Resuming or adding events moves the subscription onto the key you use, which must hold every subscribed event's scope (otherwise 400); adding events also lifts a credential pause. So revoking a key during rotation pauses its subscriptions: resume them with the new key. Reinstating the old key doesn't. POST /webhooks/{id}/test is refused with 400 while paused.

Managing subscriptions​

MethodPathWhat it does
GET/webhook-eventsEvents this key may subscribe to
POST/webhooksCreate a subscription; returns the secret once
GET/webhooksList subscriptions
GET/webhooks/{id}Read one
PATCH/webhooks/{id}Change URL, description, events or pause state
DELETE/webhooks/{id}Delete it and cancel its queue
POST/webhooks/{id}/rotate-secretIssue a new secret with a 24-hour overlap
POST/webhooks/{id}/testQueue a connect.test event
GET/webhooks/{id}/deliveries30 days of delivery attempts
  • Create, update and rotate need an Idempotency-Key. Delete and test don't: a repeated test sends twice.
  • DELETE answers 204, including for an id that is already deleted or never existed. It cancels the pending queue before it returns, which can take a while for a large backlog; raise your client timeout, and repeat it if it is cut short.
  • A deleted subscription answers 404 on every route except /deliveries, which stays readable.
  • A branch holds at most max_webhook_subscriptions live subscriptions (from /capabilities). Past it, POST /webhooks is 409 webhook_subscription_limit_reached.
  • Destination URLs must be public HTTPS. Raw IPs, private or loopback addresses, and URLs with credentials are refused, at creation and before every delivery; every DNS answer must be public.

Rotating a secret​

  1. POST /webhooks/{id}/rotate-secret and store the new secret beside the old one.
  2. For 24 hours both secrets sign every delivery; accept either.
  3. Then drop the old secret.

Rotating again within the overlap ends the older overlap immediately. Secrets are shown only at creation and rotation; if you lose one, rotate. A replay of a create or rotate request returns the same secret for 24 hours, so protect those idempotency keys like the secret.

Delivery history​

GET /webhooks/{id}/deliveries shows state, attempt count, HTTP status and last_error_category, never bodies or headers.

last_error_categoryMeaningRetried
http_rejectedYour endpoint answered 4xx (not 408 or 429)No
http_retryable408, 429, 5xx or a redirectYes
dnsDNS lookup failed or returned nothingYes
timeoutNo response headers within 10 secondsYes
networkThe connection failed or droppedYes
socket_<reason>The connection could not be opened, for example socket_connectionrefusedYes
destination_rejectedThe URL or its addresses broke the destination rulesNo
client_disposed, or any value you don't recogniseA fault on our sideYes

Retention​

Delivery history and events are kept 30 days. Replaced secrets are deleted 7 days after they expire; deleting a subscription expires its secret at once. A deleted subscription is removed 30 days after deletion.