Webhook Events and Security
This article covers everything you need to know about the data your webhooks deliver and how to ensure that data is authentic. You'll find the full list of available events, an example payload, instructions for verifying HMAC signatures, and details on retry behavior and secret rotation.
Available events
Driive can send webhook notifications for the following events. When creating a webhook, you choose which events to subscribe to.
New: service requests. A new lead now enters Driive as a service request — the raw inquiry from your website form, a phone call, a text, an email, or your team — and fires
service_request.created. Once that request is qualified and committed, it produces an appointment, which firesappointment.created. If you previously relied onappointment.createdto catch new leads, subscribe toservice_request.createdinstead. See the Service request webhook migration guide for the full walkthrough.
Service request events
| Event | Fires when |
|---|---|
service_request.created | A new lead / service request is created |
service_request.updated | A service request is modified (info gathered, qualified, approved, or archived) |
Tip: Subscribe to
service_request.*to receive all service request events with a single subscription.service_request.updatedis debounced — rapid edits to the same request (common while a customer is filling out a form) collapse into a single delivery, roughly once per minute. Preview/test requests are never delivered.
Appointment events
| Event | Fires when |
|---|---|
appointment.created | An appointment is created from a service request — at needs_scheduled, or scheduled when booked immediately. This no longer fires for new leads — see the service request events above. |
appointment.updated | Appointment details or status are changed |
appointment.confirmed | A time slot is confirmed for the appointment |
appointment.cancelled | The appointment is cancelled |
appointment.rescheduled | The appointment is moved to a new date and time |
Tip: You can subscribe to
appointment.*to receive all appointment events with a single subscription. This is useful if you want to keep an external system fully in sync without selecting individual events.
Customer events
| Event | Fires when |
|---|---|
customer.created | A new customer is created |
customer.updated | A customer's details change (name, email, phone, tags, etc.) |
Tip: Subscribe to
customer.*to receive all customer events with a single subscription.
Issue events
| Event | Fires when |
|---|---|
issue.created | A new issue appears in the Inbox — something on a request or appointment needs a human |
The issue.* wildcard is also available and currently matches this single event — subscribe to it if you want to automatically pick up any issue events added in the future.
Payload structure
Every webhook delivery sends a JSON payload as an HTTP POST to your destination URL. All payloads follow the same top-level envelope.
Delivery envelope
| Field | Type | Description |
|---|---|---|
id | string | Unique delivery ID for deduplication |
type | string | The event type (e.g., appointment.created) |
previousData | object or null | The pre-update snapshot on *.updated events, in the same shape as data. Lets you diff what changed without keeping your own copy. null on *.created and other events. |
data | object | The event-specific data (varies by event type — see below) |
createdAt | string | ISO 8601 timestamp of when the event occurred |
Appointment event data fields
All appointment.* events include the following fields in the data object:
| Field | Type | Description |
|---|---|---|
appointmentId | string | Unique appointment ID |
organizationId | string | Your organization ID |
appointmentTypeId | string | The appointment type ID |
appointmentTypeName | string | The appointment type name (e.g., "Roof Inspection") |
status | string | Current appointment status (see Appointment statuses below) |
type | string | Meeting type: phone, in_person, virtual, or other |
customer | object | Customer details (see below) |
customerNotes | string or null | Notes the customer provided |
description | string or null | Internal description of the appointment |
startTime | string or null | Scheduled start time (ISO 8601) |
endTime | string or null | Scheduled end time (ISO 8601) |
duration | number or null | Duration in minutes |
location | object or null | Service address |
leadScore | number or null | Lead qualification score (0-100) |
bookedAt | string or null | When the appointment was booked |
cancelledAt | string or null | When the appointment was cancelled |
cancellationReason | string or null | Reason for cancellation |
utmParams | object or null | UTM tracking parameters captured from the booking URL |
bookingUrl | string | Customer-facing booking link for this appointment |
timezone | string or null | The timezone used for the local time fields (the customer's timezone, when known) |
startTimeLocal | string or null | Start time in that timezone, with UTC offset |
endTimeLocal | string or null | End time in that timezone, with UTC offset |
assignedTeamMembers | array | Users assigned to this appointment — each entry is { id, firstName, lastName, email, avatarUrl } |
leadTech | object or null | The lead user on the appointment, in the same shape as an assignedTeamMembers entry |
createdAt | string | When the appointment was created (ISO 8601) |
updatedAt | string | When the appointment was last updated (ISO 8601) |
legacy | boolean | Present only on the deprecated compatibility bridge. See Legacy appointment.created bridge. |
deprecationNotice | string | Present only on the deprecated compatibility bridge — a human-readable migration notice. |
Appointment statuses
Gathering and approval now happen on the service request, not the appointment. As a result, the former needs_information and needs_approval statuses no longer appear on appointments — a qualified, committed request produces an appointment that starts at needs_scheduled (or scheduled when booked immediately).
| Status | Meaning |
|---|---|
needs_scheduled | The appointment exists but has no confirmed date and time yet. Awaiting scheduling. |
scheduled | A date and time are confirmed. On the calendar. |
completed | The service was delivered. The job is done. |
cancelled | The appointment was cancelled by the customer or your team. |
These four are the only appointment statuses. Archiving is not a status — an archived appointment keeps its status and is simply hidden from the default views.
Customer object:
This shape is shared by both appointment.* and service_request.* payloads.
| Field | Type | Description |
|---|---|---|
firstName | string | Customer's first name |
lastName | string | Customer's last name |
email | string or null | Email address |
phone | string or null | Phone number |
timezone | string or null | Customer's timezone |
street | string or null | Street address |
city | string or null | City |
state | string or null | State |
zip | string or null | ZIP code |
lat | string or null | Latitude of the geocoded address |
lon | string or null | Longitude of the geocoded address |
placeId | string or null | Geocoder place ID for the address |
Service request event data fields
All service_request.* events include the following fields in the data object. Fields shared with the appointment payload keep the same shape and meaning.
| Field | Type | Description |
|---|---|---|
serviceRequestId | string | Unique service request ID — the primary identifier for the lead |
organizationId | string | Your organization ID |
customerId | string or null | The linked customer record's ID |
customer | object or null | Customer details (same shape as the appointment customer object) |
appointmentTypeId | string or null | The appointment type ID, if known yet |
appointmentId | string or null | The appointment produced from this request, once one exists — your join key to appointment.* events |
channel | string | How the lead arrived: phone, sms, chat, email, companycam, web_form, preview, manual, or leadconnector |
origin | string | What created the request: agent, team_member, public_booking, email_forward, web_form, companycam, manual, or leadconnector |
issue | object or null | The open "needs attention" issue: { id, reasons[], pinned }. See Issue reasons below. |
questionAnswers | array or null | Answers to your booking questions (questionId, questionLabel, answerValue, answerText, answerType) |
leadScore | number or null | Lead qualification score (0-100) |
disqualificationReason | string or null | Why the lead failed qualification, if it did |
summary | string or null | Human-readable summary of the request |
address | string or null | The raw address string as entered (location is the geocoded version) |
location | object or null | Geocoded service address (same shape as the appointment location) |
utmParams | object or null | UTM tracking parameters captured from the booking URL |
customFields | object or null | Custom field key/value map |
metadata | object | Free-form metadata |
preview | boolean or null | Preview/test flag (preview requests are never delivered, so expect false) |
approvedAt | string or null | When the request was approved, if it was |
deletedAt | string or null | Archive marker — set when the request is archived |
createdAt | string | When the request was created (ISO 8601) |
updatedAt | string | When the request was last updated (ISO 8601) |
Service request issue reasons
A service request's issue.reasons array tells you why it needs attention. It replaces the appointment status as the "what needs a human" signal at the lead stage.
| Reason | Meaning |
|---|---|
needs_information | Incomplete — waiting on more details before it can move forward |
failed_qualification | Gathered and complete, but the lead scored below your threshold |
out_of_service_area | Gathered and complete, but the location is outside your service area |
needs_user_approval | Gathered and complete, but the appointment type requires your sign-off |
manual_review | Flagged by a user for a human to look at |
user_cancelled | The customer cancelled the appointment via the public booking link |
call_drop | A voice/agent call dropped without producing an appointment |
Customer event data fields
All customer.* events include the following fields in the data object:
| Field | Type | Description |
|---|---|---|
customerId | string | Unique customer ID |
organizationId | string | Your organization ID |
firstName | string | Customer's first name |
lastName | string | Customer's last name |
businessName | string or null | Business name, if any |
email | string or null | Email address |
phone | string or null | Phone number |
street | string or null | Street address |
city | string or null | City |
state | string or null | State |
zip | string or null | ZIP code |
lat | string or null | Latitude of the geocoded address |
lon | string or null | Longitude of the geocoded address |
placeId | string or null | Geocoder place ID for the address |
timezone | string or null | Customer's timezone |
status | string | Customer status: lead, customer, or inactive |
source | string | Where the customer came from (e.g., booking, manual, import) |
notes | string or null | Notes on the customer |
tags | array or null | Tags applied to the customer |
customFields | object or null | Custom field key/value map |
deletedAt | string or null | Archive marker — set when the customer is archived |
createdAt | string | When the customer was created (ISO 8601) |
updatedAt | string | When the customer was last updated (ISO 8601) |
Issue event data fields
The issue.created event includes the following fields in the data object:
| Field | Type | Description |
|---|---|---|
issueId | string | Unique issue ID |
organizationId | string | Your organization ID |
objectType | string | What the issue points at: service_request or appointment |
serviceRequestId | string or null | The request that needs attention — set when objectType is service_request |
appointmentId | string or null | The appointment that needs attention — set when objectType is appointment |
reasons | array | Why it needs attention — one or more reason codes. Service request issues use the issue reasons above; appointment issues use needs_scheduled (an appointment exists but has no time yet). |
pinned | boolean | Whether the issue is pinned to the top of the Inbox |
createdAt | string | When the issue was created (ISO 8601) |
updatedAt | string | When the issue was last updated (ISO 8601) |
Exactly one of serviceRequestId / appointmentId is set, matching objectType.
Example: appointment.created
{ "id": "del_abc123", "type": "appointment.created", "createdAt": "2025-01-15T10:30:00Z", "data": { "appointmentId": "apt_abc123", "organizationId": "org_xyz789", "appointmentTypeId": "aty_def456", "appointmentTypeName": "Roof Inspection", "status": "needs_scheduled", "type": "in_person", "customer": { "firstName": "John", "lastName": "Smith", "email": "john@example.com", "phone": "+15551234567", "timezone": "America/Denver", "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903", "placeId": "ChIJ-aBc123" }, "startTime": null, "endTime": null, "duration": 60, "location": { "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903" }, "leadScore": 85, "bookedAt": null, "cancelledAt": null, "cancellationReason": null, "bookingUrl": "https://app.getdriive.com/l/apt_abc123", "timezone": "America/Denver", "startTimeLocal": null, "endTimeLocal": null, "assignedTeamMembers": [], "leadTech": null, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T10:30:00Z" } }
Example: appointment.confirmed
{ "id": "del_ghi789", "type": "appointment.confirmed", "createdAt": "2025-01-15T14:00:00Z", "data": { "appointmentId": "apt_abc123", "organizationId": "org_xyz789", "appointmentTypeId": "aty_def456", "appointmentTypeName": "Roof Inspection", "status": "scheduled", "customer": { "firstName": "John", "lastName": "Smith", "email": "john@example.com", "phone": "+15551234567", "timezone": "America/Denver", "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903", "placeId": "ChIJ-aBc123" }, "startTime": "2025-01-20T16:00:00Z", "endTime": "2025-01-20T17:00:00Z", "duration": 60, "location": { "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903" }, "leadScore": 85, "bookedAt": "2025-01-15T14:00:00Z", "cancelledAt": null, "cancellationReason": null, "bookingUrl": "https://app.getdriive.com/l/apt_abc123", "timezone": "America/Denver", "startTimeLocal": "2025-01-20T09:00:00-07:00", "endTimeLocal": "2025-01-20T10:00:00-07:00", "assignedTeamMembers": [ { "id": "mem_xyz789", "firstName": "Alex", "lastName": "Rivera", "email": "alex@example.com", "avatarUrl": null } ], "leadTech": { "id": "mem_xyz789", "firstName": "Alex", "lastName": "Rivera", "email": "alex@example.com", "avatarUrl": null }, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T14:00:00Z" } }
Example: appointment.rescheduled
{ "id": "del_jkl012", "type": "appointment.rescheduled", "createdAt": "2025-01-16T09:00:00Z", "data": { "appointmentId": "apt_abc123", "organizationId": "org_xyz789", "appointmentTypeId": "aty_def456", "appointmentTypeName": "Roof Inspection", "status": "scheduled", "customer": { "firstName": "John", "lastName": "Smith", "email": "john@example.com", "phone": "+15551234567", "timezone": "America/Denver", "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903", "placeId": "ChIJ-aBc123" }, "startTime": "2025-01-22T16:00:00Z", "endTime": "2025-01-22T17:00:00Z", "duration": 60, "location": { "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903" }, "leadScore": 85, "bookedAt": "2025-01-15T14:00:00Z", "cancelledAt": null, "cancellationReason": null, "bookingUrl": "https://app.getdriive.com/l/apt_abc123", "timezone": "America/Denver", "startTimeLocal": "2025-01-22T09:00:00-07:00", "endTimeLocal": "2025-01-22T10:00:00-07:00", "assignedTeamMembers": [ { "id": "mem_xyz789", "firstName": "Alex", "lastName": "Rivera", "email": "alex@example.com", "avatarUrl": null } ], "leadTech": { "id": "mem_xyz789", "firstName": "Alex", "lastName": "Rivera", "email": "alex@example.com", "avatarUrl": null }, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-16T09:00:00Z" } }
Example: service_request.created
{ "id": "del_pqr678", "type": "service_request.created", "previousData": null, "createdAt": "2025-01-15T10:30:00Z", "data": { "serviceRequestId": "sr_abc123", "organizationId": "org_xyz789", "customerId": "cus_def456", "appointmentTypeId": "aty_def456", "appointmentId": null, "channel": "web_form", "origin": "public_booking", "customer": { "firstName": "John", "lastName": "Smith", "email": "john@example.com", "phone": "+15551234567", "timezone": "America/Denver", "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903", "placeId": "ChIJ-aBc123" }, "issue": { "id": "iss_123", "reasons": ["needs_information"], "pinned": false }, "questionAnswers": [ { "questionId": "q_1", "questionLabel": "What's going on?", "answerValue": "Roof is leaking after the storm", "answerText": "Roof is leaking after the storm", "answerType": "text" } ], "leadScore": 85, "disqualificationReason": null, "summary": "Customer reports a leaking roof after last week's storm.", "address": "123 Main St, Denver, CO 80202", "location": { "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903" }, "utmParams": { "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "spring-roofing" }, "customFields": { "referral_code": "SPRING25" }, "metadata": {}, "preview": false, "approvedAt": null, "deletedAt": null, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T10:30:00Z" } }
Example: customer.created
{ "id": "del_mno345", "type": "customer.created", "previousData": null, "createdAt": "2025-01-15T10:30:00Z", "data": { "customerId": "cus_def456", "organizationId": "org_xyz789", "firstName": "John", "lastName": "Smith", "businessName": null, "email": "john@example.com", "phone": "+15551234567", "street": "123 Main St", "city": "Denver", "state": "CO", "zip": "80202", "lat": "39.7392", "lon": "-104.9903", "placeId": "ChIJ-aBc123", "timezone": "America/Denver", "status": "lead", "source": "booking", "notes": null, "tags": ["roofing"], "customFields": null, "deletedAt": null, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T10:30:00Z" } }
Example: issue.created
{ "id": "del_stu901", "type": "issue.created", "previousData": null, "createdAt": "2025-01-15T10:31:00Z", "data": { "issueId": "iss_123", "organizationId": "org_xyz789", "objectType": "service_request", "serviceRequestId": "sr_abc123", "appointmentId": null, "reasons": ["needs_information"], "pinned": false, "createdAt": "2025-01-15T10:31:00Z", "updatedAt": "2025-01-15T10:31:00Z" } }
Legacy appointment.created bridge (deprecated)
To keep existing integrations working during the move to service requests, Driive offers an opt-in compatibility bridge. When it's enabled for your organization, a new service request also emits a stand-in appointment.created event, so an un-migrated integration keeps receiving new leads.
The bridge is a stopgap and is deliberately easy to detect:
legacyistrue.deprecationNoticecarries a message explaining the deprecation.appointmentIdis actually theserviceRequestId— there is no real appointment behind it.statusis hardcoded toneeds_information.bookingUrlpoints at the public request page (/r/...), not an appointment.
{ "id": "del_def456", "type": "appointment.created", "data": { "appointmentId": "sr_abc123", "status": "needs_information", "legacy": true, "deprecationNotice": "DEPRECATED legacy compatibility event. `appointmentId` is actually a serviceRequestId. Migrate to the `service_request.created` webhook — this payload will be removed soon.", "...": "..." } }
Important: The bridge is off by default and will be removed. Don't build anything new on it. Once you've migrated to
service_request.created, ignore (and drop) anyappointment.createdpayload wherelegacyistrue. See the Service request webhook migration guide for the full migration path.
HMAC signature verification
Every webhook delivery includes an X-Webhook-Signature header. This header contains a timestamp and an HMAC-SHA256 hash that you should verify to confirm the payload was sent by Driive and hasn't been tampered with.
The header looks like this:
X-Webhook-Signature: t=1736937000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis a Unix timestamp (in seconds) of when the delivery was signed.v1is a hex-encoded HMAC-SHA256 signature.
How it works
- Driive takes the timestamp and the raw request body, joined with a period:
{timestamp}.{payload}. - Driive computes an HMAC-SHA256 hash of that string using your webhook's signing secret as the key.
- The timestamp and hash are sent together in the
X-Webhook-Signatureheader ast={timestamp},v1={signature}. - Your server extracts
tandv1from the header, performs the same computation over{t}.{raw body}, and compares its result tov1.
Because the timestamp is part of the signed content, you can also reject stale deliveries (say, older than five minutes) to protect against replay attacks.
Verification pseudocode
t, v1 = parse(request.headers["X-Webhook-Signature"]) // "t=...,v1=..." expected = HMAC-SHA256(signing_secret, t + "." + request_body) if (constant_time_equal(expected, v1)) { // Payload is authentic — process it } else { // Payload may be forged — reject it }
Verification example (Node.js)
const crypto = require("crypto"); function verifyWebhook(signingSecret, requestBody, signatureHeader) { // Header format: "t={timestamp},v1={signature}" const parts = Object.fromEntries( signatureHeader.split(",").map((pair) => pair.split("=")) ); const { t: timestamp, v1: signature } = parts; if (!timestamp || !signature) return false; const expected = crypto .createHmac("sha256", signingSecret) .update(`${timestamp}.${requestBody}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature) ); }
Verification example (Python)
import hmac import hashlib def verify_webhook(signing_secret, request_body, signature_header): # Header format: "t={timestamp},v1={signature}" parts = dict(pair.split("=", 1) for pair in signature_header.split(",")) timestamp = parts.get("t") signature = parts.get("v1") if not timestamp or not signature: return False signed_payload = f"{timestamp}.{request_body}" expected = hmac.new( signing_secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)
Important: Always use a constant-time comparison function (like
timingSafeEqualorcompare_digest) to prevent timing attacks. And always compute the hash over the raw request body exactly as received — parsing and re-serializing the JSON can change whitespace or key order and break the signature.
Why verify signatures?
Without verification, anyone who discovers your webhook URL could send fake payloads to your server. Signature verification ensures:
- The payload was sent by Driive, not a third party
- The payload hasn't been modified in transit
- Your integration only processes authentic events
HTTP headers
Every webhook delivery includes the following headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Id | The ID of the webhook receiving this delivery — useful when several webhooks point at the same endpoint |
X-Webhook-Event | The event type (e.g., service_request.created), so you can route without parsing the body |
X-Webhook-Signature | t={timestamp},v1={signature} — see HMAC signature verification |
User-Agent | Driive-Webhooks/1.0 |
Timeouts and retry logic
Your endpoint has 30 seconds to respond to each delivery. If it doesn't respond in time, or responds with anything other than a 2xx status code (200-299), Driive treats the delivery as failed and retries automatically.
Retry behavior:
- Retries: Up to 5 retry attempts after a failed delivery
- Backoff strategy: Exponential backoff — each retry waits longer than the previous one
Failures never disable a webhook automatically — the endpoint keeps receiving new events. Its health is shown in the Status column on the Webhooks page: a Failing badge with the last error message and the number of consecutive failures.
Tips for handling retries:
- Make your webhook endpoint idempotent — processing the same event twice should produce the same result. Use the delivery
idfield to detect duplicates. - Return a
200status code as quickly as possible. If you need to do heavy processing, accept the webhook, return200, and process the data asynchronously. - Keep an eye on the Status column on the Webhooks page for patterns of failure.
Secret rotation
If your signing secret is compromised (or as part of routine security hygiene), you can rotate it through the API:
POST /v1/organizations/{orgId}/webhooks/{webhookId}/rotate-secret
Driive generates a new signing secret and returns it once in the response — store it securely, because it can't be retrieved again. The old secret stops working immediately. See the interactive API reference for the full request and response details.
Tip: Because the old secret is invalidated the moment you rotate, be ready to update your server's verification logic right away. To avoid rejecting deliveries during the switchover, you can briefly accept either secret by checking the signature against both.
Best practices
- Always verify signatures — Never skip HMAC verification in production
- Respond quickly — Return
200within a few seconds; process data asynchronously if needed - Handle duplicates — Your endpoint may receive the same event more than once due to retries
- Log deliveries — Keep your own record of received webhooks for debugging
- Use HTTPS — Your destination URL must use HTTPS to protect data in transit
- Monitor delivery health — Check the Status column on the Webhooks page regularly and set up your own alerting for failed deliveries
Next steps
- Webhooks — Set up and manage your webhook subscriptions
- Service request webhook migration guide — Move new-lead integrations from
appointment.createdtoservice_request.created - API Keys — Create API keys for programmatic access
- Troubleshooting — Fix common webhook issues