One-click access for influencer whitelisting

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

MakeZapiern8nGoHighLevel

Setup

  1. Create a custom webhook in your automation tool (Make, Zapier, n8n, …) and copy its URL.
    Hover to play
  2. In ClientInvite, go to Settings → API & Webhooks, paste the URL, and click Save.
    Hover to play
  3. 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

Headers
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...
FieldTypeDescription
X-ClientInvite-EventheaderThe event name, connection.completed.
X-ClientInvite-DeliveryheaderA unique ID for this delivery. It stays the same across retries — use it to deduplicate.
X-ClientInvite-Signatureheadersha256= followed by the HMAC-SHA256 of the raw body. See Verifying the signature.

Payload

connection.completed — example 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

FieldTypeDescription
eventstringAlways "connection.completed".
apiVersionintegerPayload version, currently 1.
timestampstring (ISO 8601)When the event was generated.
statusstring"SUCCESS" (every asset connected) or "PARTIAL_SUCCESS".
accessLevelstring"admin" if any connected asset has admin access, otherwise "view".
requestIdstringThe ID of the access link the client used.
requestNamestringThe access link’s name.
requestUrlstringThe access link’s URL.
clientIdstringThe client’s ID. Use it with GET /clients/{id}.
clientNamestringThe name your client entered in the connect flow.
clientEmailstring | nullThe email address your client entered.
clientSummaryUrlstringYour Clients page in the ClientInvite dashboard.
customRefstring | nullYour own ID, if the connect link included ?ref=. See below.
connectionSummaryTextstringA one-line, human-readable summary of what was connected.
connectionSummaryHtmlstringThe same summary as HTML, ready for an email.
assetCountintegerHow many assets were connected.
connectionAssets[]arrayOne entry per asset on the access link, including ones not connected.
connectionAssets[].idstring | nullThe platform’s own ID for the account (e.g. act_… or a Google Ads customer ID). null if not connected.
connectionAssets[].namestring | nullThe account’s name on the platform.
connectionAssets[].typestringe.g. "Meta ads", "Facebook page", "Meta pixel", "Google ads", "Google analytics".
connectionAssets[].platformstring"Meta", "Google", "LinkedIn" or "Shopify".
connectionAssets[].connectionStatusstring"Connected", "In progress" or "Not Connected".
connectionAssets[].accessLevelstringThe access your link asks for: "view", "admin" or "whitelisting".
connectionAssets[].timestring | nullWhen the client connected this asset.
connectionAssets[].linkToAssetnullReserved 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:

  1. Your HubSpot contact for Acme Co has record ID crm-record-8841.
  2. You send them your connect link with the ref appended: https://app.clientinvite.com/workspace/acme-agency/onboarding?ref=crm-record-8841
  3. 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

verify.js — Express
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

verify.py — Flask
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 "", 200

PHP

verify.php
<?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://, localhost and private IP addresses aren't allowed.
  • 403 on API calls — the API requires the Agency plan.

Next steps

Still stuck?

Contact support or use the chat bubble in your dashboard — we usually reply within a few hours.

Get started

It All Starts With a Delightful Onboarding Experience.

First impressions matter. Give your clients a feeling of trust and confidence in your services while you save time, money, and sanity with automated access.

Create your first link — it's free

No card required · Unlimited connections · Cancel anytime

Already a member? Log in