SIGNALS Documentation
API Reference

PHP SDK

The official Saloon-based PHP client for the Signals API — typed requests for every endpoint, filters, pagination, throttling, and error handling.

PHP SDK

signals-rental/signals-api-php-sdk is the official PHP client for the Signals API. It is generated from the same OpenAPI 3.1 document this installation publishes at /api/openapi.json, then wrapped in a hand-written core that covers what a generator cannot: q[...] filters, offset pagination, idempotency keys, optimistic concurrency, dry runs, async job polling, and a throttler that keeps you inside the weighted rate limits.

It is built on Saloon v4, so Saloon's mocking, middleware, pools and sendAsync() all work unchanged.

Source and issues: github.com/signals-rental/signals-api-php-sdk.

Requirements

PHP 8.4 or newer
Saloon v4 (saloonphp/saloon: ^4.0)
Laravel optional — 13+ for the service provider, facade and cache-backed throttle store

The SDK is framework-agnostic: everything that touches Laravel lives under src/Laravel, and an architecture test in the package keeps it that way.

Installation

composer require signals-rental/signals-api-php-sdk

Getting started

Signals is self-hosted, so you supply your installation's application URL and a Sanctum bearer token. The SDK appends /api itself.

use SignalsRental\SignalsApiPhpSdk\SignalsConnector;

$signals = SignalsConnector::fromUrl('https://acme.example.com', 'your-api-token');

$rental = $signals->rentals()->show(1042);

$rental->unwrap();   // ['id' => 1042, 'title' => 'Summer Festival', ...]

For anything beyond a URL and a token, build the config yourself. SignalsConfig::make() accepts snake_case or camelCase keys:

use SignalsRental\SignalsApiPhpSdk\SignalsConfig;
use SignalsRental\SignalsApiPhpSdk\SignalsConnector;

$signals = new SignalsConnector(SignalsConfig::make([
    'base_url' => 'https://acme.example.com',
    'token' => 'your-api-token',
    'timeout' => 30,
    'connect_timeout' => 10,
    'retries' => 2,
    'retry_interval' => 250,
    'user_agent' => 'acme-crm/2.1',
    'options' => [
        'throttle' => [
            'units_per_minute' => 6_000,
            'max_wait_seconds' => 15.0,
        ],
    ],
]));

Laravel

The service provider and the Signals facade register through package discovery. Set two environment variables, publish the config if you need more:

SIGNALS_BASE_URL=https://acme.example.com
SIGNALS_API_TOKEN=your-api-token
php artisan vendor:publish --tag=signals-config
use SignalsRental\SignalsApiPhpSdk\Laravel\Facades\Signals;

$rental = Signals::rentals()->show(1042);
$order = Signals::rentals()->convertToOrder(1042);

Injecting SignalsConnector resolves the same shared singleton. That is deliberate: the throttling engine is stateful, and one instance paces every request your application makes against one budget. In Laravel the counters live in your cache store, so the budget is shared across workers, cron and the web tier.

Configuration

config/signals.php maps one-to-one onto these environment variables. Outside Laravel the same throttle settings live under options.throttle.

Environment variable Default What it does
SIGNALS_BASE_URL — Your installation's application URL. Required.
SIGNALS_API_TOKEN — The Sanctum bearer token. Limits are per token, so issue one per integration.
SIGNALS_TIMEOUT / SIGNALS_CONNECT_TIMEOUT 30 / 10 Seconds to wait for a response / a connection.
SIGNALS_RETRIES / SIGNALS_RETRY_INTERVAL 0 / 0 Repeats for transient failures only, and the first back-off in milliseconds (exponential from there).
SIGNALS_USER_AGENT signals-php-sdk/{version} Names your application in the Signals request log.
SIGNALS_THROTTLE_ENABLED true Pace requests at all.
SIGNALS_THROTTLE_REQUESTS_PER_MINUTE 60 The request budget. null trusts the response headers alone.
SIGNALS_THROTTLE_UNITS_PER_MINUTE null The weighted-unit budget. Quotas are per installation, so set yours.
SIGNALS_THROTTLE_MAX_WAIT 30.0 Longest the SDK blocks waiting for capacity before throwing.
SIGNALS_THROTTLE_MAX_RETRIES_429 2 Extra Retry-After-driven attempts a 429 earns.
SIGNALS_THROTTLE_CACHE_STORE / SIGNALS_THROTTLE_KEY_PREFIX / SIGNALS_THROTTLE_SCOPE app default / signals:throttle / derived Where the counters live, and the bucket connectors must share to share a budget.

Resources and requests

Every OpenAPI tag becomes a resource accessor on the connector, and every operation becomes a method on that resource. Path parameters come first, then the JSON payload for a write or the query parameters for a read; each method's docblock carries the endpoint's verb, path and description.

