Skip to content
Documentation

Integration guide

Connect ClickFix to your own product end to end; identity verification, external ids, custom fields, companies, events, the API channel, webhooks, system binding and the mobile webview. Widget, Node.js, Go, PHP and curl samples at every step.

This is the single integration guide: in the order the Setup wizard in the panel follows, it covers everything you write to connect your own product to ClickFix. Every step has five samples: the widget in the browser (JavaScript), Node.js (@getclickfix/node), Go (the sdks/go module), PHP (clickfix/sdk) and curl. The samples come from the repository's sdks/examples folder and are executed on every release; the code you see here is the code that passed.

Before you start

You needWhereUsed for
API key (cf_sk_…)Organization → API and automations → API keysEvery call from your server (Authorization: Bearer)
Widget identity secretWeb widget → Security → Identity verificationComputing user_hash (user + company id), server side only
Widget id (WIDGET_ID)Web widget → Installwidget.js and the mobile webview URL
API channel instance and signing secretChannels → Add channel → APIOnly when you connect your own chat app as a channel

The base URL is https://api.getclickfix.com (no /v1 suffix). Writes require Idempotency-Key. Keys never reach the browser or the mobile app. Envelope, error codes and rate limits: API.

Packages

PackageVersionInstall
widget.js (SDK 2.1)https://getclickfix.com/widget.js, types at https://getclickfix.com/widget.d.ts<script src="https://getclickfix.com/widget.js" data-widget="WIDGET_ID" async></script>
@getclickfix/node0.2.1npm install @getclickfix/node (Node 22+)
@getclickfix/visitor-core0.1.1npm install @getclickfix/visitor-core (React Native, Node 22+, browsers; no DOM)
@getclickfix/react-widget0.1.2npm install @getclickfix/react-widget (React 18+)
Go modulego/v0.1.0go get github.com/ClickSolveMainTR/clickfix-sdks/go@v0.1.0
clickfix/sdk0.1.0composer require clickfix/sdk guzzlehttp/guzzle (PHP 8.1+, any PSR-18 client)
clickfix/laravel0.1.0composer require clickfix/laravel (Laravel 10-13, PHP 8.1+); see Laravel package

Each package keeps its changelog in CHANGELOG.md. In 0.x a minor version may carry a breaking change; the changelog marks it Breaking. To run the samples against your own environment set CLICKFIX_BASE_URL, CLICKFIX_API_KEY, CLICKFIX_IDENTITY_SECRET, CLICKFIX_CHANNEL_INSTANCE and CLICKFIX_CHANNEL_SECRET.

The right setup order

The Setup wizard in the panel has five steps; the sections of this guide follow the same order. Open each step in the panel first, then write its code.

Wizard stepIn this guideIn the panel
1. Introduce the customerIdentity verification, external ids, custom fields, company, eventsWeb widget → Security; Settings → Custom fields
2. Connect your systemSystem bindingIntegrations → Connect your system
3. Teach the AINext stepsAI → Procedures
4. Knowledge baseNext stepsKnowledge base
5. Automation and SLAWebhooks, next stepsAutomation, SLA

The API channel and the mobile webview are channel setup; do them after introducing the customer, before or after system binding.

1. Identity verification: user_hash

user_hash is the only way to introduce a signed-in user to the widget with proof:

# user that belongs to a company (identify sends company.id): both ids are signed together
user_hash = hex(HMAC-SHA256(widget_identity_secret, user_id + "\n" + company_id))

# no company: the user id alone
user_hash = hex(HMAC-SHA256(widget_identity_secret, user_id))

user_id is the user's id in your system and is the external id of the next section; company_id is the company's id in your system and goes to identify as company: { id }. The value is computed on your server only; your site and your mobile app receive the finished user_hash, never the secret. A contact verified with a valid user_hash gets the Verified badge in the panel and their anonymous history moves to the same contact. When the company id is signed, the contact is bound to that company by the signed value, the company gets the Verified badge in the panel too, and a verified user cannot claim another company; the identify event reports company_verified: true. When identify is rejected the widget keeps working but the user stays anonymous, and the error event carries WIDGET_IDENTITY_DISABLED (no secret generated yet) or WIDGET_IDENTITY_INVALID (mismatch, including a hash that signs another company id). Neither id may contain a line break (\n).

Migration. The original user_hash over user_id alone keeps working (backward compatible). When such a call also carries company.id, the user is still verified but the company id is not linked and not stored: company.name and company.domain are used only as matching hints, the widget prints a fixed console warning and the identify event reports company_verified: false. To bind the company by its id, add company_id to the signature on your server: Node identityHash(secret, userId, companyId), Go IdentityCompanyHash(secret, userID, companyID); the PHP and Laravel helpers get the same formula in their own releases.

// Adım 1: sunucunuzdan aldığınız user_hash ile giriş yapmış kullanıcıyı tanıtın.
// @ts-check
ClickFix("boot", { widgetId: "WIDGET_ID", locale: "tr" });

fetch("/api/support-session") // sizin ucunuz: { user_id, user_hash, company_id } döner
  .then((r) => r.json())
  .then((session) =>
    ClickFix("identify", {
      user_id: session.user_id, // kişinin dış kimliği
      // Firma ile: hex(HMAC-SHA256(anahtar, user_id + "\n" + company_id)), sunucuda.
      // Firmasız: hex(HMAC-SHA256(anahtar, user_id)).
      user_hash: session.user_hash,
      name: "Ayşe Yılmaz",
      email: "ayse@example.com",
      company: { id: session.company_id }, // imzadaki firma kimliği
    }),
  );

ClickFix("on", "error", (e) => {
  if (e.code === "WIDGET_IDENTITY_INVALID")
    console.warn("user_hash eşleşmedi:", e.message);
});
// Adım 1: user_hash sunucuda hesaplanır ve giriş yapmış kullanıcıya verilir.
import { identityHash } from "@getclickfix/node";

// Panel > Web widget > Güvenlik > Kimlik doğrulama anahtarı (bir kez gösterilir)
const secret = process.env.CLICKFIX_IDENTITY_SECRET!;

// Oturum açmış kullanıcının sizin sisteminizdeki kimliği (dış kimlik) ve
// firmasının kimliği (identify'daki company.id). Firma verilirse imza ikisini
// birden kapsar: hex(HMAC-SHA256(anahtar, user_id + "\n" + company_id)).
export function userHashFor(userId: string, companyId?: string): string {
  return identityHash(secret, userId, companyId);
}

// Örnek: /api/support-session uç noktanız bunu döner, tarayıcı identify'a verir
console.log(
  JSON.stringify({
    user_id: "1001",
    user_hash: userHashFor("1001"), // firmasız: eski formül
    company_id: "F-77",
    company_user_hash: userHashFor("1001", "F-77"), // company: { id: "F-77" } ile gönderilir
  }),
);
package main

