Webhooks
ClientInvite sends an HTTP POST to your URL the moment a client finishes a connect flow. One event exists today: connection.completed.
Works with every automation platform
Setup
- Create a custom webhook in your automation tool (Make, Zapier, n8n, …) and copy its URL.Hover to play
- In ClientInvite, go to Settings → API & Webhooks, paste the URL, and click Save.Hover to play
- Click Send test event.
Your URL must be a public https:// address. Private and internal addresses are rejected.
Build everything with the test event
The test event has exactly the same shape as a real one, so you can build and test your entire automation before a real client ever connects. Fire it as many times as you need. Test events always have "clientId": "test_client", so you can filter them out later.
When it fires
connection.completed fires once per client, when they finish the connect flow: "status": "SUCCESS" if they connected every asset on your link, "PARTIAL_SUCCESS" if some weren't connected. It also fires if a client shares a Meta ad account or pixel in Meta without finishing on the link, once ClientInvite confirms everything on the link has been shared. It never fires for abandoned flows.
Webhooks are an Agency plan feature. If your workspace isn't on the Agency plan, nothing is sent.
Request
POST https://your-endpoint.example.com
Content-Type: application/json
X-ClientInvite-Event: connection.completed
X-ClientInvite-Delivery: 3f9a1c2e-7b4d-4e8a-9c61-0d2b5e8f4a17
X-ClientInvite-Signature: sha256=5d41402abc4b2a76b9719d911017c592...| Field | Type | Description |
|---|---|---|
| X-ClientInvite-Event | header | The event name, connection.completed. |
| X-ClientInvite-Delivery | header | A unique ID for this delivery. It stays the same across retries — use it to deduplicate. |
| X-ClientInvite-Signature | header | sha256= followed by the HMAC-SHA256 of the raw body. See Verifying the signature. |
Payload
{
"event": "connection.completed",
"apiVersion": 1,
"timestamp": "2026-07-13T09:41:27.000Z",
"status": "SUCCESS",
"accessLevel": "admin",
"requestId": "5b0f3c8e-2d1a-4c7e-9a61-3f2e8d4b7c10",
"requestName": "Onboarding - Meta & Google",
"requestUrl": "https://app.clientinvite.com/workspace/acme-agency/onboarding",
"clientId": "9c2d7f14-6b3e-4a8f-b1d2-7e5c0a9f3b21",
"clientName": "Acme Co",
"clientEmail": "hello@acme.com",
"clientSummaryUrl": "https://app.clientinvite.com/clients",
"customRef": "crm-record-8841",
"connectionSummaryText": "Acme Co granted Admin access to: Meta ads (Acme Co), Facebook page (Acme Co), Google ads (acme.com).",
"connectionSummaryHtml": "<b>Acme Co</b> granted <b>Admin</b> access to:<br>• Meta ads (Acme Co)<br>• Facebook page (Acme Co)<br>• Google ads (acme.com)",
"assetCount": 3,
"connectionAssets": [
{
"id": "act_1086344871362999",
"name": "Acme Co",
"type": "Meta ads",
"platform": "Meta",
"connectionStatus": "Connected",
"accessLevel": "admin",
"time": "2026-07-13T09:40:51.000Z",
"linkToAsset": null
},
{
"id": "104217693118841",
"name": "Acme Co",
"type": "Facebook page",
"platform": "Meta",
"connectionStatus": "Connected",
"accessLevel": "admin",
"time": "2026-07-13T09:40:12.000Z",
"linkToAsset": null
},
{
"id": "7342018841",
"name": "acme.com",
"type": "Google ads",
"platform": "Google",
"connectionStatus": "Connected",
"accessLevel": "admin",
"time": "2026-07-13T09:41:25.000Z",
"linkToAsset": null
}
]
}Field reference
| Field | Type | Description |
|---|---|---|
| event | string | Always "connection.completed". |
| apiVersion | integer | Payload version, currently 1. |
| timestamp | string (ISO 8601) | When the event was generated. |
| status | string | "SUCCESS" (every asset connected) or "PARTIAL_SUCCESS". |
| accessLevel | string | "admin" if any connected asset has admin access, otherwise "view". |
| requestId | string | The ID of the access link the client used. |
| requestName | string | The access link’s name. |
| requestUrl | string | The access link’s URL. |
| clientId | string | The client’s ID. Use it with GET /clients/{id}. |
| clientName | string | The name your client entered in the connect flow. |
| clientEmail | string | null | The email address your client entered. |
| clientSummaryUrl | string | Your Clients page in the ClientInvite dashboard. |
| customRef | string | null | Your own ID, if the connect link included ?ref=. See below. |
| connectionSummaryText | string | A one-line, human-readable summary of what was connected. |
| connectionSummaryHtml | string | The same summary as HTML, ready for an email. |
| assetCount | integer | How many assets were connected. |
| connectionAssets[] | array | One entry per asset on the access link, including ones not connected. |
| connectionAssets[].id | string | null | The platform’s own ID for the account (e.g. act_… or a Google Ads customer ID). null if not connected. |
| connectionAssets[].name | string | null | The account’s name on the platform. |
| connectionAssets[].type | string | e.g. "Meta ads", "Facebook page", "Meta pixel", "Google ads", "Google analytics". |
| connectionAssets[].platform | string | "Meta", "Google", "LinkedIn" or "Shopify". |
| connectionAssets[].connectionStatus | string | "Connected", "In progress" or "Not Connected". |
| connectionAssets[].accessLevel | string | The access your link asks for: "view", "admin" or "whitelisting". |
| connectionAssets[].time | string | null | When the client connected this asset. |
| connectionAssets[].linkToAsset | null | Reserved for future use. Always null. |
Most automations only need one field
For most automations, connectionSummaryText (or its HTML twin) is all you need. Drop it into a Slack message or email and you get a readable summary of the whole connection without touching connectionAssets.
Tag connections with your own ID: customRef
Add ?ref=YOUR_ID to any connect link to tag the resulting connection with an ID from your own system. It comes back as customRef in every webhook payload and every API response, and you can look a client up by it.
Worked example — matching a connection back to a HubSpot record:
- Your HubSpot contact for Acme Co has record ID
crm-record-8841. - You send them your connect link with the ref appended:
https://app.clientinvite.com/workspace/acme-agency/onboarding?ref=crm-record-8841 - When they connect, the webhook arrives with
"customRef": "crm-record-8841"— your automation uses it to update exactly the right HubSpot record, even if the client typed a different name or email than the one in your CRM.
Verifying the signature
Every webhook includes an X-ClientInvite-Signature header: sha256= followed by an HMAC-SHA256 hex digest of the request body, computed with your webhook secret (whsec_xxxxxxxxxxxx, shown in Settings → API & Webhooks). Verify it before trusting the payload.
Hash the raw body
Compute the HMAC over the raw request body, before any JSON parsing. If you hash the re-serialized parsed body, key order and whitespace changes will make verification fail. And compare against sha256= + the hex digest, not the digest alone.
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: use express.raw() so you hash the raw body, not parsed JSON
app.post(
'/webhooks/clientinvite',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-clientinvite-signature'] || '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.CLIENTINVITE_WEBHOOK_SECRET) // whsec_xxxxxxxxxxxx
.update(req.body) // raw Buffer
.digest('hex');
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body);
console.log(event.connectionSummaryText);
// e.g. post to Slack, update your CRM, etc.
res.sendStatus(200);
}
);
app.listen(3000);Python
import hashlib
import hmac
import json
import os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/clientinvite")
def clientinvite_webhook():
# IMPORTANT: hash request.get_data() (the raw body), not request.json
raw_body = request.get_data()
signature = request.headers.get("X-ClientInvite-Signature", "")
expected = "sha256=" + hmac.new(
os.environ["CLIENTINVITE_WEBHOOK_SECRET"].encode(), # whsec_xxxxxxxxxxxx
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = json.loads(raw_body)
print(event["connectionSummaryText"])
# e.g. post to Slack, update your CRM, etc.
return "", 200PHP
<?php
// IMPORTANT: hash the raw body from php://input, not $_POST or decoded JSON
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CLIENTINVITE_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac(
'sha256',
$rawBody,
getenv('CLIENTINVITE_WEBHOOK_SECRET') // whsec_xxxxxxxxxxxx
);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
error_log($event['connectionSummaryText']);
// e.g. post to Slack, update your CRM, etc.
http_response_code(200);Retries & delivery
- Your endpoint has 10 seconds to respond with a 2xx status.
- Any non-2xx response (or a timeout) counts as a failed delivery.
- Failed deliveries are retried at 1 minute, 10 minutes, 1 hour, and 6 hours, then marked failed.
- Settings → API & Webhooks shows your 50 most recent deliveries, with the response status, any error, and a Resend button.
Respond fast: acknowledge with a 200 first, then do slow work (CRM calls, emails) asynchronously. Retries mean your endpoint can receive the same event more than once — deduplicate on the X-ClientInvite-Delivery header, or on clientId, since connection.completed fires once per client.
Troubleshooting
- Nothing fired — the client abandoned the flow before finishing, or your workspace is on the Solo or Freelancer plan (webhooks are Agency-plan only).
- A field is missing in my automation — every field is always sent. Re-capture a sample with Send test event; older samples in your tool may predate newer fields.
- Signature mismatch — you're hashing the parsed body instead of the raw body, or comparing without the
sha256=prefix. See Verifying the signature. - My URL was rejected — it must be a public
https://URL.http://,localhostand private IP addresses aren't allowed. - 403 on API calls — the API requires the Agency plan.