jcolombo/optmyzr-api-php

PHP SDK / API client for the Optmyzr User Data API (v1) — PPC optimization suggestions, alerts, metrics, workouts and Blueprint tasks

Maintainers

Package info

github.com/jcolombo/optmyzr-api-php

pkg:composer/jcolombo/optmyzr-api-php

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-03 17:45 UTC

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/validate exists to close that gap in one command the day a token is available.

Table of contents

Requirements

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 .gitignore already excludes .env and optmyzrapi.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:

  • 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.
  • Old and current platform names fold together. 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.

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:

  • 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.

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 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.
  • 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() 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.

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.