xpiston / salsify
A PHP SDK for the Salsify ProductXM API: records, exports, imports and signed webhooks, for Laravel, Symfony or no framework at all.
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- ext-zlib: *
- illuminate/support: ^12.0 || ^13.0
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- orchestra/testbench: ^10.0
- php-http/discovery: ^1.19
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Suggests
- ext-zlib: Reads gzip-compressed export files without shelling out.
- guzzlehttp/guzzle: A PSR-18 client.
- php-http/discovery: Finds the PSR-18 client and PSR-17 factories on its own, so nothing has to be wired.
- symfony/http-client: A PSR-18 client.
Provides
None
Conflicts
None
Replaces
None
README
๐๏ธ Salsify for PHP
The PIM, as a few small objects.
Records, exports you can stream, imports, and webhook signatures that are actually verified โ for Laravel, Symfony, or no framework at all.
Salsify is a product information management platform. Its ProductXM API is the one this package speaks: an organization, a token, records that are products, exports that are files, and webhooks that are signed.
What it gives you is a typed way to do the four things an integration really does โ read a product, mirror a catalogue, push a feed, and trust an incoming webhook โ with the platform's two awkward facts made impossible to get wrong: the rate limit belongs to the organization, not to you, and the webhook signature covers a URL that is not in the request.
โ ๏ธ Read this before you trust it
Nothing here has been checked against the live Salsify API. Salsify has no public sandbox, and API access is a licence option rather than something you can sign up for โ so unlike a package built against a published test platform, there was no server to measure. Every request this library builds comes from Salsify's own documentation and OpenAPI definitions, not from a response anybody observed.
What is proved, offline and end to end, is the webhook signature verification โ see what's proved and what's read. If you have API access, the fastest useful thing you can do with this package is run one call and tell me what came back.
โจ What's inside
- ๐ Records โ get, create, update, delete, with
salsify:system fields read as PHP types and everything else kept exactly as it arrived - ๐ค Exports that stream โ start, poll, then read a JSON Lines file of any size in constant memory. A 50 000-record file is read in under a megabyte โ measured by a test, not asserted in prose
- ๐ฅ Imports โ start a configured import and ask how it went
- ๐ Webhook signatures, verified properly โ the five-part signed string, a certificate URL that is vetted before it is fetched, and timestamps refused from the future as well as from the past
- ๐ The rate limit, surfaced after every call โ
isRunningOut()fires before the 429 instead of after it - ๐ง No retries, ever โ and a paragraph on why
- ๐ Localized values โ
localized('Description', 'fr-FR')instead of digging through a nested array - ๐งฉ Laravel and Symfony bridges, a hundred lines each, both optional
- ๐งช 141 tests, PHPStan at max, zero network
๐ Quick start
composer require xpiston/salsify
You also need any PSR-18 client. If you have none, this is the shortest way:
composer require symfony/http-client nyholm/psr7 php-http/discovery
use Piston\Salsify\Salsify; $salsify = Salsify::with('your-api-token', 's-1234-5678-9012-3456'); $record = $salsify->records()->get('ABC-123'); $record->property('Brand'); // 'Acme' $record->localized('Description', 'fr-FR'); // 'Une description' $record->version(); // 7 $record->createdAt()?->format('Y-m-d'); // '2021-06-21' echo $salsify->rateLimit(); // "9998 of 10000 requests left, resetting in 2410s"
The organization id is in the Salsify app URL, right after /orgs/. It is not
optional and not decoration: see the rate limit.
Changing one product
// Only what moved. Salsify modifies only the properties you mention, so // reading a record and writing it back whole would overwrite whatever // somebody else changed in between. $salsify->records()->update('ABC-123', ['Color' => 'Red', 'In Stock' => true]);
Creating one
use Piston\Salsify\Entity\Record; $salsify->records()->create( Record::of('SKU', 'ABC-124', ['Brand' => 'Acme', 'Color' => 'Blue']) );
The id is a property โ whichever one holds the ID role in your organization โ so it goes in the record rather than beside it.
๐ฆ Exporting a whole catalogue
This is the part worth reading, because it is the part an integration gets wrong. Three calls, not one:
use Piston\Salsify\Exports\ExportConfiguration; // 1. Ask. Returns immediately with an id. $run = $salsify->exports()->start(ExportConfiguration::everything()); // 2. Come back later. Minutes to hours - from a scheduled job, not a web request. $run = $salsify->exports()->status($run); if (! $run->isReady()) { return; // $run->progress() is 0 to 100 } // 3. Read it, one line at a time, whatever its size. foreach ($salsify->exports()->stream($run, compressed: true) as $line) { if ($line->isRecord()) { $record = $line->toRecord(); // ... } }
There is deliberately no exportAndWait(). A full export takes minutes to
hours; a method that polled until it finished would hold a web request or a
queue worker open, and would hide the one decision that matters โ how often to
ask โ which is a question about your organization's ten thousand requests an
hour and not one a library can answer for you.
What a JSON Lines export actually contains
It is a data model told in order, not a list of products:
header one line: version, organization, when
property your attributes, with their data types and roles
enumerated_value the allowed values of the enumerated ones
digital_asset images and documents, with their URLs
record the products, referring to all of the above
Line gives you isHeader(), isProperty(), isEnumeratedValue(),
isDigitalAsset(), isRecord() โ and keeps anything it does not
recognise. Salsify adds to this format, and a reader that silently dropped
unknown entity types would lose data from a newer organization than itself,
which for a mirror is the worst failure there is.
It really does stream
use Piston\Salsify\Exports\JsonLines; foreach (JsonLines::records(JsonLines::open('/tmp/export.jsonl.gz', compressed: true)) as $record) { // One record in memory at a time. }
compressed: true reads a gzip file through compress.zlib:// without
shelling out and without unpacking it to disk. One of the tests builds a
50 000-record file and asserts that streaming it costs under a megabyte of
memory and that file_get_contents on the same file costs at least five
times more โ so the claim fails loudly if the generator is ever broken by a
refactor.
๐ Webhooks
Salsify signs each webhook with RSA-SHA256 over five values joined by periods:
timestamp . request_id . organization_id . endpoint_url . body
Four of those five are in the request. The fifth is not. The endpoint URL
is the one Salsify was configured to call, and a verifier has to supply it from
its own configuration. Rebuilding it from the request โ from Host, from
REQUEST_URI, from a proxy header โ means checking a signature against a value
the sender controls, which is not a check at all. So this library makes the URL
a required constructor argument; there is no way to call it without one.
use Piston\Salsify\Webhooks\InvalidSignature; use Piston\Salsify\Webhooks\SignedRequest; $verifier = $salsify->webhooks('https://shop.example.com/webhooks/salsify'); $webhook = SignedRequest::fromHeaders(getallheaders(), file_get_contents('php://input')); try { $verifier->verify($webhook); } catch (InvalidSignature $e) { http_response_code(403); error_log($e->getMessage()); // says which of six things was wrong exit; }
Headers are matched on a normalised form, so PHP's HTTP_X_SALSIFY_TIMESTAMP,
PSR-7's x-salsify-timestamp and Salsify's own X-Salsify-Timestamp all work
without the caller having to know which stack it is on.
Two things this does that the published example does not
It validates the certificate URL before fetching it. The URL arrives in a
header. Fetching whatever a header says is server-side request forgery with
extra steps: point it at 169.254.169.254 and the application obligingly
requests the cloud metadata service. So the scheme must be https and the host
must be exactly webhooks-auth.salsify.com โ checked before anything is
downloaded, and checked again inside the fetcher, because that class is public
and the day somebody calls it directly the check has to be next to the network
call rather than in the caller who skipped it.
A test asserts each of nine untrusted URLs is refused and that the fetcher
was never called โ a subdomain, a lookalike host, http, a userinfo trick,
a redirect target, the metadata address. Refusing them is easy; refusing them
before the request is the thing worth testing.
It rejects timestamps from the future. Salsify's tolerance is documented one-sided โ ignore requests that are too old โ which leaves a captured request replayable forever if its timestamp is set far enough ahead. A few seconds of clock skew is a clock; an hour is a forgery with a long life.
The raw body, not the parsed one
// Laravel use Piston\Salsify\Bridge\Laravel\WebhookRequest; $webhook = WebhookRequest::from($request); // uses $request->getContent()
// Symfony use Piston\Salsify\Bridge\Symfony\WebhookRequest; $webhook = WebhookRequest::from($request);
Both read the raw body deliberately. A JSON document that has been through
json_decode and back is a different string the moment a key order, a float or
an escape changes โ and it will verify perfectly in your tests and fail on the
first real payload that happens to differ. There is a test that signs
{"b":1, "a":2, "price": 1.10} and proves the re-encoded version does not
verify.
๐ The rate limit is the organization's
Salsify allows 10 000 requests per hour per organization โ not per user, not per token. Three consequences this library is built around:
- A 429 is as likely to be somebody else's fault as yours. Another integration in the same organization can spend the budget.
- The window resets at the top of the hour, not sixty minutes after your first request. A job that runs dry at 16:45 resumes at 17:00 with ten thousand.
- A loop over a catalogue cannot work. Fifty thousand products through the record endpoints is five hours of the whole organization's budget. Use an export to read and an import to write โ Salsify says the same thing, and the arithmetic says it louder.
$limit = $salsify->rateLimit(); $limit->remaining; // 9 412 $limit->isRunningOut(); // true below a twentieth - 500 of 10 000 $limit->isExhausted(); $limit->resetsIn; // seconds until the top of the hour
And when it does happen:
use Piston\Salsify\Exception\RateLimited; try { $salsify->records()->get('ABC-123'); } catch (RateLimited $e) { $retryAt = $e->resetsAt(); // a DateTimeImmutable, from the response }
๐ซ Three things this SDK refuses to do
It never retries. Not a 429, not a 500. A retry loop hidden inside an SDK is multiplied by every job your application starts, which is how one bad afternoon becomes two. The exception carries the seconds until the reset; the decision is yours, where you can see it.
It never puts the token in the URL. Salsify supports ?access_token= and
its own documentation explains why not to โ URLs are logged by proxies, by web
servers, by error trackers. The header costs nothing. Credentials also
refuses a token pasted with the Bearer prefix, because that produces
Bearer Bearer โฆ and a 401 that looks like a bad token.
It never pretends an error is a result. A 4xx is an exception carrying the status and the decoded body, because Salsify's error bodies are useful โ a 400 for an ambiguous organization lists the organizations you belong to โ and the most common thing an SDK does with them is throw them away.
use Piston\Salsify\Exception\SalsifyException; // the interface, if you want one catch use Piston\Salsify\Exception\ApiError; // status, body, messages() use Piston\Salsify\Exception\Unauthorized; // 401, 403 use Piston\Salsify\Exception\NotFound; // 404 use Piston\Salsify\Exception\RateLimited; // 429, with resetsAt() use Piston\Salsify\Exception\TransportFailed; // the network, or a non-JSON body use Piston\Salsify\Exception\InvalidArgument; // your mistake, before any request
The Unauthorized message names the licence, because "your organization does
not have API access" is the answer often enough to be worth saying before
somebody spends an afternoon re-reading the token.
๐งฐ Laravel
The provider is auto-discovered. Two variables:
SALSIFY_TOKEN=your-api-token SALSIFY_ORGANIZATION=s-1234-5678-9012-3456 SALSIFY_WEBHOOK_URL=https://shop.example.com/webhooks/salsify
use Piston\Salsify\Salsify; final class ProductController { public function show(Salsify $salsify, string $sku) { return $salsify->records()->get($sku)->toArray(); } }
The signature verifier is a separate binding on purpose, so a webhook endpoint can resolve it alone:
use Piston\Salsify\Bridge\Laravel\WebhookRequest; use Piston\Salsify\Webhooks\InvalidSignature; use Piston\Salsify\Webhooks\SignatureVerifier; Route::post('/webhooks/salsify', function (Request $request, SignatureVerifier $verifier) { try { $verifier->verify(WebhookRequest::from($request)); } catch (InvalidSignature) { abort(403); } ProcessSalsifyWebhook::dispatch($request->json()->all()); return response()->noContent(); });
php artisan vendor:publish --tag=salsify-config for the rest.
๐งฐ Symfony
// config/bundles.php return [ Piston\Salsify\Bridge\Symfony\SalsifyBundle::class => ['all' => true], ];
# config/packages/salsify.yaml salsify: token: '%env(SALSIFY_TOKEN)%' organization: '%env(SALSIFY_ORGANIZATION)%' webhook_url: '%env(SALSIFY_WEBHOOK_URL)%' # webhook_tolerance: 300
Salsify and, when webhook_url is set, SignatureVerifier are autowired.
The bundle picks up your PSR-18 client and PSR-17 factories from the container
if they are there, and discovers them otherwise.
โ What's proved and what's read
The honest version, feature by feature. "Documented" means it comes from Salsify's published reference and has a test against the examples printed there; "proved" means a test demonstrates the behaviour end to end on your machine; "unverified" means nobody has seen the real server answer it.
| Status | |
|---|---|
| Webhook signature verification | โ Proved. The tests generate an RSA key and a self-signed certificate, sign exactly as Salsify does, and then forge: the body altered, the body rewritten with the same data in another order, the request id, the organization id and the timestamp each tampered with, a verifier configured with the wrong endpoint URL, a different certificate, a certificate that is not one, a signature that is not base64, a replay outside the window, both tolerance edges, a timestamp from the future, and nine untrusted certificate URLs. The algorithm does not care whose key it is, so this is the real thing. |
| JSON Lines streaming | โ
Proved. Including the memory ceiling, with file_get_contents as the control. |
| Rate limit handling | โ Proved against responses carrying Salsify's documented headers. |
| Record field semantics | ๐ Documented. The salsify: system fields, localized values and digital asset shapes are the ones in the reference, with its own examples as fixtures. |
| Request shapes (paths, methods, bodies, the two base prefixes) | ๐ Documented. Every request is asserted in full against a fake PSR-18 transport โ method, URL, headers, body. They match the published definitions. Whether the server agrees is unverified. |
| Export start | ๐ Documented โ there is an OpenAPI definition for it. |
| Export status endpoint | โ ๏ธ Unverified and flagged in the code. Salsify documents the start call in full and describes polling only in prose. ExportClient::status() uses the conventional REST path, export_runs/{id}, and says so in its docblock rather than hiding it. |
| Import start and status | โ ๏ธ Unverified. The paths follow the documented pattern. |
| Everything else about imports | ๐ซ Absent. See below. |
Nothing in this table is guesswork dressed up as a measurement, and nothing in the code pretends to a certainty it does not have.
๐ If you have a token
One call is enough to turn most of the โ ๏ธ rows above into โ or into a bug report, and either is worth having:
$run = $salsify->exports()->start( \Piston\Salsify\Exports\ExportConfiguration::everything() ); var_dump($run->id(), $run->status(), $run->body); // The row I would most like to hear about: var_dump($salsify->exports()->status($run)->body);
Open an issue with what came back โ redact the ids โ and the table above gets more honest.
๐งฑ How it's built
src/
โโโ Salsify.php four doors: records, exports, imports, webhooks
โโโ Configuration/
โ โโโ Credentials.php token + organization, together, token masked in dumps
โ โโโ Organization.php the s-โฆ id
โ โโโ Endpoint.php the one place that knows there are two base paths
โโโ Http/
โ โโโ Client.php auth, JSON, status codes, rate limit. No retries.
โ โโโ Request.php a call before it is PSR-7
โ โโโ Reply.php a response after it is not
โ โโโ RateLimit.php the three headers
โโโ Entity/
โ โโโ Record.php schemaless, with typed salsify: accessors
โ โโโ RecordId.php refuses ids Salsify reserves
โ โโโ Timestamp.php parses Salsify's formats and no others
โโโ Records/RecordClient.php
โโโ Exports/
โ โโโ ExportConfiguration.php enforces the format/entity-type pairing
โ โโโ ExportClient.php start, status, stream
โ โโโ ExportRun.php id, progress, and a url that is "" until ready
โ โโโ JsonLines.php generators
โ โโโ Line.php the five entity types, and the unknown ones
โโโ Imports/
โโโ Webhooks/
โ โโโ SignatureVerifier.php the algorithm, plus the two hardenings
โ โโโ SignedRequest.php four headers and the raw body
โ โโโ HttpCertificateFetcher.php
โ โโโ CachedCertificates.php once per process, not once per webhook
โโโ Bridge/
โโโ Laravel/
โโโ Symfony/
Decisions visible in the code
Endpointexists because Salsify has two base paths. Records are under/api/v1/orgs/{org}/, export runs under/api/orgs/{org}/with no version segment at all โ that is what their own OpenAPI definition declares. An SDK that assumed one prefix would work perfectly until the first export, so the choice is made by the caller that knows and lives in exactly one class.Recordis schemaless and keeps everything. Your properties are your organization's, so there is nothing to map them to. Thesalsify:fields get typed accessors; everything else comes back exactly as it arrived, andwith()returns a new record rather than mutating one.Timestampparses only Salsify's documented formats.new DateTimeImmutable($value)would happily accept'yesterday'from a field that was supposed to hold an ISO 8601 string, and succeed โ which is worse than failing. Anything else yieldsnull.ExportRun::url()returnsnullfor an empty string. Salsify sends"url": ""while a run is in progress. Code that checksisset($body['url'])finds it set, and downloads nothing.isReady()is the question to ask.RecordIdrefuses ids that start with=,_orsalsifyโ the prefixes the platform reserves โ before a request is made rather than after a 422.Credentialsholds the token and the organization together, because the rate limit is per organization: the same token has a separate budget in each one it belongs to, and splitting them invites a client that is right about one and wrong about the other.- No retries, no token in URLs, no silent error swallowing. Each has a paragraph above, and each is a test.
โ Tests
composer install composer test # 141 tests, no network composer analyse # PHPStan, level max composer test:lint # Pint
Under three seconds, no credentials, no network. Four suites:
unit โ each piece held still. The webhook verifier gets the most
attention because it is the piece a mistake in is a security hole rather than a
bug.
integration โ the joins, which is where the mistakes that matter live: a
full export from start call to records, a record created-read-updated-deleted
with every request asserted in full, a webhook downloading its certificate over
PSR-18 and the certificate being fetched once for two deliveries.
laravel โ the bridge inside a real Laravel application, booted by
Testbench.
symfony โ the bundle compiling a real Symfony container. That is one
suite per bridge rather than one for both, because PHPUnit loads a suite's
files before it filters anything, and CI builds a vendor tree with Symfony 6.4
and no Laravel to check the LTS claim โ a tree in which the Laravel test could
not even be parsed.
Two tests exist because of specific mistakes rather than specific features: the
one that proves 'yesterday' is not a timestamp, and the one that proves the
untrusted certificate URL was never fetched rather than merely rejected.
โ ๏ธ Not included
Deliberately, and not for lack of time:
- SupplierXM (the supplier-facing product) โ a different API with a different model. This package is ProductXM only.
- The GraphQL API (accounts, configuration manifests) โ a different protocol that deserves a different package, not a method on this one.
- Workflows, Enhanced Content, SSO/SAML, user provisioning โ platform features, not data access.
- OAuth 2.0. Salsify documents it, but the token flow is what an integration uses and what an organization can actually obtain.
- Mount points and file uploads for imports. The multi-step flow โ create a
mount point, upload a file to it, configure an import, run it โ is documented
far more thinly than the export side, and I will not invent the parts I
cannot read.
ImportClientdoes the unambiguous half: run an import that exists, and ask how it went. - A paginated
records()->all(). There is no honest way to offer one that does not invite the loop the rate limit forbids. Export instead. - Retries and backoff. Above.
๐ The documentation this was built from
- API Overview ยท Organization ID ยท Rate Limiting
- Token Authentication ยท via header or parameter
- Records Overview ยท Products Overview
- Export Overview ยท Start Export Run ยท JSON Export File Format
- Imports Overview ยท JSON Lines Import Format
- Webhook Signing ยท Signature Verification Algorithm ยท Product Change Webhooks
Where the documentation is thin, the code says so in the docblock of the method that had to guess. There are exactly two such places, and both are in the table above.
License
MIT. See LICENSE.