Webhooks
Webhooks allow your application to be notified when something happens in Clio, instead of polling the API for changes. You register an HTTPS endpoint, subscribe it to a set of events, and Clio sends a signed POST request to that endpoint whenever one of those events occurs.
This guide covers Clio Platform webhooks, which work across Clio products. They are a separate system from Clio Manage webhooks, which use a different API, payload format, and signing scheme. If you are building on the Clio Manage API, see that guide instead.
Event payloads are versioned, and each subscription is pinned to a version. That is covered separately in Schema Versioning.
Event Names
Every event has a three-part name:
<product>.<resource_type>.<event_type>
For example, grow.inbox_lead.submitted is sent when a lead arrives in a Clio Grow inbox.
The product comes first because the same resource name can mean different things in different products. A matter in Clio Grow is a different resource from a matter in Clio Manage, and the two are governed by different permissions. Products also offer different sets of events, so an event that exists for one product may have no equivalent in another.
Supported Events
The Clio Platform API reference lists every event you can subscribe to, grouped by product, with the payload each one sends and the scope it requires. It is generated from the API, so it is the authoritative list.
Permissions
Managing subscriptions requires a valid access token and nothing more. There is no webhooks-specific scope. Subscribing to an event does require the scope for that event, which the API reference names alongside the event. If you're not sure whether your application has these scopes, check your application settings in the Clio Platform Developer Portal.
Your token must hold an event's scope at the time you subscribe to it. Requesting an event your token does not cover returns a 403 naming the scopes that would grant it. For a full description of each scope, see the Permissions page.
Scopes also affect existing subscriptions. If a user revokes a scope, the events that depended on it are removed from your subscription. If that leaves an active subscription with no events, it is disabled, because there is nothing left for it to deliver.
If a user revokes your application's access entirely, every subscription your application holds for that user is deleted rather than disabled. If the user connects your application again, create the subscriptions again.
Subscription Lifecycle
A subscription is always in one of four states: pending, active, disabled or suspended. Only an active subscription receives events.
New subscriptions start as pending. Clio sends a challenge request to the registered URL, and the endpoint confirms it by responding correctly. Only then does the subscription become active. Changing a subscription's URL returns it to pending, because the new URL has not been verified.
Both disabled and suspended stop delivery, but they differ in origin. A subscription is disabled because you chose to pause it, and you can resume it at any time. A subscription is suspended because Clio stopped it, most often after your endpoint failed to accept deliveries over a sustained period.
Regions
Clio operates separate regions, and a subscription belongs to exactly one. Create it against the region your firm's data is stored in, and read it back from that same region: a subscription created in one region is not visible in another. The API reference lists the base URL for each region.
Creating a Subscription
Creating a subscription requires a valid access token, plus the scope for each event you subscribe to.
Create a subscription by making a POST request to the /webhook_subscriptions endpoint. A sample request body is shown below:
{
"url": "https://your-app.example.com/webhooks/clio",
"event_types": ["grow.inbox_lead.submitted", "grow.inbox_lead.accepted"],
"schema_version": "2026-01-01"
}
url and event_types are both required. schema_version is optional and pins the subscription to a payload version, as described in Schema Versioning. The API reference has the full parameter list and the response format.
The response includes a signing_secret.
The signing_secret is returned only when a subscription is created and when the secret is regenerated. It cannot be read back afterwards. Store it when you receive it, keep it out of source control, and make sure it is filtered from your application logs.
A new subscription is pending and delivers nothing until you activate it, as described in "Activating a Subscription".
URL Requirements
The URL you register must use the https scheme. Clio also rejects URLs whose host is localhost or resolves to a loopback, private, link-local, or unspecified address. A URL that fails these checks is rejected with a 422.
These checks run again before each delivery, because a hostname that resolved publicly at registration can later resolve to a private address.
Activating a Subscription
Before delivering any events, Clio confirms that the registered URL is expecting them. Start this by making a POST request to the activate endpoint:
POST /webhook_subscriptions/{id}/activate
Authorization: Bearer <access_token>
Clio then sends a challenge request to the subscription's URL and waits up to 10 seconds for a response. The challenge is a POST carrying the standard headers and a JSON body containing a single challenge_token.
To pass the challenge, your endpoint must return a 2xx response whose JSON body echoes the token back unchanged:
{
"challenge_token": "<the token from the challenge request>"
}
Verify the signature before echoing the token, as described in "Verifying Signatures".
If the challenge succeeds, the subscription becomes active and events begin to flow. If it fails, the subscription's status is left unchanged and the activate request returns a 422 with the error challenge_failed. Activation is synchronous, so your endpoint must be able to answer the challenge at the moment you call it.
Request Headers
Every request Clio sends to your endpoint, both challenges and event deliveries, carries the same four headers:
| Header | Description |
|---|---|
X-Clio-Signature | A signature over the raw request body, prefixed with the signature version, for example v1=<hex digest>. |
X-Clio-Timestamp | The Unix timestamp the signature was generated at. Use it to reject replayed requests, as described in "Rejecting Stale Requests". |
X-Clio-Delivery-ID | Identifies the request. On an event delivery this is the delivery's ID, and retries of the same event reuse it. On a challenge it is a one-off value starting with challenge_, which does not correspond to a delivery you can look up later. |
X-Clio-Subscription-ID | The subscription the request belongs to. |
Verifying Signatures
Verify the signature on every request before acting on it. The signature is an HMAC-SHA256 digest, keyed with the subscription's signing secret, over the following string:
<timestamp>:<delivery_id>:<subscription_id>:<raw request body>
Each value comes from the matching header. Compute the digest over the raw request body exactly as received, rather than a re-serialized version of the parsed JSON, since re-serializing can reorder keys and produce a digest that never matches.
def valid_signature?(request, signing_secret)
headers = request.headers
signing_string = [
headers["X-Clio-Timestamp"],
headers["X-Clio-Delivery-ID"],
headers["X-Clio-Subscription-ID"],
request.raw_post,
].join(":")
digest = OpenSSL::HMAC.hexdigest("SHA256", signing_secret, signing_string)
ActiveSupport::SecurityUtils.secure_compare(
"v1=#{digest}",
headers["X-Clio-Signature"].to_s,
)
end
import hashlib
import hmac
def valid_signature(headers, body: bytes, signing_secret: str) -> bool:
prefix = ":".join([
headers["X-Clio-Timestamp"],
headers["X-Clio-Delivery-ID"],
headers["X-Clio-Subscription-ID"],
])
signing_string = f"{prefix}:".encode() + body
digest = hmac.new(signing_secret.encode(), signing_string, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"v1={digest}", headers.get("X-Clio-Signature", ""))
Always use a constant-time comparison, such as hmac.compare_digest or ActiveSupport::SecurityUtils.secure_compare, rather than ==.
Rejecting Stale Requests
A valid signature proves a request came from Clio, but not that it came from Clio just now. Anyone who captures a signed request can send it to your endpoint again, and the signature still verifies.
Check X-Clio-Timestamp to close that off. Reject any request whose timestamp is more than five minutes old, before or after verifying the signature:
timestamp = request.headers["X-Clio-Timestamp"].to_i
return head :bad_request if (Time.now.to_i - timestamp).abs > 300
The timestamp is part of the signed string, so it cannot be altered without invalidating the signature. That is what makes the check worth doing: an attacker replaying a captured request cannot move its timestamp forward to make it look fresh.
Five minutes is comfortably longer than a delivery takes, so a legitimate request will not be rejected. Note that a retried delivery is signed again with a new timestamp, so retries are not affected by this check.
Challenges and event deliveries arrive at the same URL. After verifying the signature, use X-Clio-Delivery-ID to tell them apart: a challenge's ID always starts with challenge_, and a delivery's never does. Only echo a token when the ID starts with challenge_, and never process a request with that prefix as an event. This stops a recorded challenge from being replayed to you as a delivery.
Event Payload Format
Each event is delivered as a POST with a JSON body in the following shape:
{
"event_id": "01JA6Z3N8Q2V5W7X9Y1B3C5D7E",
"event_type": "grow.inbox_lead.submitted",
"schema_version": "2026-01-01",
"occurred_at": "2026-01-15T17:04:12Z",
"payload": {
"id": 1234
}
}
| Field | Description |
|---|---|
| event_id | Uniquely identifies the event. A retried delivery carries the same event_id. See "Duplicate and Out-of-Order Events". |
| event_type | The name of the event, for example grow.inbox_lead.submitted. |
| schema_version | The payload version your subscription is pinned to. See Schema Versioning. |
| occurred_at | When the event happened in Clio, in UTC. |
| payload | The event's data. The fields depend on event_type and schema_version, and are documented per event in the API reference. |
The body contains nothing about the subscription itself. Routing information such as the subscription ID is carried in headers.
Responding to Events
Return a 2xx status as quickly as you can. Clio waits 5 seconds for a response before treating the delivery as failed. If handling an event involves slower work, such as writing to a database or calling another API, queue that work and respond immediately rather than doing it inline.
Only a 2xx counts as success:
| Response | Treated as |
|---|---|
2xx | Success. The delivery is complete. |
410 Gone | Permanent failure. No further attempts, and the subscription is suspended. |
3xx | Permanent failure, handled exactly like 410. Clio does not follow redirects, so a redirect suspends the subscription. Return 2xx directly. |
Other 4xx or 5xx | Transient failure. The delivery is retried. |
| No response in time | Transient failure. The delivery is retried. |
Retries and Failures
A failed delivery is retried up to four times, for five attempts in total. Each delay has a small random amount added to it so that many subscribers recovering at once do not retry in lockstep:
| Retry | Sent approximately after |
|---|---|
| 1 | 5 minutes |
| 2 | 30 minutes |
| 3 | 1 hour |
| 4 | 2 hours |
If the fifth attempt does not succeed, the delivery is abandoned. Events are not held and are not redelivered later, so a subscription that has been failing for several hours has permanently missed those events. The delivery log, described below, is how you find out which ones.
If your endpoint returns 429 Too Many Requests or 503 Service Unavailable with a Retry-After header, Clio waits as asked instead of using the schedule above, up to a maximum of 12 hours.
Suspension
Clio tracks how many deliveries in a row have exhausted every attempt. If too many fail in a row, the subscription is suspended and delivery stops.
A 410 Gone response suspends the subscription immediately, without waiting for that count. Return it when the endpoint is gone for good, and not for a temporary outage.
A suspended subscription's status_reason says why it was suspended: consecutive_dead_deliveries when too many deliveries failed in a row, and endpoint_rejected when your endpoint answered with 410 or a redirect.
To recover a suspended subscription, fix the endpoint and call the activate endpoint, which issues a fresh challenge.
Duplicate and Out-of-Order Events
Clio delivers each event at least once, which means your endpoint may occasionally receive the same event twice. Deliveries can also arrive out of order, particularly when one was retried and a newer event was not.
Use event_id to handle both. It is a ULID, which gives you two things:
- Deduplication. Record the
event_idvalues you have processed and discard any you have seen before. Handlers should be safe to run twice regardless, since a duplicate that arrives before your record is written will still be processed. - Ordering. ULIDs begin with a millisecond timestamp and sort lexicographically, so comparing two
event_idvalues for the same record tells you which event happened first. If an incomingevent_idsorts before the last one you processed for that record, the delivery is stale and can usually be discarded.
Delivery Log
Clio records the outcome of every delivery, including one entry per attempt with its HTTP status and outcome. Read it back to confirm what was sent and to investigate failures. Entries are kept for 7 days after Clio records them, so check it promptly after an outage. The endpoints, their filters and the full record format are in the Clio Platform API reference.
Since abandoned deliveries are never retried or replayed, this log is how you find out which events you missed during an outage. Filtering by failed status over the affected period gives you that list, and the after filter takes an event_id, so you can work forward from the last event you successfully processed rather than paging through everything.
The delivery log holds delivery metadata only. It never returns the event payload, so it cannot be used to recover the contents of a missed event. If you need the data from an event you did not successfully process, read the affected record back from the product's API.
Pausing and Resuming Delivery
To stop delivery without deleting the subscription, make a POST request to the disable endpoint:
POST /webhook_subscriptions/{id}/disable
The subscription moves to disabled. Events that occur while it is disabled are not held, and are not replayed when you resume.
To resume, make a POST request to the enable endpoint:
POST /webhook_subscriptions/{id}/enable
The subscription returns to active without a new challenge, since the endpoint already passed one. Only a disabled subscription can be enabled. A pending or suspended subscription recovers through the activate endpoint instead, which does issue a challenge.
Updating a Subscription
Update a subscription by making a PATCH request to /webhook_subscriptions/{id}. You can change the url, event_types, or schema_version.
A few things to note:
event_typesreplaces the whole set rather than adding to it. Supplying an empty array leaves an active subscription with nothing to deliver, so it is disabled.- Changing the
urlreturns the subscription topending, because the new URL has not been verified. You must activate it again. statusis not writable. Use the activate, enable, and disable endpoints instead.
Regenerating the Signing Secret
To replace a subscription's signing secret, make a POST request to the regenerate endpoint:
POST /webhook_subscriptions/{id}/regenerate_secret
The response contains the new secret. The subscription's status is unchanged, and no new challenge is issued.
This replaces the secret rather than rotating it. The previous secret stops working the moment the response returns, and only one secret is valid at a time. Any delivery sent between that point and your deployment of the new secret will be signed with a value your endpoint cannot verify.
Those deliveries are retried, so a prompt update usually recovers without losing events. A mismatch left in place long enough will exhaust the retries and suspend the subscription.
The safest approach is to read the signing secret from somewhere you can update without a deployment, such as your secret store, so the window between regenerating and your endpoint knowing the new value is as short as possible.
Managing Subscriptions
Subscriptions support the usual list, fetch, update and delete operations. For the full set of endpoints, their parameters and their error responses, see the Clio Platform API reference, which is generated from the API itself and is the authoritative source.
Note that deleting a subscription is permanent and stops delivery immediately. To stop delivery reversibly, use the disable endpoint described in "Pausing and Resuming Delivery".