beziworld / laravel-salesmanago
Laravel client for SALESmanago (Manago AI): contacts, external events, consents, tags, product catalogs, async exports with webhooks
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/cookie: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nesbot/carbon: ^3.8
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 20:36:10 UTC
README
Laravel client for SALESmanago (now Manago AI), the marketing automation platform:
- API v3 (header
API-KEY): product catalogs, products (chunked upserts of up to 100), price and stock updates, product collections (wishlists, favourites, custom recommendations), Web Push exports and request status - API v2 wherever API v3 has no equivalent (API v3 has no contact or event endpoints): contact upsert and
asynchronous batch upsert (chunks of 1000), deletes, lookups, contact cards, paginated lists, created and modified
contacts, data exports, owner changes and monitoring;
sha1(apiKey + clientId + apiSecret)signing is built in - external events: purchases, carts and the other 14 documented types, batches of 1000, updates and deletes
- consents: e-mail opt-in and opt-out (with double opt-in), SMS, WhatsApp and custom consents with dates and IP
- tags: account tag list, adding and removing tags on contacts
- asynchronous exports:
job/status(API v2) andresource(API v3) polling, an export webhook receiver that dispatches a Laravel event, a polling command for the scheduler and a reader for JSON and NDJSON export files restricted to trusted HTTPS hosts - the
smclientmonitoring cookie that links a browser session to the contact, excluded from cookie encryption - several SALESmanago accounts (named connections and runtime credentials per tenant)
- typed, immutable data objects; money as decimal strings, dates as
CarbonImmutableinEurope/Warsaw - typed exceptions with API v3 reason codes and API v2 messages; reads retried on 5xx, connection errors and HTTP 429, writes never
All requests go through the Laravel HTTP client, so everything works with Http::fake().
Why this package
Packagist (November 2026) has general PHP libraries for SALESmanago (salesmanago/api-sso-util, 292k downloads; the
archived Magento 2 module benhauer/salesmanago-integration, 168k; pixers/salesmanago-api; the official
managoai/php-sdk with 307 downloads) but only one Laravel package, blabs-dev/salesmanago-laravel, with 5 downloads
and no activity since 2023. None of them combines both API generations, the export webhook, the monitoring cookie and
multi-account support in a Laravel-native way. This package is built on the current official documentation at
docs.manago.ai (API v2, API v3 and Frontend SDK).
Installation
composer require beziworld/laravel-salesmanago
php artisan vendor:publish --tag=salesmanago-config # optional
# API v3: Integration Center > API > API v3 SALESMANAGO_API_KEY=your-v3-api-key # API v2: Integration Center > API > API v2 SALESMANAGO_CLIENT_ID=your-client-id SALESMANAGO_API_SECRET=your-api-secret SALESMANAGO_ENDPOINT=app3.salesmanago.pl # the server from the browser address bar SALESMANAGO_OWNER=marketing@example.com # SALESmanago user that owns the contacts
Either set can be left out when that API version is not used. Other settings: SALESMANAGO_V3_URL
(https://api.manago.ai), SALESMANAGO_TIMEOUT (30 s), SALESMANAGO_CONNECT_TIMEOUT (10 s), SALESMANAGO_RETRIES (2,
reads only), SALESMANAGO_RETRY_DELAY (1000 ms, doubled per attempt), SALESMANAGO_MAX_RETRY_WAIT (60 s) and
SALESMANAGO_TIMEZONE (Europe/Warsaw).
php artisan salesmanago:check # API v3 OK, API v2 OK (2 users: ...)
php artisan salesmanago:check --all
Contacts
use Beziworld\Salesmanago\Data\Address; use Beziworld\Salesmanago\Data\ConsentDetail; use Beziworld\Salesmanago\Data\ContactSelector; use Beziworld\Salesmanago\Data\ContactUpsert; use Beziworld\Salesmanago\Facades\Salesmanago; $result = Salesmanago::contacts()->upsert(new ContactUpsert( email: 'jan@example.com', name: 'Jan Kowalski', phone: '+48600100200', externalId: 'U12345', address: new Address('Długa 1', '00-001', 'Warszawa', 'PL'), tags: ['NEWSLETTER'], properties: ['shoe_size' => '42'], dictionaryProperties: ['member_since' => now()], // dates become DATE, integers NUMBER consents: [ConsentDetail::accepted('TERMS_OF_SERVICE', request()->ip())], forceOptIn: true, defaultOptStatus: false, // recommended by SALESmanago )); $result->contactId; // also the value of the smclient cookie // Arrays with the documented field names work too; unknown keys are passed through. Salesmanago::contacts()->upsert(['email' => 'anna@example.com', 'state' => 'CUSTOMER'], owner: 'sales@example.com'); // Asynchronous batch upsert (1000 per request) and any number of contacts in chunks $requestId = Salesmanago::contacts()->batchUpsert($contacts, fireEvents: false); $requestIds = Salesmanago::contacts()->batchUpsertChunked(User::cursor()->map(fn ($user) => ContactUpsert::email($user->email))); Salesmanago::contacts()->find('jan@example.com'); // contact ID or null Salesmanago::contacts()->basic(['jan@example.com']); // list<Contact> $card = Salesmanago::contacts()->get('jan@example.com'); // full card: tags, details, consents, events $card->hasTag('newsletter'); $card->consent('TERMS_OF_SERVICE')?->isAccepted(); foreach (Salesmanago::contacts()->all() as $contact) { /* every contact of the owner, page by page */ } Salesmanago::contacts()->modified(now()->subDay(), now()); // list<ContactReference> Salesmanago::contacts()->modifiedPage($from, $to, page: 2); // at most 30 days Salesmanago::contacts()->delete('jan@example.com'); Salesmanago::contacts()->batchDelete([ContactSelector::tag('TEST')]); Salesmanago::contacts()->setMainOwner('jan@example.com', 'new-owner@example.com'); Salesmanago::contacts()->stopMonitoring([ContactSelector::email('jan@example.com')]);
Consents and tags
Salesmanago::contacts()->optIn('jan@example.com'); // forceOptIn Salesmanago::contacts()->optIn('jan@example.com', doubleOptIn: true, language: 'PL'); // confirmation e-mail Salesmanago::contacts()->optOut('jan@example.com'); Salesmanago::contacts()->phoneOptIn('jan@example.com'); Salesmanago::contacts()->whatsAppConsent('jan@example.com', true); Salesmanago::contacts()->consents('jan@example.com', [ConsentDetail::withdrawn('PHYSICAL_MAIL')]); Salesmanago::tags()->all(includeSystemTags: true); // list<Tag> with the number of tagged contacts Salesmanago::tags()->add('jan@example.com', ['VIP']); Salesmanago::tags()->remove('jan@example.com', ['TRIAL']); Salesmanago::tags()->sync('jan@example.com', add: ['CUSTOMER'], remove: ['LEAD']);
SALESmanago has no separate consent or tag endpoints: both are sent through contact/upsert, as documented.
External events
use Beziworld\Salesmanago\Data\ExternalEvent; $eventId = Salesmanago::events()->purchase('jan@example.com', ['SKU-1', 'SKU-2'], '189.99', location: 'examplecom', orderId: 'ORDER-1'); $cartId = Salesmanago::events()->cart($contactId, ['SKU-1'], 79.99, 'examplecom'); Salesmanago::events()->add('jan@example.com', ExternalEvent::custom('MEETING', details: [1 => 'Demo'], description: 'Online demo')); Salesmanago::events()->update($cartId, ExternalEvent::cart(['SKU-1', 'SKU-3'], '129.98', 'examplecom')); // full overwrite Salesmanago::events()->batch([['jan@example.com', $event], [$contactId, $otherEvent]]); // up to 1000 Salesmanago::events()->deleteByExternalId('ORDER-1');
The location links the event to the product catalog with the same location. Event types: PURCHASE, CART,
TRANSACTION, CANCELLATION, RETURN, VISIT, PHONE_CALL, OTHER, RESERVATION, CANCELLED, ACTIVATION, MEETING, OFFER,
DOWNLOAD, LOGIN, SURVEY.
Product catalogs and products (API v3)
use Beziworld\Salesmanago\Data\Product; $catalogId = Salesmanago::products()->upsertCatalog('Example shop', 'PLN', 'examplecom', setAsDefault: true); Salesmanago::products()->catalogs()->byLocation('examplecom'); Salesmanago::products()->upsert($catalogId, ShopProduct::query()->cursor()->map(fn ($p) => new Product( productId: (string) $p->id, name: $p->name, mainCategory: $p->category, productUrl: route('products.show', $p), price: $p->price, // decimal string, int or float discountPrice: $p->promo_price, quantity: $p->stock, mainImageUrl: $p->image_url, ))); // sent in chunks of 100 Salesmanago::products()->updatePrices($catalogId, ['SKU-1' => ['199.99', '149.99'], 'SKU-2' => 10]); Salesmanago::products()->updateQuantities($catalogId, ['SKU-1' => 300]); $wishlist = Salesmanago::products()->createCollection('Wishlist', $catalogId); Salesmanago::products()->addToCollection($wishlist, [$contactId => ['SKU-1', 'SKU-2']]); Salesmanago::products()->collectionProducts($wishlist, $contactId);
Asynchronous exports and the webhook
// API v2: contact data export, polled with job/status $requestId = Salesmanago::contacts()->export( [ContactSelector::tag('CUSTOMER')], ['CONTACT', 'TAG', 'EXT_EVENT', 'CONSENTS'], now()->subMonths(6), now(), ); $status = Salesmanago::exports()->status($requestId); // integer IDs: API v2, UUIDs: API v3 if ($status->isDone()) { foreach (Salesmanago::exports()->rowsOf($status) as $row) { /* array */ } } // API v3: Web Push exports, delivered by webhook $requestId = Salesmanago::exports()->webPushStats('2026-10-01', '2026-10-30', 'MASS');
Enable the webhook receiver and set its URL in Integration Center > API > API v3:
SALESMANAGO_WEBHOOK_ENABLED=true SALESMANAGO_WEBHOOK_SECRET=long-random-string # URL to configure: https://your-app.test/salesmanago/webhook/export?token=long-random-string # for a named connection: https://your-app.test/salesmanago/webhook/export/second-shop?token=...
use Beziworld\Salesmanago\Events\ExportReady; Event::listen(function (ExportReady $event) { // $event->source is "webhook" or "poll"; the file URL expires after a few minutes $rows = Salesmanago::connection($event->connection)->exports()->rowsOf($event->status); });
SALESmanago does not sign the webhook, so the package checks the token query parameter (when a secret is set) and
accepts only file URLs on trusted HTTPS hosts (file_hosts: Google Cloud Storage and Amazon S3 by default). For API
v2 jobs, the scheduler-friendly command polls and dispatches the same event:
php artisan salesmanago:export-status 1234567 --wait=600 --interval=60
Monitoring cookie
Contacts sent through the API are monitored on the website only when the browser carries the smclient cookie with
the contact ID. The cookie must be readable by JavaScript and must not be encrypted; the service provider excludes it
from EncryptCookies automatically.
$result = Salesmanago::contacts()->upsert(ContactUpsert::email($request->email)); return redirect('/thanks')->withCookie(Salesmanago::cookie()->make($result->contactId)); // 10 years, not HttpOnly // Alternatives documented by SALESmanago Salesmanago::cookie()->url('https://shop.test/thanks', $result->contactId); // ...?smclient={contactId} Salesmanago::cookie()->script($result->contactId); // var _smclientid = "..."; Salesmanago::cookie()->contactId($request); // contact ID of the visitor, or null Salesmanago::cookie()->forget(); // remove after tracking consent is withdrawn
Several accounts
Salesmanago::connection('second-shop')->contacts()->upsert($contact); Salesmanago::using([ 'api_key' => $tenant->salesmanago_key, 'client_id' => $tenant->salesmanago_client_id, 'api_secret' => $tenant->salesmanago_secret, 'endpoint' => $tenant->salesmanago_server, 'owner' => $tenant->salesmanago_owner, ])->events()->purchase(...);
Anything else
Salesmanago::v2('contact/addNote', ['owner' => 'owner@example.com', 'email' => 'jan@example.com', 'note' => '...']); Salesmanago::v3Get('email/list', ['type' => 'STANDARD', 'dateFrom' => 1700000000, 'dateTo' => 1760000000]); Salesmanago::v3Post('keyInformation/upsert', [...]);
API v2 or v3
| Area | API | Reason |
|---|---|---|
| Product catalogs, products, prices, stock, product collections | v3 | Documented in v3 |
| Web Push exports, request status | v3 | Documented in v3, delivered by webhook |
| Contacts, batch upsert, deletes, lookups, exports | v2 | No contact methods in v3 |
| External events | v2 | No event methods in v3 |
| Tags, consents | v2 | Through contact/upsert; no v3 equivalent |
| Job status of v2 exports | v2 | job/status |
Limits
SALESmanago documents request limits only for the Frontend SDK. The package handles HTTP 429 with Retry-After for
both APIs (reads are retried, writes raise RateLimitExceeded) and enforces the documented batch sizes: 1000 contacts
per batch upsert, 1000 events per batch, 100 products per product upsert, 50 e-mails per contact/list, 30 days for
paginated modified contacts.
Errors
| Exception | When |
|---|---|
Exceptions\ApiException |
API v3 problems (reasonCodes(): 10 authentication, 11 to 19 validation; fields()), API v2 "success": false (messages()), HTTP errors, unreadable responses and connection failures. Helpers: isAuthenticationError(), isValidationError(), isRateLimited(), isTransportError(), isRetryable(), operation(), httpStatus(), body() |
Exceptions\RateLimitExceeded |
HTTP 429 after the read retries, or on the first write; retryAfter() |
Exceptions\InvalidArgument |
Input violating a documented limit (batch sizes, event types, locations, product IDs, ...) |
Exceptions\UntrustedFileUrl |
Export file URL without HTTPS or outside file_hosts |
Exceptions\InvalidConfiguration |
Missing credentials, endpoint or owner, unknown connection |
All extend Exceptions\SalesmanagoException. Reason code 12 (data trimmed) is a warning and does not raise.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
License
MIT. See LICENSE.md. This package is not affiliated with SALESmanago, Manago AI or Benhauer.