import (
	"context"
	"encoding/json"
	"os"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 1: user_hash sunucuda hesaplanır; anahtar tarayıcıya ve uygulamaya gitmez.
func userHash(ctx context.Context) error {
	secret := os.Getenv("CLICKFIX_IDENTITY_SECRET") // Panel > Web widget > Güvenlik
	userID := "1001"                                // kullanıcının sizin sisteminizdeki kimliği
	companyID := "F-77"                             // firmasının kimliği (identify: company.id)
	return json.NewEncoder(os.Stdout).Encode(map[string]string{
		"user_id":   userID,
		"user_hash": clickfix.IdentityHash(secret, userID), // firmasız: eski formül
		// Firma ile: imza user_id + "\n" + company_id üzerinden; company: { id } ile gönderilir.
		"company_id":        companyID,
		"company_user_hash": clickfix.IdentityCompanyHash(secret, userID, companyID),
	})
}
<?php
// Adım 1: user_hash sunucuda hesaplanır ve giriş yapmış kullanıcıya verilir.
require __DIR__.'/vendor/autoload.php'; // composer require clickfix/sdk

use ClickFix\UserHash;

// Panel > Web widget > Güvenlik > Kimlik doğrulama anahtarı (bir kez gösterilir)
$secret = getenv('CLICKFIX_IDENTITY_SECRET');

// Oturum açmış kullanıcının sizin sisteminizdeki kimliği (dış kimlik)
function userHashFor(string $secret, string $userId): string
{
    return UserHash::make($secret, $userId);
}

// Örnek: /api/support-session uç noktanız bunu döner, tarayıcı identify'a verir
echo json_encode(['user_id' => '1001', 'user_hash' => userHashFor($secret, '1001')]), PHP_EOL;
# Adım 1: user_hash = hex(HMAC-SHA256(kimlik_anahtari, user_id)); yalnız sunucuda.
USER_ID="1001"
printf '%s' "$USER_ID" | openssl dgst -sha256 -hmac "$CLICKFIX_IDENTITY_SECRET" | sed 's/^.* //'
# Firma ile (company: { id }): imza user_id + "\n" + company_id üzerinden hesaplanır.
COMPANY_ID="F-77"
printf '%s\n%s' "$USER_ID" "$COMPANY_ID" | openssl dgst -sha256 -hmac "$CLICKFIX_IDENTITY_SECRET" | sed 's/^.* //'

If you Rotate the secret, values made with the old one stop working; update the secret on your server at the same time. On sites that need cookie consent pass it with boot({ consent }) or ClickFix("consent", true); nothing is written to the browser before consent.

2. External ids: contact and company

The ids of your own system live in ClickFix as the external id (external_id). Contact and company external ids are separate fields, independent of each other and unique within the organization:

RecordAPI fieldWidget identify counterpartIn system binding
Contactexternal_iduser_id{external_id}
Companyexternal_idcompany.id{company_external_id}

PUT /v1/public-api/contacts/by-external-id creates or updates the contact by external id (201 created, 200 updated). Matching is by external id only; no other contact is taken over through e-mail or phone. An id that belongs to another contact returns 409 EXTERNAL_ID_EXISTS. If you create the contact through the API first and the user later arrives through identify, no new contact is opened: the user is linked to the same contact, and a conversation an agent started before the customer visited the site (with contact_external_id) appears in the widget once the user signs in.

// Adım 2: kişi ve şirket için ayrı dış kimlik. user_id kişinin, company.id şirketin.
// @ts-check
ClickFix("identify", {
  user_id: "1001", // API: contacts.external_id
  user_hash: "SUNUCUDA_HESAPLANAN", // user_id + "\n" + company.id üzerinden imzalı
  company: { id: "F-77", name: "Acme A.Ş.", domain: "acme.com.tr" }, // API: companies.external_id
});
ClickFix("on", "identify", ({ verified, company_verified }) =>
  console.log("doğrulandı:", verified, "firma:", company_verified),
);
// Adım 2: kişiyi dış kimliğiyle oluştur/güncelle, şirketi dış kimliğiyle bul.
import { createClient } from "@getclickfix/node";

const client = createClient({
  baseURL: process.env.CLICKFIX_BASE_URL!, // https://api.getclickfix.com
  apiKey: process.env.CLICKFIX_API_KEY!, // contacts:read, contacts:write, companies:read
});

// Aynı dış kimlikle ikinci çağrı yeni kişi açmaz, günceller (200); ilki 201 döner.
const contact = await client.request(
  "/v1/public-api/contacts/by-external-id",
  "put",
  { external_id: "1001", name: "Ayşe Yılmaz", email: "ayse@example.com" },
  { idempotencyKey: `user-1001-${Date.now()}` },
);

// Şirketin dış kimliği kişininkinden ayrıdır (widget: company.id).
const companies = await client.request(
  "/v1/public-api/companies",
  "get",
  undefined,
  { query: { external_id: "F-77" } },
);

console.log(
  JSON.stringify({ contact: contact.data, companies: companies.data }),
);
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 2: kişiyi dış kimliğiyle oluştur/güncelle, şirketi dış kimliğiyle bul.
func externalID(ctx context.Context) error {
	client, err := clickfix.New(os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_API_KEY"))
	if err != nil {
		return err
	}
	name, email := "Ayşe Yılmaz", "ayse@example.com"
	key := fmt.Sprintf("user-1001-%d", time.Now().UnixNano())
	res, err := client.PutV1PublicApiContactByExternalIdWithResponse(ctx,
		&clickfix.PutV1PublicApiContactByExternalIdParams{IdempotencyKey: key},
		clickfix.PutV1PublicApiContactByExternalIdJSONRequestBody{ExternalId: "1001", Name: &name, Email: &email})
	if err != nil {
		return err
	}
	if res.StatusCode() != 200 && res.StatusCode() != 201 { // 200 güncellendi, 201 oluşturuldu
		return fmt.Errorf("clickfix: HTTP %d", res.StatusCode())
	}
	ext := "F-77" // şirketin dış kimliği (widget identify: company.id)
	companies, err := client.GetV1PublicApiCompaniesWithResponse(ctx, &clickfix.GetV1PublicApiCompaniesParams{ExternalId: &ext})
	if err != nil {
		return err
	}
	return json.NewEncoder(os.Stdout).Encode(map[string]any{"contact": res.StatusCode(), "companies": companies.StatusCode()})
}
<?php
// Adım 2: kişiyi dış kimliğiyle oluştur/güncelle, şirketi dış kimliğiyle bul.
require __DIR__.'/vendor/autoload.php'; // composer require clickfix/sdk guzzlehttp/guzzle

use ClickFix\Client;

$client = new Client(
    getenv('CLICKFIX_BASE_URL'), // https://api.getclickfix.com
    getenv('CLICKFIX_API_KEY'),  // contacts:read, contacts:write, companies:read
);

// Aynı dış kimlikle ikinci çağrı yeni kişi açmaz, günceller (200); ilki 201 döner.
$contact = $client->contacts()->upsertByExternalId(
    '1001',
    ['name' => 'Ayşe Yılmaz', 'email' => 'ayse@example.com'],
    idempotencyKey: 'user-1001-'.(int) (microtime(true) * 1000),
);

// Şirketin dış kimliği kişininkinden ayrıdır (widget: company.id).
$company = $client->companies()->findByExternalId('F-77');

echo json_encode(['contact' => $contact, 'company' => $company]), PHP_EOL;
# Adım 2: kişiyi dış kimliğiyle oluştur/güncelle (201 oluşturuldu, 200 güncellendi).
curl -sS --fail-with-body -X PUT "$CLICKFIX_BASE_URL/v1/public-api/contacts/by-external-id" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: user-1001-$(date +%s)" \
  -d '{ "external_id": "1001", "name": "Ayşe Yılmaz", "email": "ayse@example.com" }'

# Şirketi kendi dış kimliğiyle bul (kişi dış kimliğinden ayrıdır).
curl -sS --fail-with-body "$CLICKFIX_BASE_URL/v1/public-api/companies?external_id=F-77" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY"

The identity_state field of contact and conversation responses is verified, anonymous or self_named; webhook events carry contact_external_id, contact_verified, company_uuid and company_external_id, so you match your own record without another request.

3. Custom fields

Define a custom field once under Settings → Custom fields; defining it is the only step. Whatever the field's key is, send that key both in custom_fields through the API and in setAttributes / identify({ attributes }) in the widget. Values are validated by the field's type; list fields accept the option id or its label and store the id. The field's widget access decides whether anonymous visitors may write it (default: verified users only). An undefined key is not dropped silently: it is listed in the panel as an undefined field warning and applied to the contact once you define the field. When the visitor has no contact yet the widget result is pending: true and the values travel with the first message.

// Adım 3: özel alanlar. Anahtar panelde tanımlı alanın anahtarıdır; tanımsız
// anahtar "tanımsız alan" uyarısı olarak kalır ve alanı tanımlayınca işlenir.
// @ts-check
ClickFix("setAttributes", {
  plan: "pro",
  seats: 12,
  renewal_date: "2027-01-31",
});
ClickFix("on", "attributes", ({ applied, ignored, pending }) => {
  console.log({ applied, ignored, pending }); // pending: ilk mesajla gönderilecek
});
// Adım 3: özel alanlar. Alanı panelde tanımlayın (Ayarlar > Özel alanlar);
// anahtarı neyse custom_fields içinde o anahtarla gönderin.
import { createClient } from "@getclickfix/node";

const client = createClient({
  baseURL: process.env.CLICKFIX_BASE_URL!,
  apiKey: process.env.CLICKFIX_API_KEY!,
});

const contact = await client.request(
  "/v1/public-api/contacts/by-external-id",
  "put",
  {
    external_id: "1001",
    // Liste alanlarında seçeneğin kimliği ya da etiketi kabul edilir.
    custom_fields: { plan: "pro", seats: 12, renewal_date: "2027-01-31" },
  },
  { idempotencyKey: `user-1001-fields-${Date.now()}` },
);

console.log(JSON.stringify(contact.data));
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 3: özel alanlar; anahtar panelde tanımladığınız alanın anahtarıdır.
func customFields(ctx context.Context) error {
	client, err := clickfix.New(os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_API_KEY"))
	if err != nil {
		return err
	}
	fields := map[string]any{"plan": "pro", "seats": 12, "renewal_date": "2027-01-31"}
	res, err := client.PutV1PublicApiContactByExternalIdWithResponse(ctx,
		&clickfix.PutV1PublicApiContactByExternalIdParams{IdempotencyKey: fmt.Sprintf("user-1001-fields-%d", time.Now().UnixNano())},
		clickfix.PutV1PublicApiContactByExternalIdJSONRequestBody{ExternalId: "1001", CustomFields: &fields})
	if err != nil {
		return err
	}
	if res.StatusCode() != 200 && res.StatusCode() != 201 {
		return fmt.Errorf("clickfix: HTTP %d", res.StatusCode())
	}
	return json.NewEncoder(os.Stdout).Encode(map[string]int{"status": res.StatusCode()})
}
<?php
// Adım 3: özel alanlar. Alanı panelde tanımlayın (Ayarlar > Özel alanlar);
// anahtarı neyse custom_fields içinde o anahtarla gönderin.
require __DIR__.'/vendor/autoload.php';

use ClickFix\Client;

$client = new Client(getenv('CLICKFIX_BASE_URL'), getenv('CLICKFIX_API_KEY'));

// Liste alanlarında seçeneğin kimliği ya da etiketi kabul edilir.
$contact = $client->customFields()->setForContact(
    '1001',
    ['plan' => 'pro', 'seats' => 12, 'renewal_date' => '2027-01-31'],
    idempotencyKey: 'user-1001-fields-'.(int) (microtime(true) * 1000),
);

echo json_encode($contact), PHP_EOL;
# Adım 3: özel alanlar; anahtar panelde tanımlı alanın anahtarıdır.
curl -sS --fail-with-body -X PUT "$CLICKFIX_BASE_URL/v1/public-api/contacts/by-external-id" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: user-1001-fields-$(date +%s)" \
  -d '{ "external_id": "1001", "custom_fields": { "plan": "pro", "seats": 12, "renewal_date": "2027-01-31" } }'

4. Company

A company is created or matched through company in the widget's identify call: the first key is company.id (the company's external id; used only when user_hash signs it too, see identity verification), the second is domain. A company bound through a signed id carries the Verified badge in the panel. Through the API a company is found with GET /v1/public-api/companies?external_id=; three customer-health signals are sent against it: usage (PUT /company-usage, 0–100), revenue (PUT /company-revenue) and product events (POST /company-events, next section). In all three the company is selected by company_uuid, external_id or domain.

// Adım 4: şirket identify ile oluşur ya da dış kimliğiyle eşleşir (ikinci anahtar: domain).
// company.id yalnız user_hash onu da imzalıyorsa bağlanır (user_id + "\n" + company.id).
// @ts-check
ClickFix("boot", {
  widgetId: "WIDGET_ID",
  user: {
    user_id: "1001",
    user_hash: "SUNUCUDA_HESAPLANAN", // user_id + "\n" + "F-77" üzerinden
    company: { id: "F-77", name: "Acme A.Ş.", domain: "acme.com.tr" },
  },
});
// Adım 4: şirket. Şirket widget identify (company.id) ile oluşur; API ile
// bulunur ve müşteri sağlığı sinyalleri (kullanım, gelir, olay) gönderilir.
import { createClient } from "@getclickfix/node";

const client = createClient({
  baseURL: process.env.CLICKFIX_BASE_URL!,
  apiKey: process.env.CLICKFIX_API_KEY!, // companies:read, health:write
});

const found = await client.request(
  "/v1/public-api/companies",
  "get",
  undefined,
  {
    query: { external_id: "F-77" },
  },
);

// 0-100 arası kullanım sinyali; 30 gün boyunca sağlık puanına katılır.
const usage = await client.request(
  "/v1/public-api/company-usage",
  "put",
  { external_id: "F-77", score: 82 },
  // İşlemi tanımlayan sabit değer: kendi ölçüm kaydınızın kimliği (örnekte zaman damgası)
  { idempotencyKey: `usage-F-77-${Date.now()}` },
);

console.log(JSON.stringify({ found: found.data, usage: usage.data }));
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 4: şirketi bul ve kullanım sinyali gönder (health:write).
func company(ctx context.Context) error {
	client, err := clickfix.New(os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_API_KEY"))
	if err != nil {
		return err
	}
	ext := "F-77"
	found, err := client.GetV1PublicApiCompaniesWithResponse(ctx, &clickfix.GetV1PublicApiCompaniesParams{ExternalId: &ext})
	if err != nil {
		return err
	}
	usage, err := client.PutV1PublicApiCompanyUsageWithResponse(ctx,
		// İşlemi tanımlayan sabit değer: kendi ölçüm kaydınızın kimliği (örnekte zaman damgası)
		&clickfix.PutV1PublicApiCompanyUsageParams{IdempotencyKey: fmt.Sprintf("usage-F-77-%d", time.Now().UnixNano())},
		clickfix.PutV1PublicApiCompanyUsageJSONRequestBody{ExternalId: &ext, Score: 82})
	if err != nil {
		return err
	}
	if usage.StatusCode() != 200 {
		return fmt.Errorf("clickfix: HTTP %d", usage.StatusCode())
	}
	return json.NewEncoder(os.Stdout).Encode(map[string]int{"found": found.StatusCode(), "usage": usage.StatusCode()})
}
<?php
// Adım 4: şirket. Şirket widget identify (company.id) ile oluşur; API ile
// bulunur ve müşteri sağlığı sinyalleri (kullanım, gelir, olay) gönderilir.
require __DIR__.'/vendor/autoload.php';

use ClickFix\Client;

$client = new Client(
    getenv('CLICKFIX_BASE_URL'),
    getenv('CLICKFIX_API_KEY'), // companies:read, health:write
);

$found = $client->companies()->findByExternalId('F-77');

// 0-100 arası kullanım sinyali; 30 gün boyunca sağlık puanına katılır.
$usage = $client->companies()->setUsage(
    ['external_id' => 'F-77', 'score' => 82],
    // İşlemi tanımlayan sabit değer: kendi ölçüm kaydınızın kimliği (örnekte zaman damgası)
    idempotencyKey: 'usage-F-77-'.(int) (microtime(true) * 1000),
);

echo json_encode(['found' => $found, 'usage' => $usage]), PHP_EOL;
# Adım 4: şirket kullanım sinyali (0-100, health:write); şirket external_id ile bulunur.
curl -sS --fail-with-body -X PUT "$CLICKFIX_BASE_URL/v1/public-api/company-usage" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: usage-F-77-$(date +%s%N)" \
  -d '{ "external_id": "F-77", "score": 82 }'

5. Events and screen context

There are two separate event channels:

  • Screen context (widget): the last 20 events you record with ClickFix("track", name, properties) and the page URL travel with the visitor's next message. In the panel they appear in the conversation header as which screen they wrote from, and they feed the AI agent's input and workflow conditions. Query strings and # fragments are dropped on the server; still, keep personal data out of track properties.
  • Product events (API, events:write): POST /v1/public-api/company-events writes to the company timeline; contact_email links the event to a known contact and idempotency_key stops the same event from being written twice (duplicate: true).
// Adım 5: ekran bağlamı. Son 20 olay ve sayfa adresi ziyaretçinin mesajıyla
// gider; panelde "hangi ekrandan yazdı" ve AI'ın girdisi olur.
// @ts-check
ClickFix("track", "order_viewed", { order_id: "SP-1042", total: 1250.5 });
ClickFix("track", "payment_failed", { reason: "insufficient_funds" });
// Kullanıcı destek isterse konuşmayı hazır metinle açın:
ClickFix(
  "startConversation",
  "SP-1042 numaralı siparişimde ödeme hatası alıyorum",
);
// Adım 5: olaylar. Ekran bağlamı widget'tan (track) gelir; ürün olayları
// şirket zaman çizelgesine API ile yazılır (events:write).
import { createClient } from "@getclickfix/node";

const client = createClient({
  baseURL: process.env.CLICKFIX_BASE_URL!,
  apiKey: process.env.CLICKFIX_API_KEY!,
});

// Olayı sizin sisteminizde tanımlayan kimlik; tekrar gönderimde aynı kalır (örnekte zaman damgası)
const eventId = `seat-limit-F-77-${Date.now()}`;

const event = await client.request(
  "/v1/public-api/company-events",
  "post",
  {
    external_id: "F-77", // şirketin dış kimliği (ya da company_uuid / domain)
    contact_email: "ayse@example.com", // olayı bilinen kişiye bağlar
    name: "seat_limit_reached",
    properties: { seats: 12, limit: 12 },
    idempotency_key: eventId,
  },
  { idempotencyKey: eventId },
);

console.log(JSON.stringify(event.data));
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 5: ürün olayını şirket zaman çizelgesine yaz (events:write).
func events(ctx context.Context) error {
	client, err := clickfix.New(os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_API_KEY"))
	if err != nil {
		return err
	}
	// Olayı sizin sisteminizde tanımlayan kimlik; tekrar gönderimde aynı kalır (örnekte zaman damgası)
	ext, email, idem := "F-77", "ayse@example.com", fmt.Sprintf("seat-limit-F-77-%d", time.Now().UnixNano())
	props := map[string]any{"seats": 12, "limit": 12}
	res, err := client.PostV1PublicApiCompanyEventWithResponse(ctx,
		&clickfix.PostV1PublicApiCompanyEventParams{IdempotencyKey: idem},
		clickfix.PostV1PublicApiCompanyEventJSONRequestBody{
			ExternalId: &ext, ContactEmail: &email, Name: "seat_limit_reached", Properties: &props, IdempotencyKey: &idem,
		})
	if err != nil {
		return err
	}
	if res.StatusCode() != 200 && res.StatusCode() != 201 {
		return fmt.Errorf("clickfix: HTTP %d", res.StatusCode())
	}
	return json.NewEncoder(os.Stdout).Encode(map[string]int{"status": res.StatusCode()})
}
<?php
// Adım 5: olaylar. Ekran bağlamı widget'tan (track) gelir; ürün olayları
// şirket zaman çizelgesine API ile yazılır (events:write).
require __DIR__.'/vendor/autoload.php';

use ClickFix\Client;

$client = new Client(getenv('CLICKFIX_BASE_URL'), getenv('CLICKFIX_API_KEY'));

// Olayı sizin sisteminizde tanımlayan kimlik; tekrar gönderimde aynı kalır (örnekte zaman damgası)
$eventId = 'seat-limit-F-77-'.(int) (microtime(true) * 1000);

// idempotency_key aynı zamanda Idempotency-Key başlığı olur.
$event = $client->events()->track([
    'external_id' => 'F-77', // şirketin dış kimliği (ya da company_uuid / domain)
    'contact_email' => 'ayse@example.com', // olayı bilinen kişiye bağlar
    'name' => 'seat_limit_reached',
    'properties' => ['seats' => 12, 'limit' => 12],
    'idempotency_key' => $eventId,
]);

echo json_encode($event), PHP_EOL;
# Adım 5: ürün olayını şirket zaman çizelgesine yaz (events:write).
# Olayı sizin sisteminizde tanımlayan kimlik; tekrar gönderimde aynı kalır (örnekte zaman damgası)
EVENT_ID="seat-limit-F-77-$(date +%s%N)"
curl -sS --fail-with-body -X POST "$CLICKFIX_BASE_URL/v1/public-api/company-events" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $EVENT_ID" \
  -d '{ "external_id": "F-77", "contact_email": "ayse@example.com", "name": "seat_limit_reached", "properties": { "seats": 12, "limit": 12 }, "idempotency_key": "'$EVENT_ID'" }'

6. System binding

The Integrations → Connect your system screen lets ClickFix call your system; it is not an API in the other direction. You only write an endpoint that answers by the customer's external id; the rest is set up in the panel:

  1. Connection: your system's base URL (public https only) and authentication (none, Bearer, custom header or username/password). The secret is stored encrypted, never shown again and masked in logs.
  2. Concepts: the objects of your system (Invoice, Order, License…). Each concept is a custom object type.
  3. Source: which endpoint the concept is read from, relative to the base URL: /customers/{external_id}/invoices. {external_id} is the customer's id in your system, {company_external_id} their company's; one of them must appear in the path. Try calls it with a real id; you pick where the records are in the answer (items) and which fields the AI sees by clicking on the answer.
  4. Actions: operations such as "Resend invoice": method, endpoint, parameters, whether approval is needed and who may use it (AI, agent, workflow).
  5. AI playbooks: for one intent, which concepts to read and which actions may run.

ClickFix calls public hosts only (private networks, localhost and cloud metadata addresses are refused), follows no redirects, caps the response at 256 KB and makes at most 60 calls per minute per organization. The shape of your endpoint is yours; the sample below answers a source set up in the panel as /customers/{external_id}/invoices with record path items. The curl sample is the exact call the panel's Try button makes.

// Adım 8: sistem bağlama için widget'ta ek bir şey yoktur; ClickFix sisteminizi
// identify'daki user_id (dış kimlik) ile çağırır. Doğru kimliği verdiğinizden emin olun:
// @ts-check
ClickFix("on", "ready", () => {
  const state = ClickFix("getState");
  if (state && !state.verified)
    console.warn("identify yapılmadı: sistem verisi eşleşmez");
});
// Adım 8: sistem bağlama. ClickFix sizin sisteminizi çağırır; siz yalnız
// müşterinin dış kimliğiyle cevap veren bir uç yazarsınız. Panelde
// Entegrasyonlar > Sistemini bağla > Kaynak: /customers/{external_id}/invoices
import { createServer } from "node:http";

const token = process.env.SYSTEM_TOKEN ?? "example-system-token"; // bağlantıda Bearer

createServer((req, res) => {
  if (req.headers.authorization !== `Bearer ${token}`) {
    res.writeHead(401).end();
    return;
  }
  const m = /^\/customers\/([^/]+)\/invoices$/.exec(req.url ?? "");
  if (!m) {
    res.writeHead(404).end();
    return;
  }
  const externalId = decodeURIComponent(m[1]);
  // Gerçek sisteminizde: veritabanından externalId'nin faturaları
  const items = [
    {
      number: "F-2026-0042",
      total: 1250.5,
      currency: "TRY",
      status: "paid",
      customer_id: externalId,
    },
  ];
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ items })); // Kayıt yolu: items
}).listen(Number(process.env.PORT ?? 3000), () => console.log("listening"));
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

// Adım 8: sistem bağlama. ClickFix bu ucu müşterinin dış kimliğiyle çağırır:
// Panel > Entegrasyonlar > Sistemini bağla > Kaynak: /customers/{external_id}/invoices
func systemEndpoint(ctx context.Context) error {
	token := os.Getenv("SYSTEM_TOKEN") // bağlantıda Bearer olarak girilir
	if token == "" {
		token = "example-system-token"
	}
	http.HandleFunc("GET /customers/{external_id}/invoices", func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer "+token {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		externalID := r.PathValue("external_id")
		// Gerçek sisteminizde: veritabanından externalID'nin faturaları
		items := []map[string]any{{"number": "F-2026-0042", "total": 1250.5, "currency": "TRY", "status": "paid", "customer_id": externalID}}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{"items": items}) // Kayıt yolu: items
	})
	port := strings.TrimSpace(os.Getenv("PORT"))
	if port == "" {
		port = "3000"
	}
	fmt.Println("listening")
	return http.ListenAndServe("127.0.0.1:"+port, nil)
}
<?php
// Adım 8: sistem bağlama. ClickFix sizin sisteminizi çağırır; siz yalnız
// müşterinin dış kimliğiyle cevap veren bir uç yazarsınız. Panelde
// Entegrasyonlar > Sistemini bağla > Kaynak: /customers/{external_id}/invoices
// Çalıştırma: php -S 127.0.0.1:3000 08-system-endpoint.php (ya da framework rotanız)

