Search by

beziworld / laravel-salesmanago

kamcw

Laravel client for SALESmanago (Manago AI): contacts, external events, consents, tags, product catalogs, async exports with webhooks

Package info

github.com/Beziworld/laravel-salesmanago

Homepage

Issues

pkg:composer/beziworld/laravel-salesmanago

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-10 20:35 UTC

This package is auto-updated.

Last update: 2026-11-10 20:36:10 UTC


README

tests Latest Version License

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) and resource (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 smclient monitoring 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 CarbonImmutable in Europe/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().

Polska wersja dokumentacji

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.

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.