Outbound webhooks
Send new Superpractice contacts, booked meetings, matter updates, and payments 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.matter.created— a new matter is added to a contact, in Superpractice or from Clio.matter.qualified— a matter enters the Qualified stage.matter.engagement_sent— a matter enters the Engagement sent stage, moved by hand or automatically when you send an engagement letter.matter.retained— a matter enters the Retained stage, moved by hand or automatically when payments cover its value, an engagement letter is signed, or Clio opens it.payment.received— a payment is recorded against a matter.payment.refunded— a refund or dispute is applied against a matter's payments.
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.
- 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, for example contact.created or matter.retained. |
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": null,
"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"
}
}
}leadStatus is retired and is null for new contacts. Use status (touchpoint, scheduled, qualified, pending, converted, referred_out, disqualified, spam).
{
"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.
{
"event": "matter.retained",
"eventId": "matter.retained:5c2e8f10-…:2026-09-30T15:04:00.000Z",
"createdAt": "2026-09-30T15:04:05.000Z",
"data": {
"matter": {
"id": "5c2e8f10-…",
"name": "Trademark Filing",
"displayName": "Jane Public · Trademark Filing",
"stage": "retained",
"valueCents": 350000,
"payments": {
"collectedCents": 150000,
"heldCents": 0,
"balanceCents": 200000,
"count": 1,
"lastPaymentAt": "2026-09-30T15:04:00.000Z"
},
"services": [
{
"id": "…",
"serviceId": "…",
"name": "Trademark Registration",
"description": null,
"quantity": 1,
"priceCents": 350000,
"discountCents": 0,
"totalCents": 350000
}
],
"practiceArea": "Intellectual Property",
"matterType": "Trademark Registration",
"feeType": "flat",
"closeDate": "2026-09-30T15:04:00.000Z",
"stageChangedAt": "2026-09-30T15:04:00.000Z",
"assignedTo": { "id": "…", "name": "Alex Attorney", "email": "alex@example-firm.com" },
"createdAt": "2026-09-28T10:12:00.000Z"
},
"contact": { "…": "the same shape as data.contact in contact.created, or null" }
}
}All four matter events send this shape; event and data.matter.stage tell them apart. A few notes:
- Stage events fire every time a matter enters the stage. A matter that goes Retained → Lost → Retained sends
matter.retainedtwice, each with its owneventId. If an endpoint subscribes to several stages, one matter sends one event per stage it enters. - Only the stage the matter lands on fires. A matter moved straight from New to Retained sends
matter.retainedonly — notmatter.qualifiedormatter.engagement_sent. - A matter created straight into a stage (for example, retained from Clio) sends
matter.createdand that stage's event. stagevalues arenew,qualified,engagement_letter_sent,retained, andlost. Moving to New or Lost does not fire an event.paymentsmatches the matter window:collectedCentsis applied payments,heldCentsis money received but not yet applied, andbalanceCentsis what is still owed (never below zero). All amounts are in cents.serviceslists the matter's services in order.totalCentsispriceCents × quantity − discountCents; the services add up tovalueCentsunless the value was set by hand.nameis the matter's name as stored. New matters store only the service ("Trademark Filing"); older ones may start with the client's name.displayNameis the name as Superpractice shows it in firm-wide lists: the client's name, then the matter ("Jane Public · Trademark Filing"), with the client never named twice. Payment events carry the same two fields ondata.payment.matter.- Case notes and custom fields are never included.
eventId is deterministic (contact.created:<contact id>, meeting.booked:<meeting id>, matter.created:<matter id>, matter.retained:<matter id>:<stageChangedAt>), 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; for matter and payment events, against the matter's contact.
| Filter | Matches |
|---|---|
| Ad channels | The contact's normalized channel: Google Ads, Google LSA, Meta Ads, OpenAI Ads, Microsoft 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, bookings, and matters, 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, meetings, or matters.
- Not sent: bulk CSV imports, calendar imports, contacts, meetings, and matters brought in by the HubSpot/GoHighLevel syncs, and matters from a first Clio import do not trigger outbound webhooks. After the first import, Clio matter changes do.
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. |