Search by

Official PHP SDK for the NotifyPress REST API — send and manage web push notifications, subscribers, segments, and automations.

v0.1.0 2026-08-03 14:43 UTC

This package is auto-updated.

Last update: 2026-11-01 21:15:33 UTC


README

Official PHP client for the NotifyPress REST API — browser push notifications, subscribers, segments, and automation workflows.

Runs unchanged in WordPress, Laravel, and plain PHP, with no required runtime dependencies.

use NotifyPress\NotifyPress;
use NotifyPress\Payload\SendNotification;

$client = new NotifyPress(getenv('NOTIFYPRESS_API_TOKEN'));

$client->notifications->send(
    SendNotification::make('Flash sale', '25% off, today only')
        ->targetUrl('https://example.com/sale')
);

Requirements

  • PHP 8.1+
  • ext-json, plus either ext-curl or allow_url_fopen (any PSR-18 client works too)

Installation

composer require notifypress/sdk

Credentials

NotifyPress issues two credentials per site, and they are not interchangeable:

Where to find it Where it may be used
API token Site settings → API token Servers only. It is a secret with full access to the site.
Public key (npk_...) Site settings → widget snippet Safe to embed in a page. Used only by the public opt-in endpoints.

Use NotifyPress with the API token, and NotifyPressPublic with the public key.

Pass the token exactly as the dashboard shows it. If you have a value containing a pipe, use only the part after the pipe. The part before it is an internal id the server supplies itself; including it still authenticates, so nothing looks wrong, but the site can no longer be resolved and every call fails with NotFoundException: No site is linked to this API token.

Quickstart per environment

Plain PHP

Nothing to configure. The SDK falls back to cURL, then PHP streams.

require 'vendor/autoload.php';

$client = new NotifyPress\NotifyPress('your-api-token');

foreach ($client->subscribers->list() as $subscriber) {
    echo $subscriber->token, PHP_EOL;
}

Laravel

Guzzle is already installed, so the SDK detects it and reuses the application's HTTP stack. Bind it once:

// app/Providers/AppServiceProvider.php
use NotifyPress\NotifyPress;

$this->app->singleton(NotifyPress::class, fn () => new NotifyPress(config('services.notifypress.token')));
public function send(NotifyPress $notifypress): RedirectResponse
{
    $notifypress->notifications->send(
        SendNotification::make($request->title, $request->message)
    );

    return back();
}

WordPress

Calls are routed through wp_remote_request() automatically, so the site's proxy constants, http_request_args filters, and WP_HTTP_BLOCK_EXTERNAL allowlist are all honoured — a raw cURL call from a plugin bypasses every one of them and breaks on managed hosts requiring an outbound proxy.

require_once __DIR__ . '/vendor/autoload.php';

$client = new NotifyPress\NotifyPress(get_option('notifypress_api_token'));
$site = $client->sites->current();

Anything else

Pass your own PSR-18 client:

use NotifyPress\Config;
use NotifyPress\Http\Client\Psr18Client;

$client = new NotifyPress(
    Config::make('your-api-token')->withHttpClient(new Psr18Client($yourPsr18Client))
);

Configuration

Config is immutable — every with*() returns a new instance:

use NotifyPress\Config;

$client = new NotifyPress(
    Config::make('your-api-token')
        ->withBaseUrl('https://app.notifypress.com/api/v1')
        ->withTimeout(10.0)
        ->withMaxRetries(4)
        ->withHeader('X-Request-Source', 'my-app')
);

A built client can be re-pointed without rebuilding it: $client->withBaseUrl(...), ->withApiToken(...), ->withTimeout(...).

Resources

sites, notifications, subscribers, tags, segments, automations, reports, announcements, dashboard.

$client->sites->current();
$client->sites->updateCurrent(SiteUpdate::make('Example', 'https://example.com')->promptDelaySeconds(5));

$client->notifications->list(NotificationQuery::make()->status(NotificationStatus::SENT)->limit(50));
$client->notifications->retrieve(9);
$client->notifications->cancel(9);

$client->subscribers->create(SubscriberCreate::make($fcmToken)->country('CA')->browser('Chrome'));
$client->subscribers->addTag($fcmToken, $tagId);
$client->subscribers->delete($subscriberId);   // numeric ID, not the token

$client->tags->create(TagCreate::interest('Deals'));
$client->segments->previewCount(SegmentRule::make()->location(['CA', 'US']));
$client->automations->trigger('cart_abandoned', $fcmToken);
$client->dashboard->analytics();

Fluent payloads

Every payload and query is an immutable builder, so a base can be safely specialised:

$base = SendNotification::make('Flash sale', '25% off');

$client->notifications->send($base->toSegment(4));
$client->notifications->send($base->toEveryone()->iconUrl('https://example.com/icon.png'));

Plain arrays work everywhere a builder does, and ->set('key', $value) passes through any parameter the SDK does not model yet:

