Webhooks
ClientInvite sends an HTTP POST to your URL the moment a client finishes a connect flow. One event exists today: connection.completed.
Setup
- Go to Settings → API & Webhooks in your dashboard.
- Paste your webhook URL and click Save.
- Click Send test event.
Build everything with the test event
The test event sends a realistic sample payload, so you can build and test your entire automation before a real client ever connects. Fire it as many times as you need.
When it fires
connection.completed fires when a client completes a connect flow, whether the result is SUCCESS (every requested asset granted) or PARTIAL_SUCCESS (some assets granted, some not). It never fires for abandoned flows — if the client opens your link and leaves without finishing, nothing is sent.
Payload
{
"event": "connection.completed",
"createdAt": "2026-07-13T09:41:27Z",
"data": {
"connectionId": "conn_9f2kd83a",
"status": "SUCCESS",
"clientName": "Acme Co",
"clientEmail": "hello@acme.com",
"customRef": "crm-record-8841",
"completedAt": "2026-07-13T09:41:25Z",
"connectionSummaryText": "Acme Co (hello@acme.com) connected 4 accounts: Facebook Ad Account \"Acme Co\", Facebook Page \"Acme Co\", Meta Pixel \"Acme Co Pixel\", Google Ads \"acme.com\".",
"connectionSummaryHtml": "<p><strong>Acme Co</strong> (hello@acme.com) connected 4 accounts:</p><ul><li>Facebook Ad Account — Acme Co</li><li>Facebook Page — Acme Co</li><li>Meta Pixel — Acme Co Pixel</li><li>Google Ads — acme.com</li></ul>",
"assets": [
{
"platform": "facebook",
"type": "ad_account",
"name": "Acme Co",
"externalId": "act_1086344871362999",
"permission": "manage",
"status": "GRANTED"
},
{
"platform": "facebook",
"type": "page",
"name": "Acme Co",
"externalId": "104217693118841",
"permission": "manage",
"status": "GRANTED"
},
{
"platform": "facebook",
"type": "pixel",
"name": "Acme Co Pixel",
"externalId": "1265103317439916",
"permission": "view",
"status": "GRANTED"
},
{
"platform": "google",
"type": "ads_account",
"name": "acme.com",
"externalId": "734-201-8841",
"permission": "manage",
"status": "GRANTED"
}
]
}
}Field reference
| Field | Type | Description |
|---|---|---|
| event | string | Always "connection.completed". |
| createdAt | string (ISO 8601) | When the webhook was generated. |
| data.connectionId | string | Unique ID for this connection. Stable across retries — use it to deduplicate. |
| data.status | string | "SUCCESS" or "PARTIAL_SUCCESS". |
| data.clientName | string | The name your client entered in the connect flow. |
| data.clientEmail | string | The email address your client entered. |
| data.customRef | string | null | Your own ID, if the connect link included ?ref=. See below. |
| data.completedAt | string (ISO 8601) | When the client finished the flow. |
| data.connectionSummaryText | string | Human-readable one-line summary of everything that was connected. |
| data.connectionSummaryHtml | string | The same summary as ready-to-send HTML. |
| data.assets[] | array | One entry per requested asset. |
| data.assets[].platform | string | "facebook", "google", "shopify", or "linkedin". |
| data.assets[].type | string | Asset type, e.g. "ad_account", "page", "pixel", "ads_account". |
| data.assets[].name | string | The asset’s name in the client’s account. |
| data.assets[].externalId | string | The platform’s own ID for the asset. |
| data.assets[].permission | string | "view" or "manage" — the level you requested. |
| data.assets[].status | string | "GRANTED" or "NOT_GRANTED". |
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 the assets array.
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 data.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/your-agency?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: 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 intermittently.
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 = 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.data.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 = 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["data"]["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 = 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['data']['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 a delivery log for every attempt, with 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 data.connectionId.
Troubleshooting
- Nothing fired — the client abandoned the flow before finishing, or your workspace is on the Freelancer plan (webhooks are Agency-plan only).
- Missing fields — re-fetch a sample with Send test event; older captured samples may predate newer fields.
- Signature mismatch — you're hashing the parsed body instead of the raw body. See Verifying the signature.
- 403 on API calls — the API requires the Agency plan.