$signals->rentals()->show(1042);
$signals->rentals()->store(['title' => 'Summer Festival']);
$signals->rentals()->update(1042, ['reference' => 'PO-2026-114']);
$signals->rentals()->convertToOrder(1042);

$signals->catalogueItems()->index();
$signals->accounts()->show(17);
$signals->webhooks()->listWebhooks();

Every endpoint method returns a SignalsResponse; the …Request() twins return the unsent request, and paginate() returns an OffsetPaginator.

When you need to change a request first — an idempotency key, an expected_version guard, a dry run — build the request class and send it through the connector:

use SignalsRental\SignalsApiPhpSdk\Requests\Rentals\ConvertToOrderRequest;

$response = $signals->send(
    (new ConvertToOrderRequest(1042))->expectedVersion(7)
);

ConvertToOrderRequest::OPERATION_ID;   // 'v1.rentals.convert_to_order'
ConvertToOrderRequest::WEIGHT;         // 4

OperationManifest::find('v1.rentals.convert_to_order') maps an operation id from the endpoint catalogue back to the class that sends it, its weight, and its resource method.

Filtering, sorting, includes and sparse fieldsets

Collection endpoints take the ?q[field_predicate]=value filters described in API Conventions. Filter builds them and serialises values the way the API reads them — dates as ISO-8601 in UTC, booleans as 1/0, backed enums as their value. Custom fields are addressed as cf.name.

use SignalsRental\SignalsApiPhpSdk\Query\Filter;
use SignalsRental\SignalsApiPhpSdk\Query\ListOptions;

$options = ListOptions::make()
    ->filter(fn (Filter $f) => $f
        ->eq('status', 'confirmed')
        ->cont('title', 'festival')
        ->gteq('starts_at', new DateTimeImmutable('2026-06-01 00:00:00', new DateTimeZone('UTC')))
        ->in('warehouse_id', [3, 7])
        ->cf('po_ref')->eq('PO-2026-114'))
    ->sortDesc('created_at')
    ->include('items', 'costs')
    ->fields('id', 'title', 'charge_total')
    ->perPage(50);

$response = $signals->rentals()->index(options: $options);

Only the flag predicates (_null, _not_null, _present, _blank, _true, _false) may be used without a value; eq('venue_id', null) is refused with an InvalidConfigurationException rather than sent as q[venue_id_eq]=1. Filter and ListOptions are immutable, so a part-built one is safe to share.

Filters and sorts outside an endpoint's allow-list are skipped rather than rejected, so always check what the endpoint applied — see ignoredFilters() below.

Pagination

paginate() returns an OffsetPaginator. Nothing is fetched until you iterate, and only one page is held in memory:

foreach ($signals->rentals()->paginate(options: $options)->perPage(100)->items() as $rental) {
    // every rental, page by page
}

foreach ($signals->rentals()->paginate()->pages() as $page => $response) {
    $response->ignoredFilters();
}

Other helpers: collect() (small result sets only), first(), total(), startingAt($page) and maxPages($n). Any index request can be paginated directly with OffsetPaginator::for($signals, $request).

Reading responses

SignalsResponse extends Saloon's Response and understands the Signals envelope:

$response = $signals->rentals()->index();

$response->key();             // 'rentals'
$response->unwrap();          // the rows, without the envelope
$response->total();           // 128
$response->ignoredFilters();  // ['colour_eq'] — act on this
$response->ignoredSorts();    // ['colour']

$response = $signals->rentals()->show(1042);

$response->ref();                // 'rental:1042' — the same identifier webhooks carry
$response->aggregateVersion();   // 7 — send back as expected_version
$response->rateLimit();          // limit, remaining, retryAfter

A non-empty ignoredFilters() or ignoredSorts() means the rows you got back are broader than the ones you asked for. Log it or fail on it.

Data classes and enums

Readonly data classes are generated from the document's schemas. They are a convenience for reading a response, not a validation layer: a missing field degrades rather than throwing, and undeclared keys are kept in $raw.

use SignalsRental\SignalsApiPhpSdk\Data\RentalData;

$rental = RentalData::fromArray($signals->rentals()->show(1042)->unwrap());

$rental->chargeTotal;      // '12500.00' — money stays a string
$rental->startsAt;         // '2026-06-01T09:00:00Z' — timestamps stay ISO-8601 strings
$rental->customFields;     // ['po_ref' => 'PO-2026-114']

ListOptions::make()->fields(...RentalData::FIELDS);

Money and timestamps are deliberately strings, to avoid float rounding and timezone surprises. String-backed enums cover the closed value sets; data classes decode them through tryFrom, so a value from a newer Signals release reads as null rather than crashing the integration.

Weighted endpoints and automatic throttling

