diff --git a/README.md b/README.md index 5e44fe0..e1100da 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,8 @@ $client->ads()->boostable($workspaceId); $client->ads()->connections($workspaceId); $client->ads()->sources($workspaceId); // ad accounts and Pages per connection -$url = $client->ads()->authorizeMeta($workspaceId); // finish the login in a browser +$client->ads()->providers(); // the networks this deployment knows +$url = $client->ads()->authorize('meta', $workspaceId); // finish the login in a browser $client->ads()->deleteConnection($connectionId, $workspaceId); $boost = $client->ads()->boost( @@ -546,12 +547,33 @@ $client->ads()->deleteCampaign($copyId, $workspaceId, $connectionId); // deleteNetworkAd(), duplicateAdSet(), duplicateNetworkAd(), creatives(), creative(), deleteCreative() ``` +### Forecasts, conversions and the ad library + +Available wherever the network's `capabilities` say so — `providers()` reports them. + +```php +$client->ads()->bidPricing($workspaceId, $connectionId, $adAccountId, 'traffic', $targeting); +$client->ads()->supplyForecast($workspaceId, $connectionId, $adAccountId, 'traffic', $targeting, budgetMinor: 50000); + +$rules = $client->ads()->conversionRules($workspaceId, $connectionId, $adAccountId); +$ruleId = $client->ads()->createConversionRule( + $workspaceId, $connectionId, $adAccountId, 'Checkout', 'purchase', 'last_touch', +); +$client->ads()->attachConversionRule($ruleId, $workspaceId, $connectionId, $adSetId); +$client->ads()->conversionMetrics($ruleId, $workspaceId, $connectionId, '2026-09-01', '2026-09-30'); +// Each event needs happenedAt in epoch ms and an email or a clickId; the API hashes the address. +$client->ads()->sendConversionEvents($ruleId, $workspaceId, $connectionId, $events); + +$page = $client->ads()->adLibrary($workspaceId, $connectionId, keyword: 'crm', countries: ['US']); +``` + ### Audiences, reach and insights ```php $client->ads()->audience($audienceId, $connectionId); $client->ads()->updateAudience($audienceId, $workspaceId, $connectionId, name: 'Customers 2026'); $added = $client->ads()->addAudienceUsers($audienceId, $workspaceId, $connectionId, $emails); // hashed by the API +$client->ads()->addAudienceCompanies($audienceId, $workspaceId, $connectionId, $companies); // a company list $client->ads()->deleteAudience($audienceId, $workspaceId, $connectionId); $reach = $client->ads()->estimateReach($workspaceId, $connectionId, 'act_123', '555', [ diff --git a/src/Model/AdProvider.php b/src/Model/AdProvider.php new file mode 100644 index 0000000..74ba8c6 --- /dev/null +++ b/src/Model/AdProvider.php @@ -0,0 +1,46 @@ + $capabilities campaigns, audiences, conversions, forecasts, ... + * @param array $targetingFacets what searchTargeting() accepts here + * @param array> $trackingMacros macros expanded in a creative's tracking parameters + */ + private function __construct( + array $raw, + public readonly string $id, + public readonly string $name, + public readonly ?string $logo, + public readonly bool $configured, + /** @var array */ + public readonly array $connectMethods, + public readonly array $capabilities, + public readonly array $targetingFacets, + public readonly array $trackingMacros, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'id'), + self::requiredStr($data, 'name'), + self::str($data, 'logo'), + self::bool($data, 'configured') ?? false, + array_values(array_filter(self::seq($data, 'connect_methods'), 'is_string')), + array_filter(self::map($data, 'capabilities'), 'is_bool'), + array_values(array_filter(self::seq($data, 'targeting_facets'), 'is_string')), + array_values(array_filter(self::seq($data, 'tracking_macros'), 'is_array')), + ); + } +} diff --git a/src/Model/BidPricing.php b/src/Model/BidPricing.php new file mode 100644 index 0000000..67aecc2 --- /dev/null +++ b/src/Model/BidPricing.php @@ -0,0 +1,34 @@ + $campaignIds ad sets this rule is attached to */ + private function __construct( + array $raw, + public readonly string $id, + public readonly string $name, + public readonly ?string $type, + public readonly ?string $attribution, + public readonly int $postClickWindowDays, + public readonly int $viewThroughWindowDays, + public readonly ?int $valueMinor, + public readonly ?string $currency, + public readonly bool $enabled, + public readonly ?string $createdAt, + public readonly array $campaignIds, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'id'), + self::requiredStr($data, 'name'), + self::str($data, 'type'), + self::str($data, 'attribution'), + self::int($data, 'post_click_window_days') ?? 30, + self::int($data, 'view_through_window_days') ?? 7, + self::int($data, 'value_minor'), + self::str($data, 'currency'), + self::bool($data, 'enabled') ?? true, + self::str($data, 'created_at'), + array_values(array_filter(self::seq($data, 'campaign_ids'), 'is_string')), + ); + } +} diff --git a/src/Model/SupplyForecast.php b/src/Model/SupplyForecast.php new file mode 100644 index 0000000..b103319 --- /dev/null +++ b/src/Model/SupplyForecast.php @@ -0,0 +1,36 @@ + + */ + public function providers(): array { + return AdProvider::listFrom(self::unwrap($this->http->get('/ads/providers'))); + } + + /** The network's login URL; the caller finishes it in a browser. */ + public function authorize( + string $provider, + string $workspaceId, + ?string $method = null, + ?string $returnTo = null, + ): string { $body = self::compact([ 'workspaceId' => $workspaceId, 'method' => $method, 'returnTo' => $returnTo, ]); - $result = self::unwrap($this->http->post('/ads/connections/meta/authorize', $body)); + $result = self::unwrap($this->http->post("/ads/connections/{$provider}/authorize", $body)); return is_array($result) && is_string($result['url'] ?? null) ? $result['url'] : ''; } + /** @deprecated Use authorize('meta', ...). */ + public function authorizeMeta(string $workspaceId, ?string $method = null, ?string $returnTo = null): string + { + return $this->authorize('meta', $workspaceId, $method, $returnTo); + } + /** The Google login URL; the caller finishes it in their own browser. */ public function authorizeGoogle(string $workspaceId, ?string $returnTo = null): string { @@ -853,6 +878,218 @@ public function addAudienceUsers(string $audienceId, string $workspaceId, string return is_array($result) && is_int($result['added'] ?? null) ? $result['added'] : 0; } + /** + * Add companies to a company-list audience. Returns the count the network took. + * Each row needs a name, domain, pageUrl or ticker; the rows are never stored. + * + * @param array> $companies + */ + public function addAudienceCompanies( + string $audienceId, + string $workspaceId, + string $connectionId, + array $companies, + ): int { + $result = self::unwrap($this->http->request( + 'POST', + "/ads/audiences/{$audienceId}/companies", + ['companies' => array_values($companies)], + self::scope($workspaceId, $connectionId), + )); + + return is_array($result) && is_int($result['added'] ?? null) ? $result['added'] : 0; + } + + /** + * What the auction currently costs for that audience. + * + * @param array $targeting + * @param array $placements + */ + public function bidPricing( + string $workspaceId, + string $connectionId, + string $adAccountId, + string $goal, + array $targeting, + ?array $placements = null, + ?string $bidType = null, + ): BidPricing { + $body = self::compact([ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adAccountId' => $adAccountId, + 'goal' => $goal, + 'targeting' => $targeting, + 'placements' => $placements, + 'bidType' => $bidType, + ]); + + return BidPricing::fromArray(self::unwrap($this->http->post('/ads/linkedin/bid-pricing', $body))); + } + + /** + * What that audience would deliver at that budget. + * + * @param array $targeting + * @param array $placements + */ + public function supplyForecast( + string $workspaceId, + string $connectionId, + string $adAccountId, + string $goal, + array $targeting, + ?array $placements = null, + ?int $budgetMinor = null, + ): SupplyForecast { + $body = self::compact([ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adAccountId' => $adAccountId, + 'goal' => $goal, + 'targeting' => $targeting, + 'placements' => $placements, + 'budgetMinor' => $budgetMinor, + ]); + + return SupplyForecast::fromArray(self::unwrap($this->http->post('/ads/linkedin/supply-forecast', $body))); + } + + /** @return array */ + public function conversionRules(?string $workspaceId, string $connectionId, string $adAccountId): array + { + return ConversionRule::listFrom(self::unwrap($this->http->get( + '/ads/linkedin/conversion-rules', + self::scope($workspaceId, $connectionId) + ['ad_account_id' => $adAccountId], + ))); + } + + /** Returns the new rule's id. */ + public function createConversionRule( + string $workspaceId, + string $connectionId, + string $adAccountId, + string $name, + string $type, + string $attribution, + ?int $postClickWindowDays = null, + ?int $viewThroughWindowDays = null, + ?int $valueMinor = null, + ?string $currency = null, + ): string { + $body = self::compact([ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'adAccountId' => $adAccountId, + 'name' => $name, + 'type' => $type, + 'attribution' => $attribution, + 'postClickWindowDays' => $postClickWindowDays, + 'viewThroughWindowDays' => $viewThroughWindowDays, + 'valueMinor' => $valueMinor, + 'currency' => $currency, + ]); + $result = self::unwrap($this->http->post('/ads/linkedin/conversion-rules', $body)); + + return is_array($result) && is_string($result['id'] ?? null) ? $result['id'] : ''; + } + + public function getConversionRule(string $ruleId, ?string $workspaceId, string $connectionId): ConversionRule + { + return ConversionRule::fromArray(self::unwrap($this->http->get( + "/ads/linkedin/conversion-rules/{$ruleId}", + self::scope($workspaceId, $connectionId), + ))); + } + + /** + * Change a rule. Keys are the API's own: name, type, attribution, + * postClickWindowDays, viewThroughWindowDays, valueMinor, currency, enabled. + * + * @param array $changes + */ + public function updateConversionRule( + string $ruleId, + string $workspaceId, + string $connectionId, + array $changes, + ): ConversionRule { + return ConversionRule::fromArray(self::unwrap($this->http->request( + 'PATCH', + "/ads/linkedin/conversion-rules/{$ruleId}", + $changes, + self::scope($workspaceId, $connectionId), + ))); + } + + /** Turns the rule off; the network keeps the history. */ + public function deleteConversionRule(string $ruleId, string $workspaceId, string $connectionId): void + { + $this->http->request( + 'DELETE', + "/ads/linkedin/conversion-rules/{$ruleId}", + null, + self::scope($workspaceId, $connectionId), + ); + } + + public function attachConversionRule( + string $ruleId, + string $workspaceId, + string $connectionId, + string $campaignId, + ): ConversionRule { + return $this->association('POST', $ruleId, $workspaceId, $connectionId, $campaignId); + } + + public function detachConversionRule( + string $ruleId, + string $workspaceId, + string $connectionId, + string $campaignId, + ): ConversionRule { + return $this->association('DELETE', $ruleId, $workspaceId, $connectionId, $campaignId); + } + + /** What the rule recorded between two YYYY-MM-DD days, inclusive. */ + public function conversionMetrics( + string $ruleId, + ?string $workspaceId, + string $connectionId, + string $since, + string $until, + ): ConversionMetrics { + return ConversionMetrics::fromArray(self::unwrap($this->http->get( + "/ads/linkedin/conversion-rules/{$ruleId}/metrics", + self::scope($workspaceId, $connectionId) + ['since' => $since, 'until' => $until], + ))); + } + + /** + * Send conversions back to the network. Returns how many it took. Each event + * needs happenedAt in epoch milliseconds and an email or a clickId; the + * address is hashed inside the API and nothing about an event is stored. + * + * @param array> $events + */ + public function sendConversionEvents( + string $ruleId, + string $workspaceId, + string $connectionId, + array $events, + ): int { + $result = self::unwrap($this->http->request( + 'POST', + "/ads/linkedin/conversion-rules/{$ruleId}/events", + ['events' => array_values($events)], + self::scope($workspaceId, $connectionId), + )); + + return is_array($result) && is_int($result['accepted'] ?? null) ? $result['accepted'] : 0; + } + + /** @param array $targeting countries, ageMin, ageMax, gender, audienceIds, locations, ... */ public function estimateReach( string $workspaceId, @@ -1689,6 +1926,21 @@ private static function scope(?string $workspaceId, string $connectionId): array return ['workspace_id' => $workspaceId, 'connection_id' => $connectionId]; } + private function association( + string $method, + string $ruleId, + string $workspaceId, + string $connectionId, + string $campaignId, + ): ConversionRule { + return ConversionRule::fromArray(self::unwrap($this->http->request( + $method, + "/ads/linkedin/conversion-rules/{$ruleId}/associations", + ['campaignId' => $campaignId], + self::scope($workspaceId, $connectionId), + ))); + } + /** * The API rejects a missing or array body, so an empty one goes out as `{}`. * diff --git a/tests/AdsNetworksTest.php b/tests/AdsNetworksTest.php new file mode 100644 index 0000000..9956383 --- /dev/null +++ b/tests/AdsNetworksTest.php @@ -0,0 +1,79 @@ +transport->push(200, ['data' => ['url' => 'https://www.linkedin.com/oauth?state=abc']]); + + $url = $this->client()->ads()->authorize('linkedin', 'w_1', returnTo: '/ads'); + + $this->assertSame( + 'https://api.fopost.com/v1/ads/connections/linkedin/authorize', + $this->transport->last()['url'], + ); + $this->assertSame(['workspaceId' => 'w_1', 'returnTo' => '/ads'], $this->transport->lastJson()); + $this->assertSame('https://www.linkedin.com/oauth?state=abc', $url); + } + + public function testProvidersCarryWhatEachNetworkSupports(): void + { + $this->transport->push(200, ['data' => [[ + 'id' => 'linkedin', + 'name' => 'LinkedIn Ads', + 'logo' => 'linkedin', + 'configured' => false, + 'connectMethods' => [], + 'capabilities' => ['campaigns' => true, 'conversions' => true], + 'targetingFacets' => ['country', 'job_title'], + 'trackingMacros' => [['token' => '{{LINKEDIN_CAMPAIGN_ID}}', 'description' => 'The campaign']], + ]]]); + + $providers = $this->client()->ads()->providers(); + + $this->assertCount(1, $providers); + $this->assertFalse($providers[0]->configured); + $this->assertTrue($providers[0]->capabilities['conversions']); + $this->assertSame(['country', 'job_title'], $providers[0]->targetingFacets); + } + + public function testCompanyRowsTravelWithTheRequest(): void + { + $this->transport->push(200, ['data' => ['added' => 2]]); + + $added = $this->client()->ads()->addAudienceCompanies( + 'urn:li:adSegment:44', + 'w_1', + 'conn_1', + [['domain' => 'northwind.example'], ['name' => 'Contoso']], + ); + + $this->assertSame(2, $added); + $this->assertSame( + ['companies' => [['domain' => 'northwind.example'], ['name' => 'Contoso']]], + $this->transport->lastJson(), + ); + } + + public function testConversionEventsSendTheIdentityTheApiHashes(): void + { + $this->transport->push(200, ['data' => ['accepted' => 1]]); + + $accepted = $this->client()->ads()->sendConversionEvents( + 'urn:li:conversion:9', + 'w_1', + 'conn_1', + [['happenedAt' => 1758326400000, 'email' => 'buyer@example.test']], + ); + + $this->assertSame(1, $accepted); + $this->assertStringContainsString( + '/ads/linkedin/conversion-rules/urn:li:conversion:9/events', + $this->transport->last()['url'], + ); + } +}