$token = getenv('SYSTEM_TOKEN') ?: 'example-system-token'; // bağlantıda Bearer

if (($_SERVER['HTTP_AUTHORIZATION'] ?? '') !== 'Bearer '.$token) {
    http_response_code(401);
    exit;
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '';
if (!preg_match('#^/customers/([^/]+)/invoices$#', $path, $m)) {
    http_response_code(404);
    exit;
}
$externalId = rawurldecode($m[1]);
// Gerçek sisteminizde: veritabanından externalId'nin faturaları
$items = [
    [
        'number' => 'F-2026-0042',
        'total' => 1250.5,
        'currency' => 'TRY',
        'status' => 'paid',
        'customer_id' => $externalId,
    ],
];
header('Content-Type: application/json');
echo json_encode(['items' => $items]); // Kayıt yolu: items
# Adım 8: ClickFix'in sisteminize atacağı çağrının aynısı (Sistemini bağla > Dene düğmesi).
# SYSTEM_BASE_URL sizin sisteminiz, {external_id} müşterinin dış kimliğidir.
curl -sS --fail-with-body "$SYSTEM_BASE_URL/customers/1001/invoices" \
  -H "Authorization: Bearer $SYSTEM_TOKEN"

Concept data is read by the customer's external id, so system binding only works once external ids are in place. The panel's Try as this customer preview shows what is accepted for an external id, which concepts are read and the context the AI sees.

7. API channel

Use the API channel to connect your own chat app or system as a channel; a website does not need it, the widget already is a channel. The channel is created under Channels → Add channel → API; you get an inbound address (/v1/webhooks/channels/api/{instance}), a signing secret and a field for the address agent replies are delivered to.

  • Customer message → ClickFix: POST /v1/webhooks/channels/api/{instance} with { id, sender_id, sender_name?, text, thread_id?, reply_to?, at? }. The request is verified with the channel's signing secret, not the API key: X-ClickFix-Timestamp (Unix seconds) and X-ClickFix-Signature: sha256= + hex(HMAC-SHA256(signing_secret, timestamp + "." + raw_body)). sender_id is the customer's id in your system; the same id is stored once.
  • Agent reply → you: ClickFix POSTs the apiChannelReply body to the address configured on the channel, with the same signature headers and an Idempotency-Key carrying the message UUID. Return 2xx after storing the message; 4xx other than 408 and 429 counts as permanent. Verification is the same as for webhooks (sha256= instead of v1=).
// Adım 6: web'de API kanalı gerekmez; widget zaten kanaldır. Kendi arayüzünüz
// varsa @getclickfix/react-widget'ın arayüzsüz istemcisini kullanın.
// @ts-check
ClickFix("on", "message", (m) => {
  // Temsilci veya AI yanıtı: kendi bildirim sisteminize aktarabilirsiniz
  console.log(m.conversation_uuid, m.sender_type, m.text);
});
// Adım 6: API kanalı. Kendi uygulamanızdaki müşteri mesajını ClickFix'e
// gönderin; imza API anahtarıyla değil kanalın imza anahtarıyla atılır.
import { createHmac } from "node:crypto";

const base = process.env.CLICKFIX_BASE_URL!;
const instance = process.env.CLICKFIX_CHANNEL_INSTANCE!; // Kanallar > API kanalı
const secret = process.env.CLICKFIX_CHANNEL_SECRET!;

const body = JSON.stringify({
  id: `msg-${Date.now()}`, // kanal içinde tekil; aynı id bir kez kaydedilir
  sender_id: "1001", // müşterinin sizin sisteminizdeki kimliği
  sender_name: "Ayşe Yılmaz",
  text: "Siparişim ne zaman gelir?",
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signature =
  "sha256=" +
  createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex");

const res = await fetch(`${base}/v1/webhooks/channels/api/${instance}`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-ClickFix-Timestamp": timestamp,
    "X-ClickFix-Signature": signature,
  },
  body,
});
if (!res.ok) throw new Error(`ClickFix ${res.status}`);
console.log(JSON.stringify(await res.json()));
package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"time"
)

