diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fb9fa9..06f5851 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- README: OpenAPI spec link now points to `/docs/customer/openapi.json` (was `swagger.json`).
+- `examples/paginate_messages.php` rewritten as incremental sync — the `lastMessageHash` cursor is inclusive and the API has no page size.
+
+### Documentation
+
+- README: how the `lastMessageHash` cursor behaves (oldest-first, inclusive, no page size).
+- README: 402 plan-limit and 403 `ACTION_FORBIDDEN` errors surface as `ApiException`; branch on `getApiCode()`.
+- README: required token type (`customer` / `both`, server-side only) and where to create it.
+- README: known limitation — response `meta` (warnings, `meta.updated`) is not exposed.
+
## [0.1.0] - 2026-07-17
### Added
diff --git a/README.md b/README.md
index 31acb8c..2ba7301 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@ composer require stromcom/customer-sdk
## API access & documentation
-- **Reference docs:**
-- **OpenAPI spec:**
+- **Reference docs (Swagger UI):**
+- **OpenAPI spec:**
API access is gated by your STROMCOM subscription — only plans that
explicitly include API access can issue Customer API tokens and call
@@ -23,6 +23,11 @@ the endpoints below. If your token returns `401 Unauthorized` or your
project's `allowAPI` flag is `false`, check your current plan in the
STROMCOM console or contact support to upgrade.
+The token must be an API key of type **`customer`** (or `both`), created
+in the customer panel under *Project → Credentials*
+(`/customer/project/{id}/credentials/`). It grants full access to the
+project — use it **server-side only**, never in browser or mobile code.
+
## Quick start
```php
@@ -108,7 +113,7 @@ covering each area:
| [`users_lifecycle.php`](examples/users_lifecycle.php) | Create → fetch → update → delete → restore. |
| [`thread_with_messages.php`](examples/thread_with_messages.php) | Thread + messages + attachments in one call. |
| [`create_thread_and_message.php`](examples/create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. |
-| [`paginate_messages.php`](examples/paginate_messages.php) | Cursor pagination via `lastMessageHash`. |
+| [`paginate_messages.php`](examples/paginate_messages.php) | Incremental sync via the `lastMessageHash` cursor. |
| [`download_attachment.php`](examples/download_attachment.php) | Resolve and save attachments to disk. |
| [`message_reactions.php`](examples/message_reactions.php) | List emoji reactions on a message, aggregated by code. |
| [`error_handling.php`](examples/error_handling.php) | Catch the typed exception hierarchy. |
@@ -168,6 +173,20 @@ AttachmentDownload $stromcom->messages()->attachmentDownload(string $threa
list $stromcom->messages()->reactions(string $threadCode, string $messageHash);
```
+### Message cursor (`lastMessageHash`)
+
+`messages()->list($threadCode, $lastMessageHash)` is not page-based:
+
+- messages are returned **oldest-first**;
+- the cursor is **inclusive** — the first returned message is the cursor
+ message itself;
+- there is **no page size** — the server returns every message from the
+ cursor onward (limited only by your plan's history window).
+
+Use it for incremental sync: store the hash of the newest message you
+have, fetch from it next time and drop the first item when it equals the
+cursor. See [`examples/paginate_messages.php`](examples/paginate_messages.php).
+
## Configuration
```php
@@ -197,11 +216,11 @@ All SDK exceptions extend `Stromcom\Sdk\Exception\StromcomException`.
| `AuthenticationException` | 401 — invalid / missing token |
| `NotFoundException` | 404 — resource not found |
| `ValidationException` | 400 / 422 — invalid input |
-| `ConflictException` | 409 — e.g. user code already exists, limit reached |
+| `ConflictException` | 409 — e.g. user code already exists |
| `RateLimitException` | 429 — slow down |
| `ServerException` | 5xx |
| `TransportException` | network failure / non-JSON response |
-| `ApiException` | catch-all base for any other API error |
+| `ApiException` | base class; thrown as-is for any other status, notably 402 and 403 (see below) |
Every API exception exposes:
@@ -216,9 +235,42 @@ try {
}
```
+Plan limits (**402**) and forbidden actions (**403**) have no dedicated
+subclass — they surface as a plain `ApiException`. Catch the specific
+exceptions first, then `ApiException`, and branch on `getApiCode()`:
+
+| Status | `getApiCode()` |
+|---|---|
+| 402 | `USER_LIMIT_REACHED`, `THREAD_LIMIT_REACHED`, `MESSAGE_LIMIT_REACHED`, `STORAGE_LIMIT_REACHED`, `MESSAGE_STORAGE_LIMIT_REACHED`, `WEBHOOK_LIMIT_REACHED` |
+| 403 | `ACTION_FORBIDDEN` |
+
+```php
+use Stromcom\Sdk\Exception\ApiException;
+use Stromcom\Sdk\Exception\ValidationException;
+
+try {
+ $stromcom->messages()->create('order-1234', $payload);
+} catch (ValidationException $e) {
+ // fix the payload
+} catch (ApiException $e) {
+ match ($e->getApiCode()) {
+ 'MESSAGE_LIMIT_REACHED', 'STORAGE_LIMIT_REACHED' => notifyAdminToUpgrade($e),
+ 'ACTION_FORBIDDEN' => logForbidden($e),
+ default => throw $e,
+ };
+}
+```
+
A worked example of catching every variant lives in
[`examples/error_handling.php`](examples/error_handling.php).
+## Known limitations
+
+- The SDK returns only the `data` part of the API response envelope. The
+ `meta` part (e.g. `meta.warnings`, `meta.updated`) is dropped and is not
+ reachable through the SDK. If you need it, call the API directly or wrap
+ your own `HttpClientInterface` to inspect the raw response.
+
## Requirements
- PHP **8.3** or **8.4**
diff --git a/examples/README.md b/examples/README.md
index 80adc82..386bec7 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -19,7 +19,7 @@ php examples/quickstart.php
| [`users_lifecycle.php`](users_lifecycle.php) | Full user lifecycle: create → fetch → update → delete → restore. |
| [`thread_with_messages.php`](thread_with_messages.php) | Single-call fetch of a thread, its messages, and grouped attachments. |
| [`create_thread_and_message.php`](create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. |
-| [`paginate_messages.php`](paginate_messages.php) | Walk every message in a thread via the `lastMessageHash` cursor. |
+| [`paginate_messages.php`](paginate_messages.php) | Incremental sync of new messages via the inclusive `lastMessageHash` cursor. |
| [`download_attachment.php`](download_attachment.php) | Resolve attachment metadata, fetch a signed URL, save to disk. |
| [`message_reactions.php`](message_reactions.php) | List emoji reactions on a message, aggregated by code. |
| [`error_handling.php`](error_handling.php) | Catch the typed exception hierarchy (`NotFound`, `Validation`, …). |
diff --git a/examples/paginate_messages.php b/examples/paginate_messages.php
index 96d9a1c..f226a48 100644
--- a/examples/paginate_messages.php
+++ b/examples/paginate_messages.php
@@ -2,52 +2,65 @@
declare(strict_types=1);
/**
- * Paginate through every message in a thread.
+ * Incremental sync of a thread's messages via the `lastMessageHash` cursor.
*
- * The `lastMessageHash` query parameter is a cursor: pass the hash of
- * the last message you've already seen to get the next batch. The
- * Customer API returns messages in chronological order; the loop
- * below stops when an empty page comes back.
+ * How the cursor works on the Customer API:
+ * - messages come back oldest-first;
+ * - `lastMessageHash` is INCLUSIVE — the first returned item is the
+ * cursor message itself;
+ * - there is no page size — the server returns every message from the
+ * cursor onward (bounded only by your plan's history window).
+ *
+ * So there is nothing to "page through": fetch once without a cursor,
+ * remember the hash of the newest message, and on the next sync fetch
+ * from that hash and drop the first item (you already have it).
*
* Run with:
* STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 php examples/paginate_messages.php
+ * STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_CURSOR= php examples/paginate_messages.php
*/
require __DIR__ . '/../vendor/autoload.php';
use Stromcom\Sdk\Client;
+use Stromcom\Sdk\Configuration;
+use Stromcom\Sdk\Model\Message;
$token = getenv('STROMCOM_TOKEN') ?: '';
$threadCode = getenv('STROMCOM_THREAD') ?: '';
+$baseUrl = getenv('STROMCOM_BASE_URL') ?: 'https://www.stromcom.cz/api/customer/v1/';
if ($token === '' || $threadCode === '') {
fwrite(STDERR, "Set STROMCOM_TOKEN and STROMCOM_THREAD.\n"); exit(1);
}
-$stromcom = new Client($token);
-
-$cursor = null;
-$total = 0;
+$stromcom = new Client(new Configuration(token: $token, baseUrl: $baseUrl));
-do {
- $page = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor);
- $count = count($page->messages);
- $total += $count;
+/**
+ * Return messages newer than $cursor (all messages when $cursor is null).
+ *
+ * @return list
+ */
+function fetchNewMessages(Client $stromcom, string $threadCode, ?string $cursor): array {
+ $messages = $stromcom->messages()->list($threadCode, lastMessageHash: $cursor)->messages;
- foreach ($page->messages as $msg) {
- printf("%-12s %s\n", $msg->hash, strip_tags($msg->message));
+ if ($cursor !== null && $messages !== [] && $messages[0]->hash === $cursor) {
+ array_shift($messages);
}
- if ($count === 0) {
- break;
- }
+ return $messages;
+}
- $cursor = $page->messages[$count - 1]->hash;
+// In a real integration the cursor lives in your database, per thread.
+$cursor = getenv('STROMCOM_CURSOR') ?: null;
- // First-page-only single fetch when the API serves the whole thread
- // in one shot — guard against an infinite loop on small threads.
- if ($count < 50) {
- break;
- }
-} while (true);
+$newMessages = fetchNewMessages($stromcom, $threadCode, $cursor);
+
+foreach ($newMessages as $message) {
+ printf("%-12s %s\n", $message->hash, strip_tags($message->message));
+}
+
+if ($newMessages !== []) {
+ $cursor = $newMessages[array_key_last($newMessages)]->hash;
+}
-printf("\nTotal messages walked: %d\n", $total);
+printf("\nNew messages: %d\nNext cursor: %s\n", count($newMessages), $cursor ?? '(none)');