Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 57 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ composer require stromcom/customer-sdk

## API access & documentation

- **Reference docs:** <https://www.stromcom.cz/docs/customer/>
- **OpenAPI spec:** <https://www.stromcom.cz/docs/customer/swagger.json>
- **Reference docs (Swagger UI):** <https://www.stromcom.cz/docs/customer/>
- **OpenAPI spec:** <https://www.stromcom.cz/docs/customer/openapi.json>

API access is gated by your STROMCOM subscription — only plans that
explicitly include API access can issue Customer API tokens and call
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
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -168,6 +173,20 @@ AttachmentDownload $stromcom->messages()->attachmentDownload(string $threa
list<MessageReaction> $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
Expand Down Expand Up @@ -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:

Expand All @@ -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**
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …). |
Expand Down
65 changes: 39 additions & 26 deletions examples/paginate_messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<hash> 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<Message>
*/
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)');