// Adım 6: API kanalına müşteri mesajı gönder; imza kanalın imza anahtarıyla.
func apiChannel(ctx context.Context) error {
	base, instance, secret := os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_CHANNEL_INSTANCE"), os.Getenv("CLICKFIX_CHANNEL_SECRET")
	body, _ := json.Marshal(map[string]string{
		"id":          fmt.Sprintf("msg-%d", time.Now().UnixNano()), // kanal içinde tekil
		"sender_id":   "1001",                                       // müşterinin sizin sisteminizdeki kimliği
		"sender_name": "Ayşe Yılmaz",
		"text":        "Siparişim ne zaman gelir?",
	})
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(body)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/webhooks/channels/api/"+instance, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-ClickFix-Timestamp", timestamp)
	req.Header.Set("X-ClickFix-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("clickfix: HTTP %d", res.StatusCode)
	}
	return json.NewEncoder(os.Stdout).Encode(map[string]int{"status": res.StatusCode})
}
<?php
// Adım 6: API kanalı. Kendi uygulamanızdaki müşteri mesajını ClickFix'e
// gönderin; imza API anahtarıyla değil kanalın imza anahtarıyla atılır.
require __DIR__.'/vendor/autoload.php';

use ClickFix\ApiChannel;

$channel = new ApiChannel(
    getenv('CLICKFIX_BASE_URL'),
    getenv('CLICKFIX_CHANNEL_INSTANCE'), // Kanallar > API kanalı
    getenv('CLICKFIX_CHANNEL_SECRET'),
);

