Documentation
Webhooks
Send bookings, abandoned carts, leads and chat handoffs to your CRM in real time
Webhooks send PestBooker events to any URL you choose — your CRM, Zapier, a data warehouse — the moment they happen. Each event is a signed JSON POST. Booking events carry the customer, their plan and price, the office they routed to (with its Field Routes office ID), and where they came from (UTMs and ad click IDs); you can also get out-of-area leads and chats the AI assistant hands to your team. Retries are built in, so a brief outage on your side doesn’t lose events. Fields are only ever added, never renamed or removed, so a mapping you build today keeps working.
Requirements
- Admin access to your PestBooker organization
- An HTTPS endpoint that accepts a JSON POST (a Zapier "Catch Hook" works)
Choose your events
- Go to Dashboard → Integrations → Webhooks and turn on the events you want. Each event can go to its own URL.
- Abandoned cart (cart.abandoned): Fires after the delay you choose if the customer still hasn’t finished booking. Independent of staff email timing.
- Completed sale (sale.completed): Fires when payment succeeds, including any promo or referral code. On plans that require a signed agreement it fires at payment, before signing.
- Unsigned contract (contract.unsigned): Fires ~4 hours after checkout when a signature-gated booking is still unsigned — same moment as the “Awaiting signature” staff email.
- Signed contract (contract.signed): Fires when a customer signs a signature-gated booking and its FieldRoutes appointment is created, with the appointment ID.
- Out-of-service lead (lead.out_of_service): Fires when someone outside your service area leaves their contact details — same moment as the “Out-of-Service Lead” staff email.
- Conversation handed off (conversation.handoff): Fires when the AI assistant hands a chat or text conversation to your team — same moment as the “Chatbot Handoff” staff email.
Send a test event
- Save your URLs first, then pick a Test event and press Send test event. It goes to that event’s saved URL. Once you have leads, you can test with one of your recent leads instead of sample data.
- Test events have the same shape as live ones, so you can finish mapping fields before any real booking comes in.
Verify the signature
- An admin can reveal and copy your signing secret on Integrations → Webhooks → Signing secret. Keep it on your server.
- Every request carries X-PestBooker-Event (the event name) and X-PestBooker-Signature (the hex HMAC-SHA256 of the raw request body, keyed with your signing secret).
- Compute the signature over the body exactly as received, before parsing it, and reject the request if it does not match.
Map the payload
- Answer with any 2xx status to mark the event delivered. Answer quickly: acknowledge the request first, then do any slow work.
- Integrations → Webhooks → Deliveries shows your recent deliveries and whether your endpoint accepted each one, and each event on Endpoints shows how its latest delivery went. An admin can resend a delivery — handy after you fix your endpoint. It goes to the event’s current URL, signed with your current secret. A delivery we’re still trying automatically, or an abandoned cart whose customer has since booked, can’t be resent.
- To tie a customer’s events together, use data.leadId where it is present, and fall back to email or phone: the lead ID can be null, and a customer who comes back in a new session can get a new one.
Payload reference
Every event is a JSON POST with the fields below. We only ever add fields, so a mapping you build today keeps working. Because of retries, the same event can occasionally arrive more than once. Match on the event’s IDs (listed with each event in the payload reference) so a repeat doesn’t create a duplicate.
Every event
| Field | Type | Description |
|---|---|---|
event | string | The event name (also the X-PestBooker-Event header). |
companySlug | string | Your PestBooker account slug. |
occurredAt | string | When the event was first sent (ISO 8601). It stays the same when the event arrives again — whether we tried again automatically or an admin pressed Resend in Integrations → Webhooks. |
test | boolean (optional) | true on test events (Send test event in Integrations → Webhooks); absent on real events. |
data | object | The event’s fields, below. |
Abandoned cart cart.abandonedShowHide
Fires once per lead, after the delay you set (5–60 minutes), if the customer gave their contact details but hasn’t booked. If a send is delayed on our side, it still goes out — retries are built in — but never once the customer has booked. To skip duplicates, match on data.leadId (or the customer’s email when leadId is null).
| Field | Type | Description |
|---|---|---|
data.customer | object | The customer record as the booking form submitted it. It can carry more than the fields below (UTMs, promo codes) — use attribution for the marketing source. |
data.customer.customerID | string (optional) | FieldRoutes customer ID. |
data.customer.fname | string | First name. |
data.customer.lname | string | Last name. |
data.customer.email | string | Email. |
data.customer.phone1 | string | Phone (E.164). |
data.customer.address | string | Service street address. |
data.customer.city | string | City. |
data.customer.state | string | State. |
data.customer.zip | string | ZIP code. |
data.customer.notes | string (optional) | Notes from the form. |
data.resumeUrl | string | Link that reopens the booking where they left off ("" when unavailable). |
data.pests | string[] | Pests the customer selected. |
data.quote | object | null | The plan and price the customer was shown: planId, planName, cadence, treatmentsPerYear, initialPrice, originalInitialPrice, recurringPrice, recurringUnit, addOns[], recurringTotal, discountCode, referralCode, summary, quotedAt. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.lastStep | string | null | Furthest step reached: "contact" (contact details and address), "plan", "appointment", or "payment" (a payment was attempted). null when unknown. |
data.office | object | null | The office the customer was routed to (by ZIP). null for a single-office account, or when it can’t be determined. |
data.office.id | string | PestBooker office ID. |
data.office.name | string | Office name as set in PestBooker. |
data.office.fieldRoutesOfficeID | number | The FieldRoutes office ID. |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
Sample
{
"event": "cart.abandoned",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"customer": {
"customerID": "163318",
"fname": "Test",
"lname": "Customer",
"email": "test@example.com",
"phone1": "+15555550123",
"address": "123 Test St",
"city": "San Diego",
"state": "CA",
"zip": "92129",
"notes": "Pests to focus on: Ants, Roaches",
"gclid": "TEST-GCLID-VALUE"
},
"resumeUrl": "https://getpestcontrol.services/resume/test",
"pests": [
"Ants",
"Roaches"
],
"quote": {
"planId": "quarterly",
"planName": "Quarterly Pest Control",
"cadence": "4 visits/year",
"treatmentsPerYear": 4,
"initialPrice": 149,
"originalInitialPrice": 199,
"recurringPrice": 99,
"recurringUnit": "visit",
"addOns": [
{
"label": "Mice and Rat Warranty",
"amount": 30,
"unit": "service"
}
],
"recurringTotal": 129,
"discountCode": "SAVE50",
"summary": "Quarterly Pest Control — $149 initial, then $129/visit, incl. Mice and Rat Warranty $30/service, code SAVE50",
"quotedAt": "2026-09-01T15:30:00.000Z"
},
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"lastStep": "plan",
"office": {
"id": "6f1c2b9e-0d4a-4c1e-9a53-2e8f7b1d0c11",
"name": "North County",
"fieldRoutesOfficeID": 2
},
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
}
}
}Completed sale sale.completedShowHide
Fires when payment succeeds — the FieldRoutes customer and subscription exist at that point. One per FieldRoutes customer: a returning customer’s later booking doesn’t send another. On plans that require a signed agreement, it still fires at payment: heldForSignature is true and the FieldRoutes appointment is created only after the customer signs (see contract.signed). To skip duplicates, match on data.customerID — it’s always present, and there’s one sale.completed per FieldRoutes customer.
| Field | Type | Description |
|---|---|---|
data.planType | string (optional) | Plan name. |
data.frequency | number (optional) | Services per year. |
data.value | number | Initial service charge. |
data.currency | string | Always "USD". |
data.transaction_id | string (optional) | FieldRoutes subscription ID. |
data.phone_number | string | Phone (E.164). |
data.email | string | Email. |
data.first_name | string | First name. |
data.last_name | string | Last name. |
data.source | string | "referral", "promo" or "direct". |
data.promo_code | string (optional) | Promo code used. |
data.promo_amount | number (optional) | Promo discount. |
data.referral_code | string (optional) | Referral code used. |
data.referrer_name | string (optional) | Who referred them. |
data.customerID | string | FieldRoutes customer ID. |
data.address | string | null | Service street address. |
data.city | string | null | City. |
data.state | string | null | State. |
data.zip | string | null | ZIP code. |
data.appointmentDate | string | null | Requested first-service date (YYYY-MM-DD). |
data.appointmentTime | string | null | "morning" or "afternoon". |
data.appointmentWindowStart | string | null | Arrival window start for that half-day ("08:00" or "12:00"). |
data.appointmentWindowEnd | string | null | Arrival window end ("12:00" or "17:00"). |
data.heldForSignature | boolean | null | true when the plan requires a signed agreement: paid, but the FieldRoutes appointment is created only after signing. null when we couldn’t tell. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.office | object | null | The office the customer was routed to (by ZIP). null for a single-office account, or when it can’t be determined. |
data.office.id | string | PestBooker office ID. |
data.office.name | string | Office name as set in PestBooker. |
data.office.fieldRoutesOfficeID | number | The FieldRoutes office ID. |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
Sample
{
"event": "sale.completed",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"planType": "Quarterly Pest Control",
"frequency": 4,
"value": 199,
"currency": "USD",
"transaction_id": "48291",
"phone_number": "+15555550123",
"email": "test@example.com",
"first_name": "Test",
"last_name": "Customer",
"source": "promo",
"promo_code": "SAVE20",
"promo_amount": 20,
"referral_code": "jane10",
"referrer_name": "Jane Smith",
"customerID": "163318",
"address": "123 Test St",
"city": "San Diego",
"state": "CA",
"zip": "92129",
"appointmentDate": "2026-09-03",
"appointmentTime": "morning",
"appointmentWindowStart": "08:00",
"appointmentWindowEnd": "12:00",
"heldForSignature": false,
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"office": {
"id": "6f1c2b9e-0d4a-4c1e-9a53-2e8f7b1d0c11",
"name": "North County",
"fieldRoutesOfficeID": 2
},
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
}
}
}Unsigned contract contract.unsignedShowHide
Fires once per booking when a plan that requires a signed agreement is still unsigned about 4 hours after payment. To skip duplicates, match on data.subscriptionID.
| Field | Type | Description |
|---|---|---|
data.customerID | string | FieldRoutes customer ID. |
data.subscriptionID | string | FieldRoutes subscription ID. |
data.planType | string (optional) | Plan name. |
data.frequency | number (optional) | Services per year. |
data.value | number (optional) | Initial service charge. |
data.currency | string | Always "USD". |
data.recurringCharge | number (optional) | Per-service recurring charge. |
data.agreementLengthMonths | number (optional) | Agreement length in months. |
data.quote | object | null | The plan and price the customer was shown: planId, planName, cadence, treatmentsPerYear, initialPrice, originalInitialPrice, recurringPrice, recurringUnit, addOns[], recurringTotal, discountCode, referralCode, summary, quotedAt. |
data.phone_number | string | Phone (E.164). |
data.email | string | Email. |
data.first_name | string | First name. |
data.last_name | string | Last name. |
data.address | string (optional) | Service street address. |
data.city | string (optional) | City. |
data.state | string (optional) | State. |
data.zip | string (optional) | ZIP code. |
data.appointmentDate | string (optional) | Requested first-service date (YYYY-MM-DD). |
data.appointmentTime | string (optional) | "morning" or "afternoon". |
data.appointmentWindowStart | string (optional) | Arrival window start for that half-day ("08:00" or "12:00"). |
data.appointmentWindowEnd | string (optional) | Arrival window end ("12:00" or "17:00"). |
data.contractUrl | string | Link to sign the agreement. |
data.paidAt | string | When they paid (ISO 8601). |
data.heldForSignature | boolean | Always true. |
data.gclid | string (optional) | Google Ads click ID. |
data.fbclid | string (optional) | Meta click ID. |
data.msclkid | string (optional) | Microsoft Ads click ID. |
data.wbraid | string (optional) | Google web-to-app ID. |
data.gbraid | string (optional) | Google app-to-web ID. |
data.rwg_token | string (optional) | Reserve with Google token. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.office | object | null | The office the customer was routed to (by ZIP). null for a single-office account, or when it can’t be determined. |
data.office.id | string | PestBooker office ID. |
data.office.name | string | Office name as set in PestBooker. |
data.office.fieldRoutesOfficeID | number | The FieldRoutes office ID. |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
Sample
{
"event": "contract.unsigned",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"customerID": "163318",
"subscriptionID": "48291",
"planType": "Quarterly Pest Control",
"frequency": 4,
"value": 149,
"recurringCharge": 99,
"agreementLengthMonths": 12,
"quote": {
"planId": "quarterly",
"planName": "Quarterly Pest Control",
"cadence": "4 visits/year",
"treatmentsPerYear": 4,
"initialPrice": 149,
"originalInitialPrice": 199,
"recurringPrice": 99,
"recurringUnit": "visit",
"addOns": [
{
"label": "Mice and Rat Warranty",
"amount": 30,
"unit": "service"
}
],
"recurringTotal": 129,
"discountCode": "SAVE50",
"summary": "Quarterly Pest Control — $149 initial, then $129/visit, incl. Mice and Rat Warranty $30/service, code SAVE50",
"quotedAt": "2026-09-01T15:30:00.000Z"
},
"currency": "USD",
"phone_number": "+15555550123",
"email": "test@example.com",
"first_name": "Test",
"last_name": "Customer",
"address": "123 Test St",
"city": "San Diego",
"state": "CA",
"zip": "92129",
"appointmentDate": "2026-09-03",
"appointmentTime": "morning",
"appointmentWindowStart": "08:00",
"appointmentWindowEnd": "12:00",
"contractUrl": "https://pestroutes.com/esign/example",
"paidAt": "2026-09-01T16:00:00.000Z",
"heldForSignature": true,
"gclid": "TEST-GCLID-VALUE",
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"office": {
"id": "6f1c2b9e-0d4a-4c1e-9a53-2e8f7b1d0c11",
"name": "North County",
"fieldRoutesOfficeID": 2
},
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
}
}
}Signed contract contract.signedShowHide
Fires once per booking when the customer signs a plan that requires a signed agreement and the FieldRoutes appointment is created — whether they sign right after paying or later from the email link. Same fields as contract.unsigned, plus the appointment. To skip duplicates, match on data.subscriptionID.
| Field | Type | Description |
|---|---|---|
data.appointmentID | string | null | FieldRoutes appointment ID of the first service, created at signing. |
data.appointmentCreatedAt | string | When the FieldRoutes appointment was created after signing (ISO 8601). Usually within moments of signing; if they sign from the email link, it can be up to about an hour later. |
data.heldForSignature | boolean | Always false — the agreement is signed and the visit is booked. |
data.customerID | string | FieldRoutes customer ID. |
data.subscriptionID | string | FieldRoutes subscription ID. |
data.planType | string (optional) | Plan name. |
data.frequency | number (optional) | Services per year. |
data.value | number (optional) | Initial service charge. |
data.currency | string | Always "USD". |
data.recurringCharge | number (optional) | Per-service recurring charge. |
data.agreementLengthMonths | number (optional) | Agreement length in months. |
data.quote | object | null | The plan and price the customer was shown: planId, planName, cadence, treatmentsPerYear, initialPrice, originalInitialPrice, recurringPrice, recurringUnit, addOns[], recurringTotal, discountCode, referralCode, summary, quotedAt. |
data.phone_number | string | Phone (E.164). |
data.email | string | Email. |
data.first_name | string | First name. |
data.last_name | string | Last name. |
data.address | string (optional) | Service street address. |
data.city | string (optional) | City. |
data.state | string (optional) | State. |
data.zip | string (optional) | ZIP code. |
data.contractUrl | string | Link to sign the agreement. |
data.paidAt | string | When they paid (ISO 8601). |
data.gclid | string (optional) | Google Ads click ID. |
data.fbclid | string (optional) | Meta click ID. |
data.msclkid | string (optional) | Microsoft Ads click ID. |
data.wbraid | string (optional) | Google web-to-app ID. |
data.gbraid | string (optional) | Google app-to-web ID. |
data.rwg_token | string (optional) | Reserve with Google token. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.office | object | null | The office the customer was routed to (by ZIP). null for a single-office account, or when it can’t be determined. |
data.office.id | string | PestBooker office ID. |
data.office.name | string | Office name as set in PestBooker. |
data.office.fieldRoutesOfficeID | number | The FieldRoutes office ID. |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
data.appointmentDate | string (optional) | Booked first-service date (YYYY-MM-DD). |
data.appointmentTime | string (optional) | "morning" or "afternoon". |
data.appointmentWindowStart | string (optional) | Arrival window start ("08:00" or "12:00"). |
data.appointmentWindowEnd | string (optional) | Arrival window end ("12:00" or "17:00"). |
Sample
{
"event": "contract.signed",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"customerID": "163318",
"subscriptionID": "48291",
"planType": "Quarterly Pest Control",
"frequency": 4,
"value": 149,
"recurringCharge": 99,
"agreementLengthMonths": 12,
"quote": {
"planId": "quarterly",
"planName": "Quarterly Pest Control",
"cadence": "4 visits/year",
"treatmentsPerYear": 4,
"initialPrice": 149,
"originalInitialPrice": 199,
"recurringPrice": 99,
"recurringUnit": "visit",
"addOns": [
{
"label": "Mice and Rat Warranty",
"amount": 30,
"unit": "service"
}
],
"recurringTotal": 129,
"discountCode": "SAVE50",
"summary": "Quarterly Pest Control — $149 initial, then $129/visit, incl. Mice and Rat Warranty $30/service, code SAVE50",
"quotedAt": "2026-09-01T15:30:00.000Z"
},
"currency": "USD",
"phone_number": "+15555550123",
"email": "test@example.com",
"first_name": "Test",
"last_name": "Customer",
"address": "123 Test St",
"city": "San Diego",
"state": "CA",
"zip": "92129",
"appointmentDate": "2026-09-03",
"appointmentTime": "morning",
"appointmentWindowStart": "08:00",
"appointmentWindowEnd": "12:00",
"contractUrl": "https://pestroutes.com/esign/example",
"paidAt": "2026-09-01T16:00:00.000Z",
"heldForSignature": false,
"gclid": "TEST-GCLID-VALUE",
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"office": {
"id": "6f1c2b9e-0d4a-4c1e-9a53-2e8f7b1d0c11",
"name": "North County",
"fieldRoutesOfficeID": 2
},
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
},
"appointmentID": "771204",
"appointmentCreatedAt": "2026-09-01T18:12:00.000Z"
}
}Out-of-service lead lead.out_of_serviceShowHide
Fires when someone whose address is outside every office’s service area leaves their contact details — on your booking page or through the ChatGPT booking app (see source) — at the same moment as the “Out-of-Service Lead” staff email. It also fires, with reason "office_unavailable", when the ZIP is in an area you serve but its office can’t be determined. No FieldRoutes customer is created, and office is always null. To skip duplicates, match on data.leadId (or the visitor’s email when leadId is null).
| Field | Type | Description |
|---|---|---|
data.customer | object | The visitor’s contact details and address as they entered them. |
data.customer.first_name | string | First name. |
data.customer.last_name | string | Last name. |
data.customer.email | string | Email. |
data.customer.phone_number | string | Phone (E.164). |
data.customer.address | string | Street address. |
data.customer.city | string | City. |
data.customer.state | string | State. |
data.customer.zip | string | ZIP code (outside your service area). |
data.pests | string[] | Pests the visitor selected; [] when none. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.office | null | Always null — no office matched (see reason). |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
data.source | string | "booking" (your booking page) or "chatgpt" (the ChatGPT booking app). |
data.reason | string | Why no office took the lead: "outside_service_area" (the address is outside every office’s area) or "office_unavailable" (the ZIP is in an area you serve, but its office couldn’t be determined — check your offices’ service areas; the visitor is still a lead). |
Sample
{
"event": "lead.out_of_service",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"customer": {
"first_name": "Test",
"last_name": "Customer",
"email": "test@example.com",
"phone_number": "+15555550123",
"address": "456 Sample Ave",
"city": "Yuma",
"state": "AZ",
"zip": "85364"
},
"pests": [
"Ants",
"Spiders"
],
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"office": null,
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
},
"source": "booking",
"reason": "outside_service_area"
}
}Conversation handed off conversation.handoffShowHide
Fires each time the AI assistant hands a conversation to your team — website chat, text messages, the checkout assistant or customer portal chat (see channel) — at the same moment as the “Chatbot Handoff” staff email. Portal handoffs come from existing customers. One conversation can be handed off more than once, so to skip duplicates of the same handoff, match on data.sessionId together with occurredAt.
| Field | Type | Description |
|---|---|---|
data.channel | string | "web" (website chat), "sms" (text message), "checkout" (the checkout assistant on your booking page) or "portal" (customer portal chat). |
data.reason | string | Why it was handed off, as a stable code — map on this: "ai_handoff" (the assistant reached the limit of what it may answer), "ai_unapproved_claim" (answering would have meant a claim the assistant isn’t approved to make), "customer_requested" (the customer asked for a person), "auto_escalated" (handed off automatically after the set number of messages) or "ai_disabled" (the assistant is turned off, so the conversation goes straight to your team). New codes may be added; treat one you don’t know as a general handoff. |
data.reasonDetail | string | The reason in words, for display (e.g. "Auto-escalated after 5 customer messages"). "" when there is none. |
data.customer | object | Who the conversation is with. A value we don’t know is "" (e.g. a texter who never gave an email). |
data.customer.name | string | Name, or "". |
data.customer.phone_number | string | Phone (E.164), or "". |
data.customer.email | string | Email, or "". |
data.customerMessage | string | The customer’s message that led to the handoff. |
data.aiReply | string | null | What the assistant replied as it handed off; null when it didn’t reply. |
data.conversationUrl | string | Link to the conversation in your PestBooker dashboard (sign-in required). |
data.sessionId | string | The conversation’s ID. A conversation handed off again later sends another event with the same sessionId. |
data.leadId | string | null | PestBooker lead ID. Usually the same across one customer’s events, but not guaranteed: a customer who comes back in a new session can get a new lead, and it can be null. Fall back to email or phone when matching. |
data.office | object | null | The office the conversation belongs to: the office whose number the customer texted, or the chat or portal customer’s office. null when unknown. |
data.office.id | string | PestBooker office ID. |
data.office.name | string | Office name as set in PestBooker. |
data.office.fieldRoutesOfficeID | number | The FieldRoutes office ID. |
data.attribution | object | Where the customer came from, as captured on the lead. Every key is always present; a value that wasn’t captured is null. |
data.attribution.utm_source | string | null | utm_source |
data.attribution.utm_medium | string | null | utm_medium |
data.attribution.utm_campaign | string | null | utm_campaign |
data.attribution.utm_content | string | null | utm_content |
data.attribution.utm_term | string | null | utm_term |
data.attribution.gclid | string | null | Google Ads click ID. |
data.attribution.fbclid | string | null | Meta (Facebook) click ID. |
data.attribution.msclkid | string | null | Microsoft Ads click ID. |
data.attribution.wbraid | string | null | Google web-to-app click ID. |
data.attribution.gbraid | string | null | Google app-to-web click ID. |
data.attribution.rwg_token | string | null | Reserve with Google conversion token. |
data.attribution.landing_page | string | null | The page the booking form was opened on. |
data.attribution.referrer | string | null | The outside site that sent the visitor (e.g. https://www.google.com/). |
Sample
{
"event": "conversation.handoff",
"companySlug": "your-company",
"occurredAt": "2026-09-01T16:00:00.000Z",
"data": {
"channel": "web",
"reason": "customer_requested",
"reasonDetail": "(Test) The customer asked to talk to a person.",
"customer": {
"name": "Test Customer",
"phone_number": "+15555550123",
"email": "test@example.com"
},
"customerMessage": "(Test) Can someone call me about the bees in my attic?",
"aiReply": "(Test) I’ve passed this to our team — someone will reach out shortly.",
"conversationUrl": "https://pestbooker.com/docs/webhooks",
"sessionId": "test-session",
"leadId": "3f0a6c2e-5b1d-4e8f-a7c9-0d2b4e6f8a10",
"office": {
"id": "6f1c2b9e-0d4a-4c1e-9a53-2e8f7b1d0c11",
"name": "North County",
"fieldRoutesOfficeID": 2
},
"attribution": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "fall-ants",
"utm_content": "hero-cta",
"utm_term": "ant exterminator",
"gclid": "TEST-GCLID-VALUE",
"fbclid": null,
"msclkid": null,
"wbraid": null,
"gbraid": null,
"rwg_token": null,
"landing_page": "https://www.example-pest.com/ant-control?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/"
}
}
}Verifying the signature
Each request carries X-PestBooker-Event (the event name) and X-PestBooker-Signature: the hex HMAC-SHA256 of the raw request body, keyed with your signing secret. Compute it over the body exactly as received, before parsing it. A 2xx response counts as delivered. Retries are built in: if your endpoint doesn’t answer with a 2xx, we automatically try again.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the request body exactly as received (before JSON.parse)
function isFromPestBooker(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(String(signatureHeader ?? ''))
return a.length === b.length && timingSafeEqual(a, b)
}Pro Tips
- • Your signing secret is generated the first time you save. Keep it server-side, never in a browser or a public Zap step.
- • If your secret may have leaked, an admin can roll it on the Signing secret tab. The new secret takes effect immediately, so update your receiver right away.
- • Using Zapier? Integrations → Zapier walks you through connecting an event to a Zap.
Need help with setup?
Email us and we'll walk you through any integration or setup process.
Contact Sales