PHP SDK / API client for the Optmyzr User Data API (v1) — PPC optimization suggestions, alerts, metrics, workouts and Blueprint tasks
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.
https://dataaccess.optmyzr.comGET; there are no
write endpoints.Two things to know before you startTable of contents1. 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.
ext-json^7.8 (installed via Composer)^3.3composer require jcolombo/optmyzr-api-phpAuthentication
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.
Quick startNever commit your token. Load it from an environment variable or an untracked config file. The provided
.gitignorealready excludes.envandoptmyzrapi.config.json.
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:
accountId alone 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 an
AccountKey value object.AdWords and Google Ads
produce the same identity, as do Bing Ads and Microsoft 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.
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:
Accounts has no accountId filter even though it returns accounts.Workouts accepts nothing but the token — workouts are token-scoped, not
account-scoped, and carry no account fields at all.WorkoutHistory and BlueprintTasks have no accountType filter even
though WorkoutHistory returns 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.
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 heldIncremental 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.
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.
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:
RequestAbstraction, so the object that gets serialized into the cache, written
to logs, and printed in diagnostics never holds it.token in 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).Redactor scrubs by pattern (token=… in any URL) and by registered value,
which is what catches Guzzle exception messages that embed the effective URI in
prose.connectionName() reports OptmyzrApi-***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.
CachingOff 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(); // everythingRate 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.
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.
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.
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')).
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.
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 |
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.
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.
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.
MIT — see LICENSE.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | djeventplannerhub/djep-php-sdk | 0 | 18.33 | 03-08-2026 |
| 2 | shelfwatch/shelfwatch | 0 | 10 | 03-08-2026 |
| 3 | byteplus_sdk/byteplus-php-sdk-v2 | 0 | 10 | 03-08-2026 |
| 4 | snowman/ai | 0 | 8.1 | 03-08-2026 |
| 5 | risetechapps/api-key-for-laravel | 0 | 100 | 03-08-2026 |
| 6 | shadman/laravel-skills | 0 | 21.11 | 03-08-2026 |
| 7 | nstdata-ai-crawl 0.1.0 | 0 | 5 | 20-07-2026 |
| 8 | sharpapi/laravel-invoice-manager | 0 | 19.5 | 03-08-2026 |
| 9 | automattic/blocks-engine-php-transformer | 0 | 17.14 | 03-08-2026 |
| 10 | vaani-sdk 0.3.0 | 0 | 5 | 20-07-2026 |