jcolombo / optmyzr-api-php
PHP SDK / API client for the Optmyzr User Data API (v1) — PPC optimization suggestions, alerts, metrics, workouts and Blueprint tasks
Requires
- php: >=8.1
- ext-json: *
- adbario/php-dot-notation: ^3.3
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-08-03 18:56:11 UTC
README
A PHP 8.1+ SDK / API client for the Optmyzr User Data API (v1).
Optmyzr is a PPC-management platform for Google Ads and Microsoft Advertising: optimization suggestions, alerts, quality-score tracking, workouts, and Blueprint task management. This library wraps the full published API surface — all nine documented endpoints — behind a typed entity layer, a fluent query builder, synthetic record identity, client-side rate limiting, optional caching, and raw escape hatches for anything Optmyzr adds later.
- Base URL:
https://dataaccess.optmyzr.com - Auth: a User Token, passed as a query-string parameter (see Authentication)
- Coverage: all 9 operations / 9 paths (see API coverage)
- The API is read-only. Every documented operation is a
GET; there are no write endpoints.
Two things to know before you start
1. The token travels in the query string, not a header. That is Optmyzr's design, not a choice this library made. It means the credential is part of every request URL, so it can reach access logs, proxies, and error trackers that this SDK has no control over. Inside its own boundary the SDK scrubs it everywhere — logs, exception messages, cache keys, cache files,
var_dump()output — see Credential handling. Outside that boundary, treat request URLs as secrets.2. This release is documentation-verified, not live-verified. Every endpoint, field, filter, and enum value here is transcribed from Optmyzr's published Swagger documentation and covered by mocked tests. None of it has been exercised against a live token yet. The synthetic identity keys in particular are inferences that may need recalibration against real data — see Synthetic identity and
docs/IDENTITY.md. A green test suite here means "internally consistent with the documentation", not "known to work against the live API".tests/validateexists to close that gap in one command the day a token is available.
Table of contents
- Requirements
- Installation
- Authentication
- Quick start
- Synthetic identity
- API coverage
- Filter-support matrix
- Server-side vs client-side filtering
- Account scoping
- Pagination
- Incremental sync
- Read-only enforcement
- Enums are advisory
- Credential handling
- Caching
- Rate limiting
- Errors and logging
- Configuration
- Extending the SDK (classMap)
- Raw escape hatches
- Known API quirks
- Examples
- Development and testing
- License
Requirements
- PHP 8.1+
ext-json- Guzzle
^7.8(installed via Composer) - adbario/php-dot-notation
^3.3 - An Optmyzr User Token
Installation
composer require jcolombo/optmyzr-api-php
Authentication
The Optmyzr User Data API authenticates with a User Token sent as a query parameter:
GET https://dataaccess.optmyzr.com/UserData/V1/Accounts?token=<YOUR_TOKEN>
Optmyzr's documentation says only: "You will need an API Token to access this API. Contact your Optmyzr Account Manager for an API token." There is no self-service token generation, no documented expiry, and no documented scope model — so a token cannot be assumed present in any environment, and this SDK never fabricates one.
You supply the token once, to connect(), and never handle it again:
use Jcolombo\OptmyzrApiPhp\Optmyzr; $optmyzr = Optmyzr::connect(getenv('OPTMYZR_API_TOKEN'));
Optmyzr::connect() is a singleton per token + base URL — calling it again with
the same token returns the same connection, with its warmed Guzzle client and
rate-limit state. It makes no network call.
Never commit your token. Load it from an environment variable or an untracked config file. The provided
.gitignorealready excludes.envandoptmyzrapi.config.json.
Quick start
use Jcolombo\OptmyzrApiPhp\Optmyzr; $optmyzr = Optmyzr::connect(getenv('OPTMYZR_API_TOKEN')); // Which operations does this token actually have access to? // (Also the cheapest call in the API, so a good liveness check.) if (!$optmyzr->supports('Accounts')) { throw new RuntimeException('This token has no Accounts access.'); } // Every ad account the token can see foreach ($optmyzr->accounts()->fetch() as $id => $account) { // $id is a stable synthetic identity — safe as a local database key echo $id, ' ', $account->accountName(), ' (', $account->accountType(), ")\n"; } // One client's pending work $acct = $optmyzr->account('123-456-7890', 'AdWords'); $suggestions = $acct->suggestions()->fetch(); echo $suggestions->totalChanges(), " pending changes\n"; foreach ($acct->alerts()->fetch() as $alert) { echo $alert->alertType(), ': ', $alert->alertDetails(), "\n"; } // Who applied what, last month $history = $optmyzr->history() ->between('2026-07-01', '2026-07-31') ->fetch(); foreach ($history->changesByUser() as $email => $changes) { echo $email, ': ', $changes, " changes\n"; } // Overdue Blueprint tasks, across every page $overdue = $optmyzr->blueprintTasks()->overdue()->fetchAll(); echo $overdue->count(), ' overdue across ', $overdue->pagesFetched(), " pages\n"; Optmyzr::disconnect();
Synthetic identity
The Optmyzr API returns no record identifiers. No endpoint returns one, none
accepts one, and there is no GET /{id} anywhere. That makes ordinary work —
caching a record, recognising it in a later fetch, storing it locally, diffing two
snapshots — impossible without inventing an identity scheme.
This SDK derives three values per record:
| Accessor | Derived from | Stable when a value changes? |
|---|---|---|
naturalKey() |
the key fields, normalised, pipe-joined | yes |
id() |
prefix_ + hash of the natural key |
yes |
fingerprint() |
hash over every field | no — moves when anything moves |
$suggestion = $optmyzr->suggestions()->fetch()->first(); $suggestion->naturalKey(); // "google|123-456-7890|Search Terms" $suggestion->id(); // "optsug_1f3c8a…" stable $suggestion->fingerprint(); // "9b21ef…" moves on any change
The split is the whole point. Hashing the entire row — the obvious approach —
produces an identifier that changes whenever any value changes, so a suggestion
whose changes count moves from 4 to 7 reads as a delete plus an insert rather
than an update. Hashing only the key fields keeps identity stable while
fingerprint() carries the "did anything move?" signal.
Per-resource keys, and the reasoning for each, are in
docs/IDENTITY.md. Two structural points:
accountIdalone is not unique. The same numeric id can exist on both ad platforms, so the platform is part of the key. That composite is modelled as anAccountKeyvalue object.- Old and current platform names fold together.
AdWordsandGoogle Adsproduce the same identity, as doBing AdsandMicrosoft Advertising. Without that fold, a vendor rename would change the id of every record in every account, and a routine sync would read as a complete delete-and-reinsert.
The keys are configuration, not code, because they are inferred from prose rather than observed in data:
// If real data shows a key is not unique, correct it without a package release: Configuration::set('identity.keys.activeAlert', ['accountType', 'accountId', 'alertType', 'link', 'alertDetails']);
A collision detector reports rows that collapse onto one id, which is the direct empirical check on those inferences:
$alerts = $optmyzr->alerts()->fetch(); if ($alerts->hasCollisions()) { // Two distinct records share an id — the configured key is missing a field. print_r($alerts->collisions()); }
API coverage
All 9 documented operations.
| Endpoint | Accessor | Resource | Paginated |
|---|---|---|---|
GET /UserData/V1/GetAPIList |
apiList(), supports() |
(bare string[]) |
— |
GET /UserData/V1/Accounts |
accounts() |
Account |
no |
GET /UserData/V1/Metrics |
metrics() |
Metrics |
no |
GET /UserData/V1/ActiveAlerts |
alerts() |
ActiveAlert |
no |
GET /UserData/V1/OptimizationSuggestions |
suggestions() |
OptimizationSuggestion |
no |
GET /UserData/V1/OptimizationHistory |
history() |
OptimizationHistory |
no |
GET /UserData/V1/Workouts |
workouts() |
Workout |
no |
GET /UserData/V1/WorkoutHistory |
workoutHistory() |
WorkoutHistory |
no |
GET /UserData/V1/BlueprintTasks |
blueprintTasks() |
BlueprintTask |
yes |
Full field-by-field reference: docs/API-REFERENCE.md.
Filter-support matrix
Reproduced here rather than only linked, because it is the single fact you most need and least can guess. Optmyzr's filter support is irregular — four endpoints are missing a filter you would expect them to have.
| Endpoint | accountId |
accountType |
emailId |
toolName |
date window | status |
completedStatus |
page |
|---|---|---|---|---|---|---|---|---|
| GetAPIList | — | — | — | — | — | — | — | — |
| Accounts | — | — | ✓ | — | — | — | — | — |
| Metrics | ✓ | ✓ | ✓ | — | — | — | — | — |
| ActiveAlerts | ✓ | ✓ | ✓ | — | — | — | — | — |
| OptimizationSuggestions | ✓ | ✓ | ✓ | ✓ | — | — | — | — |
| OptimizationHistory | ✓ | ✓ | ✓ | ✓ | ✓ startDate/endDate |
— | — | — |
| Workouts | — | — | — | — | — | — | — | — |
| WorkoutHistory | ✓ | — | ✓ | — | — | — | ✓ | — |
| BlueprintTasks | ✓ | — | ✓ | — | ✓ dueDateStart/dueDateEnd |
✓ | — | ✓ |
The bolded gaps are the traps:
Accountshas noaccountIdfilter even though it returns accounts.Workoutsaccepts nothing but the token — workouts are token-scoped, not account-scoped, and carry no account fields at all.WorkoutHistoryandBlueprintTaskshave noaccountTypefilter even thoughWorkoutHistoryreturns one.
The SDK encodes this in its API surface: each collection composes only the filter
methods its endpoint supports, so $optmyzr->accounts()->status('Open') is a call-site
error a static analyser catches, not a parameter the server silently ignores.
Calling param() with an unsupported name throws UnsupportedFilterException.
Server-side vs client-side filtering
Because the matrix is irregular, the SDK is explicit about where each filter runs.
// Server-side: the endpoint has an accountId parameter $optmyzr->metrics()->accountId('123-456-7890'); // Client-side: the Accounts endpoint has no accountId parameter, so where() // filters in PHP after the rows arrive $accounts = $optmyzr->accounts()->where('accountId', '123-456-7890'); $accounts->fetch(); print_r($accounts->explain()); // [ // 'endpoint' => 'Accounts', // 'paginated' => false, // 'server' => [], // 'client' => ['accountId = 123-456-7890'], // 'sort' => null, // 'take' => null, // ]
where() routes to a server parameter when one exists and falls back to a
client-side comparison when it does not. explain() always tells you which
happened — reach for it first when a result set is wider or narrower than expected.
Other client-side operations (the API offers no server equivalent for any of them):
$optmyzr->suggestions() ->filter(fn ($s) => $s->changes() > 5, 'changes > 5') ->sortBy('changes', SortDirection::DESC) // no sort parameter exists in the API ->take(10) ->fetch();
Account scoping
The API denormalises accountType + accountId into seven of its nine resources
and declares no relationships at all. AccountContext reconstructs the one join
that matters:
$acct = $optmyzr->account('123-456-7890', 'AdWords'); $acct->metrics()->fetch(); // accountId + accountType server-side $acct->alerts()->fetch(); // accountId + accountType server-side $acct->suggestions()->fetch(); // accountId + accountType server-side $acct->history()->fetch(); // accountId + accountType server-side $acct->workoutHistory()->fetch(); // accountId server-side, platform matched in PHP $acct->blueprintTasks()->fetch(); // accountId server-side $acct->detail(); // the Accounts row for this account, or null
You do not have to remember which endpoints accept accountType — that is the
asymmetry this class exists to absorb. There is deliberately no workouts()
accessor, because workouts have no account dimension.
Grouping works the same way:
foreach ($optmyzr->metrics()->fetch()->byAccount() as $key => $group) { // $key is 'google|123-456-7890'; $group is a full sub-collection echo $key, ': ', $group->averageQualityScore(), "\n"; }
An account id with no platform is legitimate — the API accepts accountId alone,
and BlueprintTask rows carry no platform at all:
$optmyzr->account('123-456-7890')->blueprintTasks()->fetchAll();
Pagination
Only BlueprintTasks paginates: 1-based page, a fixed size of 1000, and an
out-of-range page returns 200 with zero entries rather than an error.
$tasks = $optmyzr->blueprintTasks()->status('Open')->fetchAll(); echo $tasks->count(), ' tasks across ', $tasks->pagesFetched(), " pages\n"; // Resume a partial sweep $optmyzr->blueprintTasks()->page(4)->fetchAll();
fetchAll() stops on the first empty or short page, on a page that adds no new
records (a guard against a server ignoring the page parameter), or at a hard page
cap. Calling it on any other endpoint issues one request and warns, rather than
silently pretending to page.
For the eight unpaginated endpoints, Optmyzr documents nothing about whether they cap their result sets. The SDK exposes the raw row count and warns on a suspiciously round one:
$metrics = $optmyzr->metrics()->fetch(); $metrics->entryCount(); // raw rows the API returned — exactly 1000 is suspicious $metrics->totalCount(); // distinct records held
Incremental sync
id() + fingerprint() give a real delta against an API with no identifiers. Your
application persists two short strings per record — nothing else.
$previous = $store->loadSnapshot('optmyzr.suggestions'); // [id => fingerprint] $current = $optmyzr->suggestions()->fetch(); $diff = $current->diff($previous); $store->insert($diff->added()); // identities never seen before $store->update($diff->changed()); // same identity, different contents $store->retire($diff->removed()); // identities absent from this fetch // $diff->unchanged() is skipped entirely — no write, no churn $store->saveSnapshot('optmyzr.suggestions', $diff->snapshot());
$diff->counts() gives ['added' => n, 'changed' => n, 'removed' => n, 'unchanged' => n],
and $diff->upserts() is added + changed together.
Read-only enforcement
The API has no write endpoints, so the write methods exist and throw:
try { $account->save(); } catch (ReadOnlyResourceException $e) { // "Optmyzr Account is read-only: save() is not available. The Optmyzr User // Data API v1 exposes GET operations only — it has no write endpoints." }
They throw rather than being absent because Call to undefined method save() reads
like a broken SDK, while this message documents the API. create(), update(),
save(), delete(), and fetch($id) all behave this way.
Enums are advisory
Optmyzr describes its enumerated fields in prose and never lists their values. Worse, its documentation names the ad platforms "AdWords" and "Bing Ads" — names Google and Microsoft retired years ago — so the live API may well return something else.
Every enum here is therefore advisory, and the raw string is authoritative:
$account->accountType(); // 'AdWords' — exactly what the API sent $account->accountTypeEnum(); // AccountType::ADWORDS, or null if unrecognised $account->platform(); // 'google' — normalised, rename-proof
Nothing throws on an unknown value, and an unrecognised value never blocks
hydration. Enums exist for accountType, associationStatus, alertType, and
workout type.
There is deliberately no enum for BlueprintTask.status or
OptimizationHistory.status: Optmyzr documents neither vocabulary, so an enum
would present a guess as an API contract. Read the real values from live data:
$optmyzr->blueprintTasks()->fetch()->distinct('status');
Credential handling
Because the token is a URL parameter, containment is structural rather than incidental:
- The token is merged in only at the transport call. It is not stored on
RequestAbstraction, so the object that gets serialized into the cache, written to logs, and printed in diagnostics never holds it. - A caller-supplied
tokenin a raw-request query is stripped, so it cannot override the real one (Guzzle's shallow option merge would otherwise make that an auth bypass). Redactorscrubs by pattern (token=…in any URL) and by registered value, which is what catches Guzzle exception messages that embed the effective URI in prose.- Cache keys namespace on a hash of the token, so two tokens can never read each other's cached data and the credential never reaches a filename.
connectionName()reportsOptmyzrApi-***bc12— the last four characters only.
This is covered by explicit tests asserting the token appears in no log line, no cache key, no cache file, no serialized request, and no error message.
Caching
Off by default. Enable it by pointing path.cache somewhere writable:
Configuration::set('enabled.cache', true); Configuration::set('path.cache', '/var/cache/myapp'); Configuration::set('cache.lifespan', 900); // seconds
This API serves aggregated dashboard data rather than transactional records, so a 15-minute default window is safe.
Host applications can replace the backend entirely:
Cache::registerCacheMethods($readCallable, $writeCallable, $clearCallable);
Since the API is read-only, nothing the SDK does can stale its own cache. For out-of-band changes — someone applied an optimization in Optmyzr's own UI — invalidate explicitly:
ScrubCache::invalidate($token); // this connection's entries only ScrubCache::invalidate(); // everything
Rate limiting
Optmyzr publishes no rate limits. Its documentation declares only 200 OK for
every operation and says nothing about throttling, quotas, or 429 behaviour.
The shipped defaults — roughly 4 requests/second, 120/minute, 3000/hour — are therefore a deliberately conservative guess, not a documented ceiling. Do not read them as API behaviour. Raise them once you have observed the real limits:
Configuration::set('rateLimit.minDelayMs', 100); Configuration::set('rateLimit.perMinute', 600);
The limiter is nonetheless 429-aware: a Retry-After header (numeric seconds or
HTTP-date) is honoured ahead of exponential backoff and capped by
rateLimit.maxRetryAfterSeconds so a hostile value cannot park the process.
Errors and logging
The SDK does not throw for API-level failures. A failed request yields a
RequestResponse with success === false, and errors dispatch through configurable
handlers:
Configuration::set('enabled.logging', true); Configuration::set('path.logs', '/var/log/myapp'); Configuration::set('error.handlers.fatal', ['log']); // drop the default 'echo' Configuration::set('error.triggerPhpErrors', true); // escalate to PHP errors
Because Optmyzr documents no error responses at all, the error parser tries the
shapes an ASP.NET service actually produces (Message, ExceptionMessage,
error, errors, …) before falling back to the status line, and an HTML error page
is detected before JSON decoding so the warning names the real problem instead of
reporting a syntax error at offset 0.
Configuration
Defaults ship in default.optmyzrapi.config.json; override any subset:
use Jcolombo\OptmyzrApiPhp\Configuration; Configuration::load('/path/to/optmyzrapi.config.json'); // merge a file Configuration::overload('/path/to/dir'); // dir/optmyzrapi.config.json, ignored if absent Configuration::set('devMode', true); // one value
| Block | Keys |
|---|---|
connection |
url, pathPrefix, verify, timeout |
request |
dateFormat, epochMillisThreshold, pageSize, maxFetchAllPages |
path |
cache, logs |
enabled |
cache, logging |
cache |
lifespan |
rateLimit |
enabled, minDelayMs, safetyBuffer, maxRetries, retryDelayMs, maxRetryAfterSeconds, perMinute, perHour |
log |
connections, requests |
devMode |
extra warnings, identity-collision reporting |
identity |
algo, normalizeAccountType, keys.<entity> |
error |
enabled, handlers, logFilename, triggerPhpErrors |
classMap |
defaultCollection, entity.<key> |
Note on list values: a list override replaces wholesale rather than merging.
Overriding identity.keys.account with ["accountId"] means exactly that one
field — which is the behaviour you want, since a merge would silently produce a
longer key and change every generated id.
devMode is worth enabling the first time you point this at real data: it turns
on identity-collision reporting, envelope-shape warnings, and unknown-enum warnings.
Extending the SDK (classMap)
Every resource and collection is resolved through the classMap config, so a host
application can substitute its own subclasses without touching the SDK:
use Jcolombo\OptmyzrApiPhp\Entity\EntityMap; class MyAccount extends \Jcolombo\OptmyzrApiPhp\Entity\Resource\Account { public function customerNumber(): ?string { return $this->accountName() === null ? null : substr($this->accountName(), 0, 4); } } EntityMap::overload('account', MyAccount::class); // The facade now hydrates your subclass $optmyzr->accounts()->fetch()->first()->customerNumber();
Collections work the same way (EntityMap::overload('accounts', MyAccountCollection::class, 'collection')).
Raw escape hatches
For anything Optmyzr adds after this release:
$body = $optmyzr->rawGet('UserData/V1/SomeNewEndpoint', ['accountId' => '123']); $response = $optmyzr->request('GET', 'SomeOther/Path');
Both flow through the full pipeline — auth, caching, rate limiting, error mapping —
and $path is used verbatim, so it can reach outside UserData/V1.
Known API quirks
Each is handled, and each handler has a test. Full detail with reasoning in
docs/API-REFERENCE.md; design decisions in
OVERRIDES.md.
| # | Quirk | Handling |
|---|---|---|
| G1 | Date format is self-contradictory: prose says mm-dd-yy, the example is 02-17-2018 |
m-d-Y (the example wins), configurable |
| G2 | timestamp is a date-time string on OptimizationHistory, an int64 epoch on WorkoutHistory |
per-resource types, never a global field-name rule |
| G3 | Epoch unit (seconds vs milliseconds) never stated | magnitude sniff at 1e12, configurable |
| G4 | No error responses documented at all | ASP.NET body shapes tried, HTML detected before decoding |
| G5 | No rate limits documented | conservative defaults, flagged as a guess |
| G6 | Enum values described in prose only, using retired platform names | raw string authoritative, enums advisory |
| G7 | Credential travels in the query string | structural containment + redaction |
| G8 | Field casing inconsistent (timeStamp/timestamp, updatetime/updatedTime) |
property aliases |
| G9 | Pagination documented for one endpoint only | fetchAll() only there; raw count exposed elsewhere |
| G10 | GetAPIList values not enumerated |
case- and punctuation-insensitive matching |
| G11 | XML is an advertised response type | Accept: application/json always sent |
| G12 | accountId without accountType unspecified |
sent exactly as given; no platform invented |
Examples
Runnable scripts in examples/:
| File | Shows |
|---|---|
01-basic-connection.php |
connect, apiList(), supports(), disconnect |
02-accounts-and-metrics.php |
account inventory, quality scores, raw vs enum accessors |
03-suggestions-and-alerts.php |
per-account pending work via AccountContext |
04-optimization-history.php |
date windows, attribution by user, totals |
05-blueprint-tasks-pagination.php |
fetchAll(), open(), overdue(), page counts |
06-identity-and-diffing.php |
id()/fingerprint(), snapshots, CollectionDiff |
07-configuration-and-caching.php |
config overlays, cache bridging, explain(), collisions |
Each reads OPTMYZR_API_TOKEN from the environment and exits cleanly when it is
absent.
Development and testing
composer install composer test # full mocked suite — needs no credentials composer test:docs # same, as a readable behaviour list
The suite is fully mocked (Guzzle MockHandler) and passes with zero
credentials configured. It proves the SDK is internally consistent with Optmyzr's
documentation — that the right URL is built, the right parameters are sent, the
documented envelope is parsed, identity is stable, and the token never leaks. It
does not prove the documentation matches reality.
Validating against the live API
export OPTMYZR_API_TOKEN=… composer test:live # one request per endpoint, reports shape and counts ./tests/validate --verbose # also dumps the first row of each endpoint ./tests/validate --identity # fetches twice and asserts every id() is stable
Without a token it prints instructions and exits 0 (skipped, not failed), so CI
stays green.
--verbose and --identity are the point: they answer the open questions this
build could not — the real accountType strings, the real status vocabularies,
the epoch unit, and above all whether the inferred identity keys are actually
unique.
License
MIT — see LICENSE.