graphcomment/sdk-api-php

PHP wrapper for the GraphComment API

Maintainers

Package info

github.com/graphcomment/sdk-api-php

pkg:composer/graphcomment/sdk-api-php

Transparency log

Statistics

Installs: 293

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

v3.1.0 2026-07-31 08:33 UTC

README

Server-to-server PHP wrapper for the GraphComment API: register and authenticate your users on GraphComment (unidirectional SSO), read and update their profile, count a thread's comments, get a thread's JSON-LD, and export your site's comments back into your own database.

composer require graphcomment/sdk-api-php

Requirements: PHP 8.1+, Guzzle 7.15.1+ (earlier 7.x releases carry published advisories, one of which silently downgrades an HTTPS proxy connection to cleartext), a GraphComment account with a paid plan, and your site's SSO key pair.

Getting your keys

In the GraphComment back-office (administrator access):

  1. Enable SSO in Settings → Authentication.
  2. Read your keys in Setup → Single Sign On:
    • your SSO public key — identifies your site in every call;
    • your SSO private key — signs every call. It must never leave your server.

Quick start

use Graphcomment\Sdk;

$client = new Sdk(GC_SSO_PUBLIC_KEY, GC_SSO_PRIVATE_KEY);

// Register a user (do this when they sign up on your site)
$response = $client->registerUser('username', 'user@example.com', 'fr', '');
// → {"gc_id": "...", "do_sync": 1690000000000}
// Store gc_id and do_sync in your database.

The SDK targets https://api.graphcomment.com/api by default. To point at another environment (e.g. a test platform), use setDir() — no code change needed:

$client->setDir('https://dev.graphcomment.com/api');

setDir() requires a valid HTTPS URL, without credentials in it, and throws an InvalidArgumentException otherwise: the signed token travels in the query string, so a plain-http call would put a valid credential on the wire in clear text.

Upgrading

3.0.x → 3.1.0 — transport hardening

The signed token is a credential carried in the URL, so v3.1.0 closes the ways it could escape. Two changes can break existing code:

  • A non-https base URL is now refused (InvalidArgumentException). If you passed http://… to setDir(), switch to https:// — that call was putting a usable credential on the wire in clear text, and it went unnoticed because the API answers a redirect to https, so the call still worked.
  • Guzzle 7.15.1 is now the minimum (was 7.8).

Redirects are no longer followed and transport errors no longer carry the token — see "Note on the transport" above.

2.x → 3.0.0 — breaking release

  • PHP 8.1+ and Guzzle 7 are required (was PHP 5.5 / Guzzle 6).
  • The default API host is now api.graphcomment.com (was graphcomment.com). Both currently serve the API; api. is the maintained reference. Use setDir() if you need to pin the old host.
  • Autoloading is PSR-4 and the class file is now src/Sdk.php. The class name is unchanged: Graphcomment\Sdk.
  • Methods now return a string (the raw response body). Previously they returned a PSR-7 stream object that you had to cast — if you already did (string) $result or echo $result, nothing changes.
  • Parameters are strictly typed: passing null where 2.x tolerated it (e.g. an absent avatar) now throws a TypeError — pass an empty string instead.
  • Signature encoding fix: the signed token is now percent-encoded in the URL. The 2.x SDK could fail intermittently with a 403 whenever the base64 payload contained a + character.
  • exportConfirmComments([]) no longer hangs: an empty confirmation returns "[]" immediately without calling the API (and since the July 2026 server update, the API itself answers "[]" instead of hanging).

How authentication works

Every call carries a signed token (ssoData) in the URL query string:

base64(json) + ' ' + hex(hmac_sha1(message + ' ' + timestamp, private_key)) + ' ' + timestamp
  • The signature is valid for about 3 minutes. The SDK signs each call with the current time, so this only matters if your server clock drifts: a drift of more than 3 minutes makes every call fail with 401 {"gcCode": 410, "msg": "route need authentication - route expired"}.
  • A wrong public key → 401 {"gcCode": 410, "msg": "the public key you provided is invalid"}.
  • A wrong private key → 403 {"err": "sso data you provided is invalid, ..."}.

Note on the transport. As a general rule, never put ssoData in a URL exposed to a browser (it ends up in logs and history). This SDK's server-to-server channel uses the URL query string as an assumed legacy of the existing API contract — it is acceptable here because the URL never transits through a user's browser, but don't reproduce this pattern in front-end code.

Because the token is a credential that lives in the URL, the SDK enforces three rules for you (since v3.1.0):

  • HTTPS onlysetDir() rejects any base URL that is not a valid https:// URL, so the token is never sent in clear text. Credentials embedded in that URL (https://user:pass@host) are rejected too: the API never asks for them, and they would survive in the request carried by an exception.
  • No redirect following — a redirect would hand the query string, hence a usable token, to the target host (and an httpshttp hop would expose it). Guzzle's built-in credential protection only covers the Authorization header, which does not apply to a token carried in the URL. A 3xx is therefore returned to you as-is (typically an empty body) instead of being followed.
  • Redacted transport errors — on a network failure, the token is stripped from every surface the exception exposes: message, carried request, and handler context (key=REDACTED). That covers $e->getMessage(), but also print_r($e, true) and serialize($e) — the handler context copies curl_getinfo(), which carries the full URL. Host, path and HTTP method are preserved for debugging.

Error handling

Methods return the raw response body whatever the HTTP status (network failures throw a GuzzleHttp\Exception\GuzzleException, and a non-UTF-8 payload throws a JsonException).

