graphcomment / sdk-api-php
PHP wrapper for the GraphComment API
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.15.1
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- phpunit/phpunit: ^10.5
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):
- Enable SSO in Settings → Authentication.
- 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 passedhttp://…tosetDir(), switch tohttps://— 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(wasgraphcomment.com). Both currently serve the API;api.is the maintained reference. UsesetDir()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) $resultorecho $result, nothing changes. - Parameters are strictly typed: passing
nullwhere 2.x tolerated it (e.g. an absent avatar) now throws aTypeError— 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
ssoDatain 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 only —
setDir()rejects any base URL that is not a validhttps://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
https→httphop would expose it). Guzzle's built-in credential protection only covers theAuthorizationheader, which does not apply to a token carried in the URL. A3xxis 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 alsoprint_r($e, true)andserialize($e)— the handler context copiescurl_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:
emailis required and stored lowercased;usernameis required and may be adjusted server-side for uniqueness (the stored username can differ from the one you sent);languageis an ISO 639-1 two-letter code (anything else falls back toen);picturemust 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 returnspictureas 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 acommentskey 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.