$client->notifications->send(['title' => 'Flash sale', 'message' => '25% off']);
$client->notifications->send($base->set('ttl_seconds', 300));

Pagination

Iterating a list yields the current page. Walking the whole set is explicit, because every step is another HTTP round trip:

$page = $client->subscribers->list(SubscriberQuery::make()->limit(100));

$page->total;          // cached count of the full set
$page->hasNextPage();
$page->nextPage();

foreach ($page as $subscriber) { }               // this page
foreach ($page->autoPaging() as $subscriber) { } // every page, fetched lazily

GET /automations is offset-paginated and returns an OffsetPage with currentPage, lastPage, and isPaidUser().

Errors

Every non-2xx response throws. The three server error shapes are normalised into one hierarchy:

NotifyPressException
├── ApiException            {status, body, response}
│   ├── ValidationException {errors}       422
│   ├── AuthenticationException            401, or 403 rejecting the credential
│   ├── PaidPlanException   {upgradeUrl}   403 on a paid-plan gate
│   ├── ForbiddenException                 other 403
│   ├── NotFoundException                  404
│   ├── RateLimitException  {retryAfter}   429
│   └── ServerException                    5xx
├── TimeoutException
├── ConnectionException
└── ConfigurationException
use NotifyPress\Exception\PaidPlanException;
use NotifyPress\Exception\RateLimitException;
use NotifyPress\Exception\ValidationException;

try {
    $client->notifications->send(SendNotification::make($title, $message));
} catch (ValidationException $e) {
    echo $e->first('title');            // "The title may not be greater than 50 characters."
} catch (PaidPlanException $e) {
    echo $e->upgradeUrl;
} catch (RateLimitException $e) {
    sleep($e->retryAfter ?? 5);
}

Credentials never reach a message or a stack trace — see NotifyPress\Http\Redactor, which is what everything diagnostic goes through.

Retries

Idempotent requests are retried up to maxRetries times (default 2) on 429/502/503/504 and on connection failures, honouring Retry-After up to a 10-second cap, otherwise exponential backoff with jitter.

Mutations are never retried automatically. A send that times out may already have gone out, and retrying it would push a duplicate to the entire audience. Opt in per call when a request really is safe to repeat:

use NotifyPress\Http\RequestOptions;

$client->tags->delete($tagId, RequestOptions::make()->retryable());

Scheduling and timezones

scheduled_at is Y-m-d H:i:s in the account's display timezone — not ISO 8601, and not UTC. The SDK formats the wall-clock time exactly as given and performs no conversion, so pass a DateTime already expressed in that timezone:

$client->notifications->schedule(
    ScheduleNotification::make('Morning', 'Rise and shine')
        ->at(new DateTimeImmutable('2026-08-15 09:30:00'))
);

RecurringSchedule::weekly('09:30')->onDays([1, 3, 5]);   // Mon/Wed/Fri
RecurringSchedule::monthly('09:30')->onDayOfMonth(15);

Datetimes on responses stay strings for the same reason — parsing them here would attach the wrong timezone.

Uploads

MultipartBody builds the request bytes by hand, so one code path works across every transport:

use NotifyPress\Http\Multipart\MultipartBody;

$client->notifications->create(
    MultipartBody::make()
        ->field('title', 'Flash sale')
        ->field('message', '25% off')
        ->file('icon', '/path/to/icon.png')      // max 2 MB
        ->file('image', '/path/to/hero.jpg')     // max 4 MB
);

Oversized or wrongly-typed files fail locally with the same field key the API would have used, so there is one error path either way. ->withoutClientValidation() defers entirely to the server.

Public endpoints

For visitor opt-in, keyed by the public npk_ key:

use NotifyPress\NotifyPressPublic;
use NotifyPress\Payload\PublicSubscribe;

$push = new NotifyPressPublic('npk_...', 'https://example.com');

$push->subscribe(PublicSubscribe::make($fcmToken)->browser('Chrome')->device('Desktop'));
$push->unsubscribe($fcmToken);          // keeps the row, marks it inactive
$push->erase($fcmToken);                // GDPR hard delete, 5/min
$push->recentNotifications();

The site URL is required. The server validates Origin/Referer against the site registered in NotifyPress (exact host or the www. variant) and rejects requests carrying neither. A browser sets that header itself; server-side PHP does not, so the SDK sends it explicitly. Give the URL registered in NotifyPress, or every call will 403.

Forward compatibility

The SDK is built not to break when the API grows:

  • Models keep the full decoded payload. A new field is readable through $model->get('new.field') instead of being dropped.
  • Enums are read with tryFrom(). A status this release has never heard of leaves statusEnum() null while $model->status still holds the raw string — it never fatals.
  • $client->transport() reaches any endpoint the SDK does not model yet, with auth, retries, and error mapping intact.

Development

composer install
composer test      # Pest, no network
composer phpstan   # level 8, analysed at the 8.1 floor
composer cs        # PSR-12
composer check     # all three

License

MIT