// X-ClickFix-Timestamp ve X-ClickFix-Signature başlıklarını SDK ekler.
$received = $channel->send([
    'id' => 'msg-'.(int) (microtime(true) * 1000), // kanal içinde tekil; aynı id bir kez kaydedilir
    'sender_id' => '1001', // müşterinin sizin sisteminizdeki kimliği
    'sender_name' => 'Ayşe Yılmaz',
    'text' => 'Siparişim ne zaman gelir?',
]);

echo json_encode($received), PHP_EOL;
# Adım 6: API kanalına müşteri mesajı; imza kanalın imza anahtarıyla (API anahtarı değil).
BODY='{"id":"msg-'$(date +%s)'","sender_id":"1001","sender_name":"Ayşe Yılmaz","text":"Siparişim ne zaman gelir?"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$CLICKFIX_CHANNEL_SECRET" | sed 's/^.* //')
curl -sS --fail-with-body -X POST "$CLICKFIX_BASE_URL/v1/webhooks/channels/api/$CLICKFIX_CHANNEL_INSTANCE" \
  -H "Content-Type: application/json" \
  -H "X-ClickFix-Timestamp: $TS" \
  -H "X-ClickFix-Signature: sha256=$SIG" \
  -d "$BODY"

8. Webhooks

Webhooks deliver events (new conversation, incoming message, contact update, SLA breach, satisfaction…) to you as signed POSTs; no polling. Create the subscription with the webhooks:write scope through POST /v1/public-api/webhooks; data.secret is returned in that response only. The URL must be public https. Every delivery carries ClickFix-Webhook-Id, ClickFix-Webhook-Event, ClickFix-Webhook-Timestamp and ClickFix-Webhook-Signature: v1=…. Verification: hex(HMAC-SHA256(secret, timestamp + "." + raw_body)) over the raw body, constant-time comparison, five minutes tolerance, deduplication by ClickFix-Webhook-Id; then 2xx. Failed deliveries are retried with growing intervals for up to 24 hours. Event list and body format: Webhooks.

