helpscout/api
A PHP SDK for the Help Scout API, offering a clean, object-oriented client for mailboxes, conversations, customers, tags, and webhooks. Easily integrate Help Scout support data into PHP or Laravel applications with an expressive, framework-friendly API.
Require the package and create a Help Scout OAuth app (Your Apps → Create App) to get a client ID and secret. For an internal/single-account integration use the client-credentials flow; for a distributed app use the authorization-code flow.
composer require helpscout/api
// config/services.php
'helpscout' => [
'client_id' => env('HELPSCOUT_CLIENT_ID'),
'client_secret' => env('HELPSCOUT_CLIENT_SECRET'),
'mailbox_id' => env('HELPSCOUT_MAILBOX_ID'),
],
// app/Providers/AppServiceProvider.php
use HelpScout\Api\ApiClient;
use HelpScout\Api\Auth\Token\FileTokenStore;
$this->app->singleton(ApiClient::class, fn () => ApiClient::forClientCredentials(
config('services.helpscout.client_id'),
config('services.helpscout.client_secret'),
new FileTokenStore(storage_path('framework/helpscout-token.json'))
));
First use case: confirm credentials with a cheap read, then create a conversation.
$client = app(ApiClient::class);
$mailboxes = $client->mailboxes()->list(); // smoke test
$customer = (new \HelpScout\Api\Customers\Customer())
->setFirstName('Ada')
->setLastName('Lovelace');
$thread = (new \HelpScout\Api\Conversations\Thread())
->setType(\HelpScout\Api\Conversations\Thread::TYPE_CUSTOMER)
->setCustomer($customer)
->setText('My order never arrived.');
$conversation = (new \HelpScout\Api\Conversations\Conversation())
->setSubject('Where is my order?')
->setMailboxId((int) config('services.helpscout.mailbox_id'))
->setType(\HelpScout\Api\Conversations\Conversation::TYPE_EMAIL)
->setCustomer($customer)
->setStatus(\HelpScout\Api\Conversations\Conversation::STATUS_ACTIVE)
->addThread($thread);
$created = $client->conversations()->create($conversation);
Look first at the service methods on ApiClient (conversations(), customers(), mailboxes(), tags(), workflows(), users(), webhooks(), plus Docs entities) and the entity classes under HelpScout\Api\*\ — they are the source of truth for allowed fields and status/type constants. The README covers the two grant types and token stores; the class constants cover the API's enums.
Bind the client as a singleton and wrap it. Constructing ApiClient performs auth/token work, so never new it per job. Put a thin HelpScoutService around it exposing your domain verbs (openConversation, addNote, tagConversation). That gives you one place to fake in tests and one place to absorb breaking changes from this small package.
Entities are mutable DTOs. Build Customer, Thread, Conversation, Tag, etc., set fields, and pass them to the service. Re-fetch before updating: the API's updates are effectively full-resource writes, so get() → mutate → update() is the safe loop.
$conversation = $client->conversations()->get($id);
$conversation->setStatus(Conversation::STATUS_CLOSED);
$client->conversations()->update($conversation);
Adding replies/notes follows the same entity pattern against the conversation's thread endpoint — create a Thread with TYPE_NOTE or TYPE_REPLY, set createdBy/customer, and post it. Attachments are uploaded first (separate attachment call, returns an ID) and then referenced by ID on the thread.
Paginate explicitly. List calls return one page plus HAL navigation links; loop over the collection and follow the next-page link until null. Never assume list() returned everything — cap iterations and log when you hit the cap.
$page = $client->conversations()->list(['status' => 'active', 'mailbox' => $mailboxId]);
while ($page !== null) {
foreach ($page as $conversation) { /* ... */ }
$page = $page->getNextPage(); // null on the last page
}
Webhook ingestion. Receive the POST, persist the raw payload, and return 204 immediately; process in a queued job. Treat webhooks as notifications, not data — use the IDs in the payload to get() the fresh conversation/customer. Make the job idempotent on conversation/thread ID because retries and duplicate deliveries are normal.
Rate-limit-aware jobs. Wrap outbound calls in a job middleware or a retry helper that releases with backoff on 429 (honor Retry-After). Batch tagging, backfills, and report pulls should be chunked artisan commands, not synchronous request-path work.
Testing. Mock your own HelpScoutService in feature tests. For the wrapper itself, inject a Guzzle handler with MockHandler into the underlying RestClient (check the client's constructor/factory for the injection point) and assert on the hydrated entities. Mocking ApiClient's fluent service chain directly is brittle.
Configuration/env. Keep mailbox IDs, tags, and workflow IDs in config, not in code — they differ per Help Scout account and per environment.
FileTokenStore is per-server. It writes a token file to local disk. On multi-instance deployments each node will re-authenticate and potentially thrash tokens. Implement the package's TokenStore interface against cache/DB, or store tokens in a shared volume. This is the single most common production bug with this package.Retry-After. Without retry/backoff you will see sporadic failures in backfills and webhook fan-out.DateTime objects and the API expects ISO-8601. Timezone drift between app and Help Scout is a regular source of "wrong day" bugs in report filters._embedded; unknown or newly added fields may be dropped on hydration. If a field silently disappears, check whether the entity has a setter — upgrade or subclass.$request->getContent() is the raw body to use.composer.json, read the source when behavior is unclear, and keep the wrapper interface small. You are effectively the maintainer of the integration layer.TokenStore interface (shared/Redis tokens), the underlying Guzzle client (logging middleware, retries, MockHandler in tests), and entity subclasses (for fields the package hasn't exposed yet). Debugging is easiest by adding a Guzzle log middleware and dumping the last request/response around a single API call.How can I help you explore Laravel packages today?