Error bodies are not uniform: authentication errors are JSON objects carrying an err or gcCode field, but a rejected registration (HTTP 409) can be a plain-text body (e.g. field email is required), and a deletion of an unknown user (HTTP 500) is a JSON-encoded string. The reliable pattern is therefore to check for the field you expect, not for the absence of an error field:

$data = json_decode($client->registerUser($username, $email), true);
if (!is_array($data) || !isset($data['gc_id'])) {
    // registration failed — the raw body describes why
}

The same applies to every method: token for loginUser, gc_id for getUser, count for countComments, comments for exportComments, the exact string ok for deleteUser.

Methods

registerUser(string $username, string $email, string $language = 'en', string $picture = ''): string

Registers a user on GraphComment. Returns {"gc_id": "...", "do_sync": ...} — store both in your database. Notes:

  • email is required and stored lowercased; username is required and may be adjusted server-side for uniqueness (the stored username can differ from the one you sent);
  • language is an ISO 639-1 two-letter code (anything else falls back to en);
  • picture must be a full URL, or empty;
  • validation failures come back with HTTP 409 and an error body.

loginUser(string $gc_id): string

Authenticates a user and returns {"token": "...", "refreshToken": "..."} (GraphComment JWTs used to open an authenticated widget session). An unknown gc_id returns a 404 error body.

getUser(string $gc_id): string

Returns {"gc_id", "username", "email", "language", "picture", "do_sync"}. If do_sync changed since your last read, re-synchronize the profile in your database.

Picture caveat: if the stored picture is an absolute URL (which is the case when it was set through registerUser/updateUser), the API returns picture as an empty string — an avatar URL you send at registration never comes back as-is.

updateUser(string $gc_id, string $username, string $email, string $language, string $picture): string

Updates the user's profile. Always send all five fields: the server compares each field with the stored value and only applies what changed. Known API limitation: the picture is only updated when its value is at least 10 characters long. Returns {"gc_id", "do_sync", "res": "updated"} or {"gc_id", "res": "nothing updated"}.

deleteUser(string $gc_id): string

Requests the deletion of the user's profile on your site. The deletion is asynchronous: the API answers with the plain-text body ok (not JSON) as soon as the request is queued — it means "deletion request registered", not "already deleted". An unknown gc_id yields an HTTP 500 error body.

countComments(string $url, string $uid = ''): string

Returns {"count": N} for the thread matching the page $url (and optional $uid). An unknown thread returns {"count": 0}; as of the July 2026 API update, a server-side failure returns HTTP 500 with {"err": "internal_error"}, and a call without a usable page identifier (empty $url and no $uid) returns HTTP 400 with {"err": "missing_parameter"} (this client does not throw on HTTP error statuses — check for the err key).

getThreadJsonLdFormat(string $url, string $uid = '', string $pageId = ''): string

Returns the JSON-LD (schema.org) markup of a thread, for server-side SEO rendering: a single NewsArticle object (with the thread's comments nested under comment) for a standard comment thread, or an array of documents (QAPage/DiscussionForumPosting followed by a BreadcrumbList) for a GraphDebate page. $pageId is optional and used by GraphDebate (integer page id; unknown ids are silently ignored). This call has no read side effects — it does not count a view in your audience statistics.

Requires the GraphComment API as of July 2026: earlier server versions answered with the raw thread object instead of JSON-LD on this transport.

exportComments(): string

Exports your site's comments, batch by batch, to import them into your own database. Returns {"comments": [...]} with at most 50 comments per call (20 root comments + 30 replies).

The export loop:

do {
    $body = json_decode($client->exportComments(), true);
    if (!is_array($body) || !array_key_exists('comments', $body)) {
        // authentication error (wrong key, expired signature…) or server
        // failure ({"err": "internal_error"}) — see "Error handling"
        throw new RuntimeException('export failed: ' . json_encode($body));
    }
    $batch = $body['comments'];
    foreach ($batch as $comment) {
        // insert or update the comment in your database
    }
    $client->exportConfirmComments(array_column($batch, '_id'));
} while ($batch !== []);

A reply can show up in a batch before its parent has been exported: in that case its parent_id field is absent from the payload — re-attach it on a later run rather than assuming it is a root comment.

Comments come back again on later runs when they changed (status change, edit, account deletion) — do an upsert, not a blind insert. Confirming each batch with exportConfirmComments() is what moves the export cursor forward: without confirmation, the same comments are returned forever.

Failure handling: as of the July 2026 API update, an empty list reliably means "no data left" — a server-side failure answers HTTP 500 with {"err": "internal_error"} instead of an empty list. This client does not throw on HTTP error statuses: treat a body without a comments key as an error (that is what the loop above does). Each call also carries an artificial ~300 ms latency.

exportConfirmComments(array $commentIds): string

Confirms that you imported a batch. Returns a JSON array [{"_id": "...", "result": "ok"}, ...] (result is "ko" with a message for malformed ids). An empty $commentIds returns "[]" immediately, without an HTTP call.

Privacy note

getUser and exportComments return your users' email addresses in clear text. This flow is reserved to the site owner (every call is signed with your private key), and covers data you already hold as the operator of your own site's accounts.

Tests

composer install
composer test               # unit tests (offline)
composer test:integration   # integration tests — set GC_TEST_DIR, GC_TEST_PUBKEY, GC_TEST_SECRET first

Without a local PHP, use Docker from the repository root:

docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app php:8.3-cli vendor/bin/phpunit --testsuite unit

Integration tests must target a test environment (see setDir()), never your production site: they create and modify real users.

License

MIT