// Adım 7: tarayıcı tarafında webhook yoktur; konuşma olaylarını SDK verir.
// @ts-check
ClickFix("on", "conversation:started", ({ conversation_uuid }) =>
  console.log("yeni", conversation_uuid),
);
ClickFix("on", "conversation:ended", ({ conversation_uuid, status }) =>
  console.log(conversation_uuid, status),
);
ClickFix("on", "csat", ({ score }) => console.log("memnuniyet", score));
// Adım 7: webhook aboneliği aç ve teslimatları ham gövde üzerinde doğrula.
import { createServer } from "node:http";
import { createClient, verifyWebhook } from "@getclickfix/node";

const client = createClient({
  baseURL: process.env.CLICKFIX_BASE_URL!,
  apiKey: process.env.CLICKFIX_API_KEY!, // webhooks:write
});

const created = await client.request(
  "/v1/public-api/webhooks",
  "post",
  {
    name: "CRM senkronizasyonu",
    url: "https://example.com/clickfix/webhook",
    events: ["conversation.created", "message.received", "contact.updated"],
    enabled: true,
  },
  { idempotencyKey: `webhook-crm-${Date.now()}` },
);
// data.secret yalnız bu yanıtta döner: güvenli saklayın (ör. gizli değer deposu),
// loglamayın. Doğrulama sunucunuz onu oradan okur.
const { secret: issued, ...subscription } = created.data as {
  secret: string;
  uuid: string;
};
const secret = process.env.CLICKFIX_WEBHOOK_SECRET ?? issued;
console.log(JSON.stringify({ subscription }));

createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  const raw = Buffer.concat(chunks);
  const ok = verifyWebhook(
    secret,
    String(req.headers["clickfix-webhook-timestamp"] ?? ""),
    String(req.headers["clickfix-webhook-signature"] ?? ""),
    raw,
  );
  if (!ok) {
    res.writeHead(401).end();
    return;
  }
  // ClickFix-Webhook-Id ile tekilleştirin, kaydedin, sonra 2xx dönün.
  const event = JSON.parse(raw.toString()) as { event: string; data: unknown };
  console.log(JSON.stringify({ received: event.event }));
  res.writeHead(200).end();
}).listen(Number(process.env.PORT ?? 3000), () => console.log("listening"));
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	clickfix "github.com/ClickSolveMainTR/clickfix-sdks/go"
)

// Adım 7: webhook aboneliği aç ve teslimatları ham gövde üzerinde doğrula.
func webhooks(ctx context.Context) error {
	client, err := clickfix.New(os.Getenv("CLICKFIX_BASE_URL"), os.Getenv("CLICKFIX_API_KEY"))
	if err != nil {
		return err
	}
	res, err := client.PostV1PublicApiWebhooksWithResponse(ctx,
		&clickfix.PostV1PublicApiWebhooksParams{IdempotencyKey: fmt.Sprintf("webhook-crm-%d", time.Now().UnixNano())},
		clickfix.PostV1PublicApiWebhooksJSONRequestBody{
			Name: "CRM senkronizasyonu", Url: "https://example.com/clickfix/webhook",
			Events: []string{"conversation.created", "message.received", "contact.updated"}, Enabled: true,
		})
	if err != nil {
		return err
	}
	if res.StatusCode() != 201 {
		return fmt.Errorf("clickfix: HTTP %d", res.StatusCode())
	}
	var created struct {
		Data struct {
			Secret string `json:"secret"` // yalnız bu yanıtta döner
		} `json:"data"`
	}
	if err := json.Unmarshal(res.Body, &created); err != nil {
		return err
	}
	// Yalnız bu yanıtta döner: güvenli saklayın, loglamayın; sunucu onu oradan okur.
	secret := os.Getenv("CLICKFIX_WEBHOOK_SECRET")
	if secret == "" {
		secret = created.Data.Secret
	}
	fmt.Println(`{"subscription":"created"}`)

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		if err := clickfix.VerifyWebhook(secret, r.Header.Get("ClickFix-Webhook-Timestamp"),
			r.Header.Get("ClickFix-Webhook-Signature"), raw, time.Now(), 5*time.Minute); err != nil {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		// ClickFix-Webhook-Id ile tekilleştirin, kaydedin, sonra 2xx dönün.
		var event struct {
			Event string `json:"event"`
		}
		_ = json.Unmarshal(raw, &event)
		fmt.Printf(`{"received":%q}`+"\n", event.Event)
		w.WriteHeader(http.StatusOK)
	})
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	fmt.Println("listening")
	return http.ListenAndServe("127.0.0.1:"+port, nil)
}
<?php
// Adım 7: webhook aboneliği aç ve teslimatları ham gövde üzerinde doğrula.
// Komut satırından çalışınca aboneliği açar; web sunucusu altında
// (php -S 127.0.0.1:3000 07-webhooks.php ya da framework rotanız) teslimatları doğrular.
require __DIR__.'/vendor/autoload.php';

