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
| Event | Resource | Scope to subscribe |
|---|---|---|
customer.created | customer | customers.read |
customer.status_changed | customer | customers.read |
job.scheduled | job | jobs.read |
job.started | job | jobs.read |
job.completed | job | jobs.read |
job.cancelled | job | jobs.read |
job.rescheduled | job | jobs.read |
job.on_the_way | job | jobs.read |
service.cancelled | service | services.read |
lead.created | lead | leads.read |
lead.converted | lead | quotes.read |
lead.closed | lead | leads.read |
quote.revised | quote | quotes.read |
payment_method.saved | payment_link | payments.write |
scorecard.submitted | scorecard | scorecards.read |
invoice.created | invoice | billing.read |
invoice.paid | invoice | billing.read |
payment.received | payment | billing.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.cancelledmeans the visit will not happen, anddata.reasonsays 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 onlyjob.rescheduled. - A move made in Pulse by the office or the MaidSense receptionist replaces the job: you receive
job.scheduledfor the new job andjob.rescheduledfor the old one, with differentresource.idvalues. The old id stops being live. - A bump that replaces the job works like a replacing move:
job.scheduledfor the new job andjob.rescheduledfor the old one. A bump that keeps the job and its date sendsjob.cancelledwithdata.reason"bumped"; one that keeps the job but changes its date sends onlyjob.rescheduled. job.scheduleddata.scheduled_start_timeis the local time of day onscheduled_date,HH:mm:ss, and00:00:00when the visit has no start time.- Booking a quote sends
lead.converted, notjob.scheduled. job.on_the_wayfires 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.createdfires 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, andPOST /leadsorPOST /quotesfrom any integration, including yours. Data imports don't fire it.resource.idis the lead id.lead.convertedfires when a quote not yet linked to a customer is booked: byPOST /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_amountis the new service's per-visit price in the branch currency, ornull.lead.closedfires when the office or an integration closes a lead as lost in Pulse, including your ownPOST /leads/{lead_id}/close.resource.idis the lead id anddatais empty; read why withGET /leads/{lead_id}, which returnsclosed_reason. Closing an already-closed lead sends nothing, and leads closed in the older MaidCentral app don't fire it.quote.revisedfires for aPATCH /quotes/{id}from any integration and for a MaidSense re-quote, and may fire for office edits.data.revised_totalis not stored and isnullfor 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.createdfires when office invoicing creates an invoice in Pulse, including a grouped invoice.resource.idis the invoice idGET /invoicesreturns;datacarriescustomer_idandtotal. Invoices created in V2, by a data import, byPOST /accounting/invoicesor imported from QuickBooks send nothing.invoice.paidfires when a payment or credit recorded in Pulse pays an invoice in full;datacarriescustomer_idandtotal. A payment recorded directly in QuickBooks or V2, or a paid invoice payment link, sends nothing.payment.receivedfires once per payment when Pulse takes a card or bank charge or the office records a cash, check or other payment.resource.idis the payment idGET /paymentsreturns;datacarriescustomer_idandamount, 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.
| Event | data fields |
|---|---|
customer.created | source: manual, quick_book, lead_conversion, connect_api, online_quote or unknown |
customer.status_changed | previous_status, status |
job.scheduled | scheduled_date, scheduled_start_time |
job.started | started_at |
job.completed | completed_at |
job.cancelled | cancelled_date, reason |
job.rescheduled | previous_scheduled_date, scheduled_date |
service.cancelled | effective_date |
lead.created, lead.closed, connect.test | none (data is {}) |
lead.converted | customer_id, service_set_id, first_job_id, first_job_date, booked_amount |
quote.revised | revised_total |
payment_method.saved | quote_id, customer_id |
job.on_the_way | customer_id, scheduled_date |
scorecard.submitted | job_id, customer_id, rating |
invoice.created, invoice.paid | customer_id, total |
payment.received | customer_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
| Header | Meaning |
|---|---|
Pulse-Connect-Event-Id | Same on every retry of one event |
Pulse-Connect-Delivery-Id | One subscription's copy of the event |
Pulse-Connect-Attempt | Attempt number, from 1 |
Pulse-Connect-Timestamp | Unix seconds; part of the signed value |
Pulse-Connect-Signature | v1=<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 response | What happens |
|---|---|
2xx | Delivered |
408, 429, 5xx, 3xx, DNS failure, timeout, connection error | Retried after 1m, 5m, 30m, 2h and 24h, then dead-lettered |
Any other 4xx | Dead-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_reason | Cause | To resume |
|---|---|---|
operator | Someone sent PATCH /webhooks/{id} {"paused": true} | {"paused": false} from any key with the scopes |
auto | Ten consecutive dead letters | Fix the endpoint, then {"paused": false} |
credential | The 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
| Method | Path | What it does |
|---|---|---|
GET | /webhook-events | Events this key may subscribe to |
POST | /webhooks | Create a subscription; returns the secret once |
GET | /webhooks | List 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-secret | Issue a new secret with a 24-hour overlap |
POST | /webhooks/{id}/test | Queue a connect.test event |
GET | /webhooks/{id}/deliveries | 30 days of delivery attempts |
- Create, update and rotate need an
Idempotency-Key. Delete and test don't: a repeated test sends twice. DELETEanswers204, 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
404on every route except/deliveries, which stays readable. - A branch holds at most
max_webhook_subscriptionslive subscriptions (from/capabilities). Past it,POST /webhooksis409 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
POST /webhooks/{id}/rotate-secretand store the new secret beside the old one.- For 24 hours both secrets sign every delivery; accept either.
- 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_category | Meaning | Retried |
|---|---|---|
http_rejected | Your endpoint answered 4xx (not 408 or 429) | No |
http_retryable | 408, 429, 5xx or a redirect | Yes |
dns | DNS lookup failed or returned nothing | Yes |
timeout | No response headers within 10 seconds | Yes |
network | The connection failed or dropped | Yes |
socket_<reason> | The connection could not be opened, for example socket_connectionrefused | Yes |
destination_rejected | The URL or its addresses broke the destination rules | No |
client_disposed, or any value you don't recognise | A fault on our side | Yes |
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.