Signals enforces two independent limits per token: a request rate limit (60 requests a minute by default) and the weighted unit budget that meters billing. The SDK paces itself against both — it waits until both budgets can absorb a request, records the spend, reconciles against the rate-limit headers the response carries, and honours a server-issued Retry-After for every send from that connector. Which headers those are depends on the server: with unit throughput metering enabled (the default) an authenticated response carries X-Signals-Units-* and no X-RateLimit-*; with metering disabled the flat per-minute limiter returns X-RateLimit-* instead. Read X-Signals-Units-* when present and fall back to X-RateLimit-* — see API Conventions.

Set your installation's unit quota, because the SDK cannot guess it:

SIGNALS_THROTTLE_UNITS_PER_MINUTE=6000
$usage = $signals->throttle?->usage();

$usage->requestsRemaining();  // 58
$usage->unitsRemaining();     // 5997

If capacity will not free up within throttle.max_wait_seconds, the SDK throws ThrottleTimeoutException rather than blocking forever. Keep the wait well inside your queue worker's timeout, and short for anything serving a web request.

Prefer webhooks over polling: polling a weight-6 availability endpoint every 30 seconds costs over 500,000 units a month before your application does any real work, whereas a webhook subscription is one weight-3 call and its envelope carries the same ref a REST response does, so a delivery tells you exactly which record to fetch.

Error handling

Failed responses are thrown as SDK exceptions mapped from the status. All of them extend SignalsException, which extends Saloon's request exception.

Status Exception
401 AuthenticationException
403 AuthorizationException
404 NotFoundException
409 ConflictException, or VersionConflictException / IdempotencyConflictException when the body says so
422 ValidationException
425 IdempotencyInFlightException
428 IdempotencyKeyRequiredException
429 RateLimitedException
5xx ServerException
anything else RequestException
use SignalsRental\SignalsApiPhpSdk\Exceptions\SignalsException;

try {
    $signals->rentals()->store(['title' => 'Summer Festival']);
} catch (SignalsException $e) {
    $e->apiMessage();   // 'The given data was invalid.'
    $e->errorCode();    // the stable, branchable code, when the API sent one
    $e->errors();       // ['starts_at' => ['The starts at field is required.']]
    $e->requestId();    // the X-Request-Id correlation id
}

Four exceptions sit outside that hierarchy because no HTTP response exists when they are raised — InvalidConfigurationException, ThrottleTimeoutException, JobTimedOutException and UnexpectedResponseException — so catching SignalsException never swallows a misconfiguration.

Retries are only attempted for transient failures (408, 425, 429, 502, 503, 504). A 500 is not retried: the server may have committed the write before failing, so an unkeyed POST could be duplicated. The same reasoning applies to a request that never comes back — a read timeout on an unkeyed POST/PATCH is surfaced rather than repeated, while safe methods and keyed mutations are retried.

API conventions the SDK carries for you

  • Idempotency keys — ->idempotencyKey($key) is available on operations carrying the x-signals-idempotency marker (auto or accepted), including the PUT, PATCH, and DELETE operations behind the idempotency middleware; the SDK attaches the key whenever the marker is present. Operations marked auto create a key unless you supply one (see conventions). The six automatic operations are scan-session store/close/abandon, single scan, bulk scan, and void. Replaying a key returns the stored response; reusing it with a different body is a 409; an in-flight key is a 425; installations that require keys answer 428 without one. An absent x-signals-idempotency extension does not opt an operation into either behaviour.
  • Optimistic concurrency — ->expectedVersion($n) sends expected_version; a mismatch throws VersionConflictException with currentVersion() and expectedVersion(), and nothing was mutated.
  • Dry runs — ->dryRun() on requests that support it validates without committing.
  • Async jobs — JobPoller waits on long-running operations (exports, imports) and throws JobTimedOutException if the deadline passes.

Testing your integration

The SDK is plain Saloon, so MockClient fixtures and Saloon's recording tools work as documented at docs.saloon.dev. Against a real installation, issue a dedicated token for tests so its usage and limits are isolated from production integrations.

Keeping up with the API

The package ships the OpenAPI document it was generated from and a regeneration command; when your Signals installation is upgraded, regenerate against its current /api/openapi.json to pick up new endpoints and fields. The versioning page describes how the API itself evolves.

The SDK versions independently from the framework. The current SDK release is 0.3.0, generated from the framework specification; its Composer extra.signals-spec pin records SHA-256 735d644379cef9b02300df6a084977730b15560a61d0cbf4b63810c121fc4498.

In SDK 0.3.0, regeneration replaces the hand-written Http\BulkScanResult with the generated Data\ScanBulkResult, backed by ScanBulkItemStatus. It also generates MeData, removes pagination options from scanContexts()->index(), types scanning 422 codes as ScanningErrorCode, and reads both ScanResultData.summary and WorkflowRunData.outcome as object-or-null values.

Page Why
Authentication Issuing the Sanctum token the SDK needs
Conventions Filters, idempotency, versioning and envelope rules the SDK implements
Usage & Metering The unit weights the throttler paces against
Webhooks The alternative to polling
API Reference The OpenAPI document the SDK is generated from