Outbound webhooks
Send new Superpractice contacts and booked meetings to your own systems the moment they happen — Zapier, Slack, a CRM, or any URL that accepts an HTTPS request.
Overview
Outbound webhooks are the mirror image of the inbound contact webhook: instead of pushing leads into Superpractice, they push events out to URLs you configure. Superpractice sends a signed POST request to each of your active endpoints for the events it subscribes to:
contact.created— a new contact is created, from your website pixel, a booked meeting, a phone call, Meta Lead Ads, a landing page, the AI voice agent, an inbound webhook, or manual entry.meeting.booked— a meeting is booked through your Superpractice booking widget.
Use them to forward leads to Zapier or Make, mirror contacts into another CRM, trigger internal alerting, or feed any downstream automation. You can register up to 10 endpoints per firm, each with its own signing secret, event selection, and optional filters.
Setup
- Open Settings > Integrations and find the Outbound Webhooks section.
- Click Add Webhook, give it a name, and paste the destination URL. The URL must be a public
https://(orhttp://) address. - Pick the events the endpoint should receive — contacts, booked meetings, or both.
- Optionally set filters (see below) so the endpoint only receives events from certain ad channels, campaigns, landing pages, or Meta lead forms.
- Click Test to send a signed sample of each subscribed event and confirm your receiver responds with a
2xxstatus. - Create a real contact (or book a meeting) and watch the delivered count on the endpoint card tick up.
The request your endpoint receives
Every delivery is an HTTPS POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Superpractice-Event | The event type: contact.created or meeting.booked. |
X-Superpractice-Delivery | A unique ID for this delivery attempt. Useful for logging and support. |
X-Superpractice-Signature | t=<unix seconds>,v1=<hex HMAC> — see Verifying signatures. |
Your endpoint should respond with any 2xx status within 10 seconds. The response body is ignored. Redirects are not followed — point the webhook at the final URL.
{
"event": "contact.created",
"eventId": "contact.created:6f9d2c1e-…",
"createdAt": "2026-07-27T15:04:05.000Z",
"data": {
"contact": {
"id": "6f9d2c1e-…",
"firstName": "Jane",
"lastName": "Public",
"email": "jane@example.com",
"phone": "+15550100000",
"source": "pixel",
"status": "touchpoint",
"leadStatus": "new_lead",
"practiceArea": "Estate Planning",
"practiceAreaNormalized": "Estate Planning",
"serviceType": null,
"matterType": null,
"caseValue": null,
"tags": null,
"notes": "Interested in a consultation.",
"context": null,
"address": { "street": null, "street2": null, "city": null, "state": null, "zip": null, "country": "US" },
"attribution": {
"firstTouch": {
"source": "google-ads", "medium": "cpc", "campaign": "Brand",
"content": null, "term": "estate planning attorney",
"landingPage": "https://example-firm.com/estate-planning",
"referrer": null, "gclid": "Cj0K…", "fbclid": null, "msclkid": null,
"timestamp": "2026-07-27T15:03:58.000Z"
},
"lastTouch": { "…": "same shape as firstTouch" }
},
"createdAt": "2026-07-27T15:04:04.000Z"
}
}
}{
"event": "meeting.booked",
"eventId": "meeting.booked:9a1b3c5d-…",
"createdAt": "2026-07-31T15:04:05.000Z",
"data": {
"meeting": {
"id": "9a1b3c5d-…",
"status": "confirmed",
"startTime": "2026-08-02T14:00:00.000Z",
"endTime": "2026-08-02T14:30:00.000Z",
"timezone": "America/New_York",
"durationMinutes": 30,
"eventType": { "id": "…", "name": "Initial Meeting", "slug": "initial-meeting" },
"invitee": {
"name": "Jane Public",
"firstName": "Jane",
"lastName": "Public",
"email": "jane@example.com",
"phone": "+15550100000",
"notes": "Looking for help with a trust."
},
"meetingLink": null,
"location": {
"mode": "in_person",
"label": "Main Office",
"locationId": "3b7d4e21-…",
"inPersonAddress": "123 Main Street, Springfield, IL 62701",
"inPersonNotes": "Please check in at reception.",
"phoneCallDirection": null,
"phoneCallNumber": null
},
"host": { "id": "…", "name": "Alex Attorney", "email": "alex@example-firm.com" },
"bookedAt": "2026-07-31T15:04:03.000Z"
},
"contact": { "…": "the same shape as data.contact in contact.created, or null" }
}
}A few meeting.booked notes:
statusisconfirmedfor instantly confirmed bookings andpendingwhen the event type requires host confirmation. The event fires once, when the meeting is booked — a later host confirmation does not fire a second event.data.contactcarries the full contact record (with attribution) that the booking created or matched. In the rare moment before contact integration finishes it can benull.- Legacy bookings and event types without client-selectable locations send
location: null. Video meeting URLs arrive inmeetingLinkrather than the location object. - Only meetings booked through the Superpractice booking widget fire this event — meetings synced from HubSpot, GoHighLevel, or calendar imports do not.
eventId is deterministic (contact.created:<contact id>, meeting.booked:<meeting id>), so if your receiver ever sees the same event twice you can safely deduplicate on it. Attribution uses the same canonical source values as the rest of Superpractice.
Verifying signatures
Each endpoint has its own signing secret (shown in the endpoint's Configure dialog). The signature header proves the request came from Superpractice and that the body was not modified:
tis the Unix timestamp (seconds) when the request was signed.v1isHMAC-SHA256(secret, "<t>.<raw body>")as lowercase hex.
Verify by recomputing the HMAC over the raw request body — before any JSON parsing or re-serialization — and comparing with a constant-time comparison. Reject requests whose timestamp is too old to block replays.
import { createHmac, timingSafeEqual } from "crypto"
function verifySuperpracticeSignature(signatureHeader, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")))
const timestamp = Number(parts.t)
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
if (!parts.v1 || parts.v1.length !== expected.length) return false
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"))
}Rotating? Update the secret on your receiver first, then contact support — or delete and recreate the endpoint, which issues a fresh secret.
Filters
By default an endpoint receives every subscribed event. Filters narrow that down. All matching uses the contact's first-touch attribution — how the lead originally found the firm. For meeting.booked, filters evaluate against the contact who booked the meeting.
| Filter | Matches |
|---|---|
| Ad channels | The contact's normalized channel: Google Ads, Google LSA, Meta Ads, OpenAI Ads, Bing Ads, Organic SEO, Google Business, Referral, Email, Direct, Social, AI, or Other. Legacy source spellings normalize automatically (adwords still matches Google Ads). |
| Campaign contains | Case-insensitive substring match against the first-touch campaign name. Comma-separate multiple values; any match passes. |
| Landing page URL contains | Case-insensitive substring match against the first-touch landing page URL. Comma-separate multiple values; any match passes. |
| Meta lead forms | The specific Meta lead form the contact was created from — an exact match, so an endpoint can serve a single form. Only contacts born from a Meta lead form can match; requires the Meta Ads integration with lead form sync enabled (the picker is greyed out until then). |
Within one filter the values are OR'd; different filters are AND'd together. So "channels: Google Ads, Meta Ads + campaign contains: brand" means (Google Ads or Meta Ads) and a campaign containing "brand".
Contacts with no attribution at all (for example, manually entered contacts) only match a channel filter when Direct is selected. Similarly, a Meta lead forms filter never matches contacts that came from any other source.
Delivery, retries, and reliability
- At-least-once delivery. Each event is sent immediately when it happens. If your endpoint is unreachable or returns a non-
2xxstatus, Superpractice retries with exponential backoff (roughly 5 minutes to 6 hours between attempts) for up to 3 days. A background reconciliation pass also re-checks recent contacts and bookings, so a delivery is never silently lost — worst case it arrives a few minutes late. - Ordering is not guaranteed. Retries mean events can occasionally arrive out of order. Use
data.contact.createdAtordata.meeting.bookedAtif order matters. - Duplicates are possible but rare. Dedupe on
eventIdif your automation is not idempotent. - New endpoints start from now. Adding an endpoint does not replay past contacts or meetings.
- Not sent: bulk CSV imports, calendar imports, and contacts or meetings brought in by the HubSpot/GoHighLevel syncs do not trigger outbound webhooks.
Automatic pause
If an endpoint fails 10 consecutive deliveries — or responds with 410 Gone — Superpractice pauses it so a dead URL is not hammered forever. When that happens:
- The endpoint card in Settings shows the pause reason.
- Every firm owner and admin receives an email with the endpoint, the reason, and a re-enable link.
Fix the receiver, then toggle the endpoint back on (or save a corrected URL) — deliveries resume immediately and the failure counter resets. While paused, new contacts are not queued for that endpoint.
Troubleshooting
| Symptom | Check |
|---|---|
| Test succeeds but real contacts never arrive | Filters — a channel filter silently skips non-matching and unattributed contacts. |
| Signature verification fails | You must HMAC the raw body bytes. Frameworks that re-serialize JSON (or middleware that trims whitespace) change the body and break the signature. |
| Deliveries time out | Your receiver must respond within 10 seconds. Accept fast, process async. |
| Endpoint paused unexpectedly | The card shows the last error and response status. Ten consecutive failures of any kind trigger the pause. |