clikgate / clikgate-php
The official Clikgate server SDK for PHP: send goals in three lines, keep the click ID, verify webhooks, with private mode built in.
Requires
- php: >=8.1
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- cyclonedx/cyclonedx-php-composer: ^6.2
- friendsofphp/php-cs-fixer: ^3.95
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.4 || ^13.4
- psr/http-server-middleware: ^1.0
Suggests
- ext-curl: The built-in transport (timeouts, proxies). Without it, any PSR-18 client found by php-http/discovery is used.
- clikgate/clikgate-laravel: Laravel integration: service provider, facade, middleware and queued sending.
- psr/http-server-middleware: For Clikgate\Psr15\ClikgateMiddleware and the /cg/keep handler (clickIds->keepRoute()).
Provides
None
Conflicts
None
Replaces
None
README
The official Clikgate server SDK for PHP. It:
- sends goals in three lines;
- keeps the click ID;
- verifies webhooks;
- has private mode built in.
It supports PHP 8.1+ and depends only on PSR interfaces and php-http/discovery. It sends with ext-curl when available, or with any PSR-18 client.
composer require clikgate/clikgate-php
$clikgate = new \Clikgate\Clikgate(); // reads CLIKGATE_API_KEY (cg_live_… or cg_test_…) $clikgate->goal('account_opened', ['clickId' => $clickId, 'reference' => 'APP-55231', 'value' => '2000000']);
Laravel: composer require clikgate/clikgate-laravel (provider, facade, clikgate middleware and queued sending). See its README.
goal()is the one method for goals and payments. It never throws: failures go to the logger andonError.goals->send()waits for the API and returns the result (the wire JSON as an array):statusis aGoalStatusvalue,counted,waiting,receivedorrefused(withreason), plusgoal_kind,attributed_to, …receivedmeans the name isn't ticked as a goal in the dashboard yet (or the goal is archived): it is stored, not counted, and shows in "Seen from your app and website". Every status is a result, never an error, and none is retried. Goals work on every plan, up to the plan's allowance.
use Clikgate\Enum\GoalKind; use Clikgate\Enum\GoalStatus; use Clikgate\Enum\Provider; $event = $clikgate->goals->send('order_paid', ['reference' => 'T123456', 'value' => '7500', 'currency' => 'NGN', 'provider' => Provider::Paystack, 'clickId' => $clickId]); $status = GoalStatus::tryFrom($event['status']); // null for a status this release doesn't know if (GoalStatus::Waiting === $status && GoalKind::Payment === GoalKind::tryFrom($event['goal_kind'] ?? '')) { // a Payment goal waiting for its confirmation }
GoalKind, GoalStatus and Provider are backed enums generated from the API. provider also takes a provider-key string ('acme_pay') for a gateway added after this release.
Match by hashed email or phone (ad networks)
When the business has turned on matching by hashed email or phone for an ad network destination, a goal can carry your own SHA-256 hashes: the options hashedEmail, hashedPhoneE164 and hashedPhoneDigits (wire hashed_email, hashed_phone_e164, hashed_phone_digits). HashForAds::hash() makes them on your server (offline, no client needed) and returns only the options for the values you give, ready to add to the options of goal() or goals->send():
use Clikgate\HashForAds; $clikgate->goal('account_opened', [ 'reference' => 'APP-55231', 'clickId' => $clickId, ] + HashForAds::hash('ada.obi@example.com', '+234 803 123 4567'));
The email is trimmed and lowercased. The phone must include its country code (+234… or 00234…); a national number such as 0803 123 4567 is an InvalidRequestException (invalid_argument), and the SDK never guesses the country. A hashed value that isn't 64 lowercase hex characters is refused before sending (invalid_hashed_identifier). conversions->create() takes the same wire fields. Hashed values are never logged.
In private mode, hash on your own server: Clikgate never hashes for you there.
Identify your users
Two ways to tell Clikgate which of your users is using the app. Full guide: https://clikgate.com/docs/identify-users. The customer ID is your own user ID from your database, never an email, phone number or ID number. Anonymous users don't need either path.
Path A (default): identity token. Create the identity secret in Apps › your app › Identity secret and store it as CLIKGATE_IDENTITY_SECRET on your server only. IdentityToken::create($customerId, ['appId' => …]) (also $clikgate->createIdentityToken(), with the identitySecret / identitySecrets options) signs a token with no network call; return it in the responses the app already calls, and the app passes it to the mobile SDK's identifyUser(). Tokens last 30 days by default and are never force-refreshed. Throws PHP has no browser or mobile build, so there is nothing to refuse.
use Clikgate\IdentityToken; // sign-in and "current user" responses: one extra field return ['user' => $user, 'clikgateIdentityToken' => IdentityToken::create((string) $user->id)]; // reads CLIKGATE_IDENTITY_SECRET IdentityToken::create((string) $user->id, ['appId' => 'app_…']); // several apps: CLIKGATE_IDENTITY_SECRETS
Path B: installation ID, no secret or token. The app reads getInstallationId() and includes it in its sign-in request; your server calls identifyUser with the app ID, installation ID and customer ID, after sign-in and at every signed-in app open. Clikgate accepts installations active in the last 7 days.
// The app sends Clikgate.getInstallationId() with its sign-in request (null when tracking is off: skip) if ($installationId !== null) { $clikgate->identifyUser(['appId' => 'app_…', 'installationId' => $installationId, 'customerId' => (string) $user->id]); }
People: identify a user and label them
Link a click, an app installation or your customer ID to a person, and give them a label your team sees instead of the click ID (1-80 characters; Growth and up). From your server this is always verified. Goals take the same label, and isFirst from your server is what counts a new customer. Don't put information in labels that your customers wouldn't expect you to keep.
$clikgate->identifyUser(['clickId' => $order->clickId, 'customerId' => 'user_42', 'label' => 'Ada (VIP)']); $clikgate->goal('account_opened', ['clickId' => $clickId, 'reference' => 'APP-55231', 'isFirst' => true, 'label' => 'Ada (VIP)']);
Sending modes
sendMode |
What goal() does |
|---|---|
sync (default) |
Sends one request and waits (timeout 3 s per attempt, retried safely). |
after_response |
Collects events and sends them after fastcgi_finish_request() (PHP-FPM), else at shutdown, so the visitor never waits. |
background |
Queues events and sends a batch every batchSize events, on flush() and at shutdown. |
With Laravel, CLIKGATE_QUEUE=redis sends each goal from a queued job (recommended).
Keep the click ID
The pattern is: capture it on landing, save it with your own record, then send the goal later from any job.
$clickId = $clikgate->clickIds->fromRequest($request); // PSR-7, Symfony/Laravel, or null for the current request saveApplication('APP-55231', $clickId); // days later, when the account is approved: $clikgate->goal('account_opened', ['clickId' => $application['click_id'], 'reference' => 'APP-55231', 'value' => '2000000', 'currency' => 'NGN']);
- PSR-15 (Slim, Mezzio, …):
new \Clikgate\Psr15\ClikgateMiddleware($clikgate)sets the request attributeclikgate.click_idand keeps a?cgid=…in the first-party_cg_cgidcookie (needspsr/http-server-middleware). - By hand:
$response = $clikgate->clickIds->persist($response, $clickId, ['request' => $request]). PSR-7 responses are immutable, so use the returned one. - From payment payloads (Paystack, Flutterwave, Shopify, WooCommerce):
$clikgate->clickIds->fromPaymentMetadata($payload).
Keep the click ID longer (Safari)
Safari, and every browser on iPhone, keeps a click ID saved by a script for up to 7 days (sometimes less). When your server saves it, it lasts as long as you choose, up to the browser's limit.
Two things make your server save it:
- Persist it from the query when a visitor lands with
?cgid=…:ClikgateMiddlewareabove does it, orpersist()by hand. Both also set the_cg_kept=smarker, so goals reportkept_by: server_cookie. - The
/cg/keeproute for pages your server doesn't render itself (a static or cached landing page): adddata-server-endpoint="/cg/keep"to the web script, and the script posts the click ID from the page URL to that route once.
<script src="https://sdk.clikgate.com/v1/static/clikgate.js" data-web-key="cgw_test_…" data-server-endpoint="/cg/keep" async></script>
// Slim 4 (any PSR-15 stack): persist from the query on every page, and serve /cg/keep $app->add(new \Clikgate\Psr15\ClikgateMiddleware($clikgate, ['days' => 90])); $app->any('/cg/keep', $clikgate->clickIds->keepRoute(['days' => 90])); // POST only; it answers 405 itself
- Same server only. The route must be served by the same host as the website (same scheme, host and port), because the cookie is first-party. It accepts only a same-origin
POST(Sec-Fetch-Site: same-origin, else anOriginequal to the host), a JSON body of at most 1 KB with the same valid click ID inclick_idandpage_click_id, and answers204withCache-Control: no-store, private. It never redirects and never echoes or logs what it receives. - The cookie name comes from your configuration (
clickIdParam, elsecgid), never from the request.daysis 1 to 400 (default 30);Secureis on unless you pass'secure' => false. - The cookie is readable by the page on purpose (not
HttpOnly): the web script needs to read it. It only holds the click ID. - Without PSR-15:
$clikgate->clickIds->keepRouteCore(['days' => 90])->respond($method, $headers, $rawBody)returns['status' => …, 'headers' => [[name, value], …]]to send yourself. - Visitor context for goals:
$clikgate->clickIds->context($request)returnsclickId,keptBy(server_cookie,script_cookie,formor null),browserEngineandplatform. The SDK reads theUser-Agentto work these out; the User-Agent itself is never sent. Spread it into the goal options:$clikgate->goal('account_opened', ['reference' => 'APP-55231', ...$clikgate->clickIds->context($request)]). These three fields are allowed in private mode.
Money
valueis in normal units (₦2,000,000 is'2000000', never kobo) and is always sent as a decimal string.- It accepts a
stringor anint. Afloatthrows\InvalidArgumentException, because floats can't hold exact amounts.
Payment goals
A goal is Standard or Payment (GoalKind), set in the goal's settings, never in the call. A Payment goal counts only when a trusted source confirms it:
- Your website or app reports the payment with
goal()on the Payment goal, withproviderandreference(never an amount). It waits for the confirmation (status: waiting). - Your server confirms it, right after your own verification with the gateway: with the helpers below, or by sending the same Payment goal with the confirmed value,
$clikgate->goals->send('order_paid', ['reference' => 'T123456', 'value' => '7500', 'currency' => 'NGN', 'provider' => Provider::Paystack]). It is counted at once with aclickId, and otherwise waits for the report.
Clikgate counts the goal with the confirmed amount. The SDK never calls a gateway and never takes a gateway key. Never pass data that came from the browser or the app.
// Paystack: after your own GET https://api.paystack.co/transaction/verify/{reference} $verify = json_decode($paystackBody, true); if ('success' === ($verify['data']['status'] ?? null)) { // ...fulfil the order... $clikgate->payments->fromPaystack($verify, ['goal' => 'order_paid']); } // Flutterwave: after your own GET /v3/transactions/{id}/verify $clikgate->payments->fromFlutterwave($flutterwaveVerifyBody); // Monnify: after your own GET /api/v2/transactions/{transactionReference} $clikgate->payments->fromMonnify($monnifyStatusBody); // Stripe: a PaymentIntent you retrieved, or the Checkout Session from a webhook you verified $clikgate->payments->fromStripe($stripe->paymentIntents->retrieve($id)->toArray()); // Any other gateway, or a refund: the generic call $clikgate->payments->confirm(['provider' => Provider::Paystack, 'reference' => 'T_ref_001', 'status' => 'refunded', 'amount' => '2500', 'currency' => 'NGN']);
- The helpers read only the reference, status, amount, currency, time, test flag and
cg_click_idmetadata. They never read or send customer details or the whole response. The response can be the decoded array or the raw JSON text; JSON text keeps every digit exact. - Amounts are exact decimals in normal units, and floats are never used: Paystack
750000(kobo) is sent as'7500', and Stripe2000USD as'20'. - A helper returns the confirmation, or
nullwhen there is nothing to send (warningunrecognised_payment_response).buildFromPaystack()and the otherbuildFrom*()methods return the body without sending it. confirm()checksprovider,reference,status(success,partial,pending,failed,refunded,reversed),amountoramountMinor,currency,aliases(at most 5),clickId,goal,testandoccurredAtlocally, and throwsInvalidRequestExceptionbefore anything is sent.- In private mode every one of those fields is allowed. Anything extra is refused.
- Payments need the Starter plan or above: on Free the API answers
403 plan_feature_unavailable(PermissionException,getRequiredPlan()isstarter). providerandreferenceare required on a Payment goal (422 payment_goal_needs_confirmation, withmissing); the SDK can't check it, because the kind lives only in the goal's settings.conversions->create([... 'goal' => ...])on a Payment goal is the same confirmation and returns the goal event ($result['object'] === 'goal_event').
Webhooks
use Clikgate\Webhooks; $event = Webhooks::verify(file_get_contents('php://input'), $_SERVER['HTTP_CLIKGATE_SIGNATURE'] ?? null, getenv('CLIKGATE_WEBHOOK_SECRET'));
- Pass the raw body. Deliveries are at-least-once: dedupe on
$event['id']. - Use
Webhooks::verifyAny($body, $header, [$newSecret, $oldSecret])while you rotate your own secrets.
Private mode
- With
'privateMode' => 'auto'(the default), the SDK reads your organisation's setting fromGET /v1/server-sdk/configand caches it for 10 minutes (per PHP process). - When private mode is on, only
goal,clickId,code,reference,value,currency,isFirst,occurredAt,providerandconsentare sent. Anything else (metadata, emails, customer IDs, …) is refused before sending withPrivateModeFieldRejectedException, which names the field, never its value. - Payment confirmations allow their own fields (see Payments). References are redacted in logs. If the setting can't be loaded, the SDK assumes private mode is on.
Links
$link = $clikgate->links->create(['destination_url' => 'https://example.com/spring', 'og_title' => 'Spring sale']); $result = $clikgate->links->update($link['id'], ['destination_url' => 'https://example.com/summer']); if (!$result['applied']) { echo 'waiting for a second person: ', $result['change_request']['id']; } foreach ($clikgate->links->list(['limit' => 100]) as $link) { echo $link['short_url'], "\n"; } // Filter by your own link metadata (sent as metadata[seller_id]=sel_ada); analytics take it too. foreach ($clikgate->links->list(['metadata' => ['seller_id' => 'sel_ada']]) as $link) { echo $link['metadata']['seller_id'], "\n"; } $ada = $clikgate->api->analytics->analyticsSummary(from: '2026-10-01T00:00:00Z', to: '2026-11-01T00:00:00Z', metadata: ['seller_id' => 'sel_ada']);
A locked link throws LinkLockedException. $clikgate->api-><resource>-><operation>() exposes every other API-key operation ("advanced"), generated from the OpenAPI document and sent through the same pipeline.
Errors
Every exception extends Clikgate\Exception\ClikgateException (getErrorCode(), getHttpStatus(), getRequestId(), getParam(), getErrorType(), getRaw()): ConfigurationException, AuthenticationException, PermissionException, InvalidRequestException, PrivateModeFieldRejectedException, NotFoundException, ConflictException, LinkLockedException, IdempotencyException, RateLimitException (getRetryAfter()), ServerException, ClikgateNetworkException, ClikgateTimeoutException and SignatureVerificationException.
Network errors, timeouts and HTTP 429, 500, 502, 503 and 504 are retried up to maxRetries (3) with full-jitter backoff, honouring Retry-After. Each call sends a random Idempotency-Key, reused across its retries; the API ignores a second goal event with the same goal and reference, so a retry never double counts.
Options
new Clikgate([...]) takes apiKey, baseUrl, timeout (seconds, default 3), maxRetries, sendMode, privateMode, clickIdParam, logger (PSR-3), onError, httpClient (PSR-18), proxy (http(s)://user:pass@host:port or false), batchSize, maxQueueSize and shutdownHooks. An unknown option throws ConfigurationException.
- With the built-in transport (ext-curl),
timeoutandproxyapply, and libcurl readshttps_proxy/HTTPS_PROXY/NO_PROXY. - With your own PSR-18 client, configure its timeout and proxy on that client.
Licence: MIT. Security: see SECURITY.md.