use ClickFix\Client;
use ClickFix\Webhooks;

if (PHP_SAPI === 'cli') {
    $client = new Client(
        getenv('CLICKFIX_BASE_URL'),
        getenv('CLICKFIX_API_KEY'), // webhooks:write
    );
    $created = $client->webhooks()->create([
        'name' => 'CRM senkronizasyonu',
        'url' => 'https://example.com/clickfix/webhook',
        'events' => ['conversation.created', 'message.received', 'contact.updated'],
        'enabled' => true,
    ], idempotencyKey: 'webhook-crm-'.(int) (microtime(true) * 1000));
    // secret yalnız bu yanıtta döner: güvenli saklayın (ör. gizli değer deposu),
    // loglamayın. Doğrulama sunucunuz onu oradan okur (örnekte CLICKFIX_WEBHOOK_SECRET).
    unset($created['secret']);
    echo json_encode(['subscription' => $created]), PHP_EOL;
    exit;
}

$secret = getenv('CLICKFIX_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // ham gövde; JSON'a çevirmeden doğrulayın
$ok = Webhooks::verify(
    $secret,
    $_SERVER['HTTP_CLICKFIX_WEBHOOK_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_CLICKFIX_WEBHOOK_SIGNATURE'] ?? '',
    $raw,
);
if (!$ok) {
    http_response_code(401);
    exit;
}
// ClickFix-Webhook-Id ile tekilleştirin, kaydedin, sonra 2xx dönün.
$event = json_decode($raw, true);
error_log(json_encode(['received' => $event['event'] ?? null]));
http_response_code(200);
# Adım 7: webhook aboneliği (webhooks:write). data.secret yalnız bu yanıtta döner.
curl -sS --fail-with-body -X POST "$CLICKFIX_BASE_URL/v1/public-api/webhooks" \
  -H "Authorization: Bearer $CLICKFIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-crm-$(date +%s)" \
  -d '{ "name": "CRM senkronizasyonu", "url": "https://example.com/clickfix/webhook", "events": ["conversation.created", "message.received", "contact.updated"], "enabled": true }'

For Zapier, Make and n8n, POST /v1/public-api/subscriptions (REST hook) and GET /v1/public-api/triggers (cursor polling) use the same infrastructure: Zapier, Make and n8n.

9. Mobile: a secure webview that carries the session

The path for mobile apps is not a separate native SDK but a secure webview that carries the session: the app opens https://getclickfix.com/widget-embed.html?widget=WIDGET_ID in a webview; the widget opens full screen and talks to the app over a bridge. Flutter, React Native, iOS, Android and every other platform connect the same way, with the same contract. The full bridge contract (message names, fields, error codes, security rules) is a separate document: Webview bridge.

Session handoff

  1. The app fetches { user_id, user_hash, company_id } from your own server (identity verification); user_hash signs the user id and the company id together, and the secret never reaches the app.
  2. The webview loads widget-embed.html?widget=WIDGET_ID&locale=en&theme=auto. When the widget is ready, {"type":"clickfix:ready", ...} arrives over the bridge.
  3. The app sends {"type":"clickfix:identify","id":"1","user":{"user_id":"1001","user_hash":"…","name":"…","company":{"id":"F-77"}}}; company.id is the company id in the signature (bridge contract 1.2, bridge_version 1). The answer is {"type":"clickfix:result","id":"1","ok":true}. When rejected, ok:false and error.code (such as WIDGET_IDENTITY_INVALID) come back and the user stays anonymous.
  4. On logout send {"type":"clickfix:reset"}; the visitor is forgotten and the next launch is a new visitor.

The webview's cookies and storage carry the session across reloads; never switch accounts without sending reset.

Session handover with the native core (CF-201). An app can also call the widget endpoints without a webview: create an app key with the platform and package name under Web widget → Advanced → Mobile apps; @getclickfix/visitor-core with mode: "app" sends it in the X-ClickFix-App-Key and X-ClickFix-App-Package headers, so no page origin is needed. Hand the native session to the webview with {"type":"clickfix:session","token":client.getToken()}; the widget answers with its own token in a clickfix:session message after ready, identify and reset (client.adoptToken). The key is shown only when created and can be revoked in the panel; user_hash verification is the same as in the browser.

The bridge: app to widget and widget to app

App → widget (commands): clickfix:identify, clickfix:track (adds an app event to the screen context), clickfix:open (view or conversation), clickfix:close, clickfix:update (language, theme, profile, attributes), clickfix:consent, clickfix:reset, clickfix:get-state. Every command may carry an id; when it does, the widget answers with clickfix:result. A command arrives through window.ClickFixBridge.receive(json) or a postMessage to the page.

Widget → app: clickfix:ready, clickfix:event (every SDK event: message, unread, close, conversation:ended…), clickfix:link (a link to open outside; use the system browser) and clickfix:result. The widget picks the transport by platform: ReactNativeWebView.postMessage, iOS webkit.messageHandlers.ClickFix, the Android ClickFixAndroid interface, the Flutter ClickFixFlutter channel.

The host-side samples below set up the bridge, hand over the session when the widget is ready, send an app event and close the support screen on the widget's close event. They are not part of the guide's automated test; build and verify them in your own project.

// Package: clickfix_webview (sdks/flutter/clickfix_webview; a git dependency until the first release). The package sets up
// the bridge, the origin lock and the ClickFixFlutter channel; you hand over the session.
import 'package:clickfix_webview/clickfix_webview.dart';
import 'package:url_launcher/url_launcher.dart';

final controller = ClickFixController(
  widgetId: 'WIDGET_ID',
  locale: 'en',
  conversation: conversationUuid, // optional deep link
);

ClickFixWebView(
  controller: controller,
  onReady: (_) async {
    final s = await api.supportSession(); // { user_id, user_hash } from your server
    try {
      await controller.identify(ClickFixIdentity(
        userId: s.userId, userHash: s.userHash, company: ClickFixCompany(id: s.companyId)));
      await controller.track('app_screen', properties: {'screen': 'orders'});
    } on ClickFixBridgeError catch (e) {
      debugPrint('identity rejected: ${e.code}'); // e.g. WIDGET_IDENTITY_INVALID; the visitor stays anonymous
    }
  },
  onUnread: (count) => badge.value = count,
  onLink: (link) => launchUrl(link.url, mode: LaunchMode.externalApplication), // https only
  onClose: () => Navigator.of(context).pop(),
);
// On logout: controller.reset(); when the screen closes: controller.dispose().
  • Open a specific conversation: load …/widget-embed.html?widget=WIDGET_ID&conversation=CONVERSATION_UUID, or send {"type":"clickfix:open","conversation":"CONVERSATION_UUID"} to an open webview. view picks the tab (home, messages, help, new).
  • From a notification to the conversation: subscribe to the message.sent webhook event for your app's own push notification; the event carries conversation_uuid and contact_external_id. When the notification's deep link (myapp://support?conversation=…) opens, load the webview with the URL above. The widget's unread event (clickfix:event) is enough for the badge.
  • Out of the widget: help articles and links in messages arrive as clickfix:link; open them in the system browser. Do not allow navigation away from getclickfix.com inside the webview.

Security notes

  • The identity secret never enters the app. user_hash comes from an authenticated endpoint on your server; it is neither computed nor hard-coded in the app.
  • Origin restriction: the webview loads https://getclickfix.com only (or the app URL given to you); navigation elsewhere is blocked, and a clickfix:link URL is re-validated in the app (https: only) before it opens in the system browser; never hand it to an intent or deep-link handler. Disable third-party and shared cookie stores; disable file and content access (allowFileAccess, allowContentAccess).
  • JS bridge: window.ClickFixBridge.receive exists on this page only, and the page belongs to ClickFix. Messages arriving by postMessage are accepted from the configured parent origin only when framed (boot({ bridge: { origin } })) and from the page itself only when not framed; "*" is refused. Rejected commands never write the message content (for example user_hash) to the console or the result.
  • Cookies and storage: the session lives in the webview's storage; send clickfix:reset on account switch and logout. On shared devices clear the webview storage too.
  • Consent: if your app collects consent, add consent=1 to the URL or send clickfix:consent; nothing is stored without it.

The Flutter package clickfix_webview (sdks/flutter/clickfix_webview; ClickFixWebView + ClickFixController, bridge contract v1, origin lock, deep links) will be published to pub.dev by the sdk-publish CI job with its first release; until then use it as a git dependency in pubspec.yaml: clickfix_webview: { git: { url: https://github.com/ClickSolveMainTR/clickfix-app-go.git, path: sdks/flutter/clickfix_webview, ref: main } }. The native Dart client (clickfix_visitor) and the other native SDKs are separate projects; when they ship they will use this same bridge contract.

Laravel package

For Laravel 10, 11, 12 and 13 (PHP 8.1+) clickfix/laravel packs the steps of this guide: a service provider and the ClickFix facade, config/clickfix.php, artisan commands, a signature-checking POST /clickfix/webhook route with one Laravel event per ClickFix event, and the @clickfixWidget Blade directive. Package discovery registers the provider. Source and changelog: clickfix-laravel.

Install

composer require clickfix/laravel
php artisan clickfix:install
php artisan clickfix:check

clickfix:install publishes config/clickfix.php, adds the missing CLICKFIX_* keys to .env (generating the webhook secret), and prints the webhook URL to subscribe plus the next steps. Once the keys are filled in, clickfix:check tries the API key against the API and verifies the widget id, the identity secret, the webhook secret and that the route is https; anything missing exits with code 1.

.env keyWhere
CLICKFIX_API_KEYThe API key from the Before you start table
CLICKFIX_WIDGET_IDWeb widget → Install
CLICKFIX_IDENTITY_SECRETWeb widget → Security → Identity verification; stays on the server
CLICKFIX_WEBHOOK_SECRETThe subscription's data.secret, or the value clickfix:install generated (use it in the subscription)
CLICKFIX_WEBHOOK_QUEUEEmpty: events fire inside the request; true or a queue name: from a queued job

Artisan commands

CommandWhat it does
clickfix:install [--force] [--no-env]Publish the config, add the .env keys, generate the webhook secret, print the webhook URL
clickfix:checkAPI key, widget id, identity secret, webhook secret and route; exit code 1 on failure
clickfix:user-hash {user} [--raw] [--json]user_hash for a user (model key), to debug WIDGET_IDENTITY_INVALID
clickfix:webhook:secret [--write]64 hex character secret; --write stores it in .env
clickfix:sync:contacts [--id=*] [--since=] [--chunk=] [--dry-run]Create or update the model in config('clickfix.contacts') as contacts by external id (PUT /contacts/by-external-id, contacts:write)

clickfix:sync:contacts reads the model (App\Models\User), the field map (name, email, phone, job_title, locale, timezone → model attribute or dotted path) and the custom field map from the config. The external id is always the model's primary key, the same value identify sends as user_id, so the synced contact and the widget session are one person (external id).

Widget and identity verification

    @clickfixWidget
</body>

The directive prints the queue snippet, the ClickFix("boot", {...}) call and the widget.js tag. Without arguments it identifies the authenticated user (user_id is the model key, user_hash is computed on the server with the identity secret, name and email come from the model); with nobody logged in the widget opens anonymously. @clickfixWidget($customer) takes a specific model, @clickfixWidget(null) is always anonymous, @clickfixWidget(auth()->user(), ['locale' => 'en']) adds boot options. The fields in the identity come from clickfix.widget.user, or the model implements ProvidesClickFixIdentity (including company and attributes).

For Inertia, Livewire or a SPA that boots the widget itself, build the identity on the server and share it with the page:

use ClickFix\Laravel\Facades\ClickFix;

ClickFix::userHash($user);  // hex HMAC-SHA256(identity secret, model key)
ClickFix::identity($user);  // ['user_id' => '42', 'user_hash' => '…', 'name' => …, 'email' => …]

Call ClickFix("identify", identity) in the browser; the identity secret never leaves the server (identity verification).

Webhook

The package registers POST /clickfix/webhook outside the web group (no session, no CSRF) behind the clickfix.webhook middleware. The middleware verifies ClickFix-Webhook-Timestamp and ClickFix-Webhook-Signature over the raw body (the rule in Webhooks; 5 minute tolerance, constant-time comparison); a bad or stale signature is 401, a missing secret 503. A redelivery with the same ClickFix-Webhook-Id is recognised from the cache and acknowledged with 200 without firing the events twice.

Every verified delivery raises ClickFix\Laravel\Events\WebhookReceived; a known event name also raises its own class: ConversationCreated, ConversationResolved, MessageReceived, MessageSent, ContactCreated, ContactUpdated, CompanyUpdated, SlaBreached, CsatSubmitted … (one class per event; an unknown name raises only WebhookReceived).

use ClickFix\Laravel\Events\MessageReceived;
use Illuminate\Support\Facades\Event;

Event::listen(MessageReceived::class, function (MessageReceived $event) {
    $event->get('conversation_uuid');
    $event->get('contact_external_id'); // your user id
    $event->payload->occurredAt();      // Carbon
});

With CLICKFIX_WEBHOOK_QUEUE=true (or a queue name) the events fire from the ProcessWebhook job instead of the request and the route answers 202. To define the route yourself set CLICKFIX_WEBHOOK_ROUTE=false and attach the clickfix.webhook middleware to your own route. Creating the subscription: Webhooks.

Next steps

The remaining steps of the setup wizard are panel work, not code:

  • Teach the AI: AI. The concepts and actions of system binding feed the AI playbooks.
  • Knowledge base: Knowledge base. The widget's help tab and the AI read from it.
  • Automation and SLA: Automation. Workflow conditions can read custom fields and screen context; for outbound actions use webhooks.

Other references: API, SDKs and sample code, Web widget, Webhooks, API reference.