Search by

usenotix / notix-php

usenotix

Official Notix SDK for PHP and Laravel: send transactional and marketing email, manage contacts and campaigns, verify one-time codes, and verify webhooks.

v1.1.0 2026-09-14 13:55 UTC

This package is auto-updated.

Last update: 2026-09-14 16:25:06 UTC


README

The official PHP client for Notix, the email sending platform. It ships a Laravel service provider and facade, and it works the same way in any PHP 8.1 or newer project.

Prerequisites

Laravel

Install

composer require usenotix/notix-php

Laravel discovers the service provider and the Notix facade automatically.

Configure

Add the key to your environment:

NOTIX_API_KEY=notix_12345
NOTIX_WEBHOOK_SECRET=whsec_12345

Then add the service to config/services.php:

'notix' => [
    'key' => env('NOTIX_API_KEY'),
    'webhook_secret' => env('NOTIX_WEBHOOK_SECRET'),
],

The provider reads services.notix.key first and falls back to the NOTIX_API_KEY environment variable.

Send an email

use Notix\Laravel\Facades\Notix;

Notix::emails()->send([
    'to' => 'hello@acme.com',
    'from' => 'hello@company.com',
    'subject' => 'Notix email',
    'html' => '<p>Welcome to Acme</p>',
    'text' => 'Welcome to Acme',
]);

A facade proxies static calls, so reach each resource through its accessor method: Notix::emails(), Notix::verify(), Notix::journeys() and so on. Outside a facade the resources are plain properties, as shown below.

You can also resolve the client from the container:

use Notix\Notix;

public function __construct(private readonly Notix $notix)
{
}

Plain PHP

composer require usenotix/notix-php
require __DIR__ . '/vendor/autoload.php';

use Notix\Notix;

$notix = new Notix('notix_12345');

$email = $notix->emails->send([
    'to' => 'hello@acme.com',
    'from' => 'hello@company.com',
    'subject' => 'Notix email',
    'html' => '<p>Welcome to Acme</p>',
]);

echo $email['emailId'];

The constructor takes an optional base URL as its second argument. When you omit it, the client reads NOTIX_BASE_URL from the environment and falls back to https://app.usenotix.dev.

Every method returns the decoded JSON body as an associative array. Connect and total timeouts default to 10 and 30 seconds, and both are constructor arguments:

$notix = new Notix('notix_12345', null, null, connectTimeout: 5, timeout: 15);

Check an API key

$key = $notix->auth->check();
// ['ok' => true, 'teamId' => 7, 'keyId' => 3, 'permission' => 'FULL' or 'SENDING']

The full API reference is at usenotix.dev/docs/reference.

Errors

An API error throws a Notix\Exception\NotixException subclass. A network failure throws Notix\Exception\ConnectionException.

Exception HTTP status API code
BadRequestException 400 BAD_REQUEST
UnauthorizedException 401 UNAUTHORIZED
InsufficientBalanceException 402 INSUFFICIENT_BALANCE
ForbiddenException 403 FORBIDDEN
NotFoundException 404 NOT_FOUND
NotUniqueException 409 NOT_UNIQUE
RiskRefusedException 422 RISK_REFUSED
RateLimitedException 429 RATE_LIMITED
ServerException 500 INTERNAL_SERVER_ERROR

Each one carries getErrorCode(), getHttpStatus(), getRawBody() and getBody(). RateLimitedException::getRetryAfter() returns the seconds to wait when the API sent a Retry-After header, and RiskRefusedException::getRisk() returns the risk object.

use Notix\Exception\NotixException;
use Notix\Exception\RateLimitedException;

try {
    $notix->emails->send($payload);
} catch (RateLimitedException $error) {
    sleep($error->getRetryAfter() ?? 60);
} catch (NotixException $error) {
    report($error);
}

Idempotency

Pass an idempotency key as the second argument to send, create or batch. The same key with the same body returns the original result, so a retry never sends twice. The same key with a different body throws NotUniqueException.

$notix->emails->send([
    'to' => 'hello@acme.com',
    'from' => 'hello@company.com',
    'subject' => 'Notix email',
    'html' => '<p>Welcome to Acme</p>',
], 'signup-123');

$notix->emails->batch([
    ['to' => 'a@example.com', 'from' => 'hello@company.com', 'subject' => 'Welcome', 'html' => '<p>Hello A</p>'],
    ['to' => 'b@example.com', 'from' => 'hello@company.com', 'subject' => 'Welcome', 'html' => '<p>Hello B</p>'],
], 'bulk-welcome-1');

batch sends up to 100 emails in one request and returns the list of created email ids.

Verification codes

Send a one-time code and check what the user typed:

$verification = $notix->verify->send([
    'to' => 'user@example.com',
    'appName' => 'Acme',
], clientIp: '203.0.113.10');

// Every send is risk scored. The level is LOW, MEDIUM or HIGH.
echo $verification['risk']['level'];

$result = $notix->verify->check([
    'id' => $verification['id'],
    'code' => '123456',
]);

if ($result['verified']) {
    // The code matched and the verification is now marked verified.
}

$state = $notix->verify->get($verification['id']);

codeLength defaults to 6 digits and accepts 4 to 8. expiresIn defaults to 600 seconds and accepts 60 to 1800. The plaintext code is never returned. A team can send at most 5 codes per 10 minutes to the same recipient, with at least 30 seconds between sends.

clientIp is optional. Pass the IPv4 or IPv6 address the end user made the request from when you have it, and the per address signal contributes to the score. Notix stores only a keyed hash of that address and never logs it.

Clear abuse is refused. Nothing is sent and no code exists to check:

use Notix\Exception\RiskRefusedException;

try {
    $notix->verify->send(['to' => 'user@mailinator.com']);
} catch (RiskRefusedException $error) {
    // Ask the user for a different address. Retrying will not help.
    $level = $error->getRisk()['level'] ?? null;
}

A verification that was refused reports status refused from verify->get.

Pass channel: 'sms' to deliver the code by SMS instead of email:

$verification = $notix->verify->send([
    'to' => '+2348012345678',
    'channel' => 'sms',
    'appName' => 'Acme',
]);

For channel: 'sms', to is a phone number in E.164 form, appName is required, senderId is optional (defaults to the shared Notix sender ID), and from, subject and templateId do not apply. The response carries channel, providerMessageId, deliveryStatus and deliveryReason.

SMS

Send a standalone transactional SMS. The message is charged to the team wallet when it is queued; delivery status arrives through the sms.* webhooks or a later sms->get.

$message = $notix->sms->send([
    'to' => '+2348012345678',
    'text' => 'Your order 4471 has shipped.',
], 'order-4471');

echo $message['status']; // queued

$message = $notix->sms->get($message['id']);

$page = $notix->sms->list(['limit' => 10, 'status' => 'failed']);

send's second argument is an idempotency key, same semantics as emails->send. list accepts cursor, limit and status (queued, sent, delivered, failed, rejected).

Insufficient wallet balance throws Notix\Exception\InsufficientBalanceException and sends nothing:

use Notix\Exception\InsufficientBalanceException;

try {
    $notix->sms->send(['to' => '+2348012345678', 'text' => 'Hi']);
} catch (InsufficientBalanceException $error) {
    // Top up the wallet and retry.
}

Journeys

$journeys = $notix->journeys->list();

$journey = $notix->journeys->get('jrn_12345');

// Enrol an existing contact by id
$notix->journeys->enroll('jrn_12345', ['contactId' => 'cnt_12345']);

// Or enrol by email within the journey's own contact book. The contact is
// created there when it does not already exist.
$notix->journeys->enroll('jrn_12345', ['email' => 'user@example.com']);

Provide exactly one of contactId or email. A contact holds at most one run per journey, so enrolling a contact that already completed or exited throws NotUniqueException.

Deliverability check

Check a message before you send it:

$report = $notix->deliverability->check([
    'from' => 'news@acme.com',
    'subject' => 'Your September update',
    'html' => '<p>Read the update. <a href="https://acme.com/news">Open it</a></p>',
    'marketing' => true,
]);

if ($report['verdict'] === 'BLOCK') {
    foreach ($report['findings'] as $finding) {
        echo $finding['title'] . ': ' . $finding['fix'];
    }
}

Provide exactly one of html or templateId. The report carries a verdict of PASS, WARN or BLOCK, a score from 0 to 100, the findings ordered block first, and the spam filter's own score.

Four findings block a send: marketing content with no unsubscribe link, a from address on a domain the team has not verified, a link whose host is a raw IP address, and a message a spam filter would reject. Everything else warns or informs.

A campaign send and a journey activation run this same check and refuse a BLOCK. This call is an API request rather than a send, so it costs no units.

Contacts and contact books

$books = $notix->contactBooks->list();
$book = $notix->contactBooks->create(['name' => 'Newsletter']);

$notix->contacts->create($book['id'], ['email' => 'user@example.com']);

// Upsert takes a contact id and a payload. The id in the path and the email in
// the payload must name the same contact: an email that already belongs to a
// different contact in the book answers 400, and so does an id that names a
// contact whose address is a different one. Leave the email out to update the
// contact the id names.
$notix->contacts->upsert($book['id'], 'cnt_12345', ['email' => 'user@example.com']);
$notix->contacts->upsert($book['id'], 'cnt_12345', ['firstName' => 'Ada']);
$notix->contacts->bulkCreate($book['id'], [
    ['email' => 'a@example.com'],
    ['email' => 'b@example.com'],
]);
$notix->contacts->bulkDelete($book['id'], ['contactIds' => ['cnt_1', 'cnt_2']]);

Segments

A segment is a saved, live filter on one contact book. Pass its id as segmentId when you create a campaign to send only to the subscribed contacts who match it when the send starts.

$segment = $notix->segments->create($book['id'], [
    'name' => 'Engaged Pro users',
    'definition' => [
        'version' => 1,
        'match' => 'all',
        'conditions' => [
            ['type' => 'property', 'key' => 'plan', 'op' => 'equals', 'value' => 'Pro'],
            ['type' => 'email_activity', 'event' => 'opened', 'op' => 'in_last_days', 'days' => 30],
        ],
    ],
]);

// Page through who matches right now.
$cursor = null;
do {
    $page = $notix->segments->contacts($book['id'], $segment['id'], array_filter(['cursor' => $cursor, 'limit' => 100]));
    foreach ($page['data'] as $contact) {
        echo $contact['email'], PHP_EOL;
    }
    $cursor = $page['nextCursor'];
} while ($cursor !== null);

list, get, update and delete take the contact book id and, for one segment, its id. A segment used by a scheduled or running campaign, or an active journey, cannot be deleted or have its definition changed.

Campaigns

$campaign = $notix->campaigns->create([
    'name' => 'Welcome Series',
    'from' => 'hello@company.com',
    'subject' => 'Welcome to our platform',
    'contactBookId' => 'cb_12345',
    'html' => '<h1>Welcome</h1><p>Thanks for joining us.</p>',
    'sendNow' => false,
]);

$notix->campaigns->schedule($campaign['id'], [
    'scheduledAt' => '2026-12-01T09:00:00Z',
    'batchSize' => 1000,
]);

$notix->campaigns->getAll(['status' => 'DRAFT', 'page' => '1']);
$notix->campaigns->pause($campaign['id']);
$notix->campaigns->resume($campaign['id']);
$notix->campaigns->delete($campaign['id']);

Domains and analytics

$notix->domains->list();
$domain = $notix->domains->create(['name' => 'acme.com', 'region' => 'us-east-1']);
$notix->domains->verify($domain['id']);
$notix->domains->get($domain['id']);
$notix->domains->delete($domain['id']);

$notix->analytics->emailTimeSeries(['days' => '30']);
$notix->analytics->reputationMetrics(['domainId' => '12']);

Webhooks

Notix signs every delivery. Verify it before you trust it, and always use the raw request body: a body that was parsed and re-encoded no longer matches the bytes that were signed.

Headers sent by Notix:

  • X-Notix-Signature: v1= plus the hex HMAC-SHA256 of <timestamp>.<rawBody>
  • X-Notix-Timestamp: Unix epoch in milliseconds
  • X-Notix-Event: webhook event type
  • X-Notix-Call: unique webhook attempt id

Signatures are accepted within 300 seconds of the timestamp. Pass a third argument to constructEvent to change that.

Laravel controller

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Notix\Exception\SignatureVerificationException;
use Notix\Webhooks;

final class NotixWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $webhooks = new Webhooks(config('services.notix.webhook_secret'));

        try {
            $event = $webhooks->constructEvent(
                $request->getContent(),
                $request->headers->all(),
            );
        } catch (SignatureVerificationException $error) {
            return response('Invalid signature', 400);
        }

        if ($event['type'] === 'email.delivered') {
            // Handle the delivery.
        }

        return response('ok');
    }
}

Exclude the webhook route from CSRF verification in bootstrap/app.php, because Notix is not a browser:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['notix/webhook']);
})

Plain PHP

use Notix\Exception\SignatureVerificationException;
use Notix\Webhooks;

$webhooks = new Webhooks(getenv('NOTIX_WEBHOOK_SECRET'));

try {
    $event = $webhooks->constructEvent(
        file_get_contents('php://input'),
        getallheaders(),
    );
} catch (SignatureVerificationException $error) {
    http_response_code(400);
    exit($error->getMessage());
}

Need only the boolean answer? Use verify:

if (!$webhooks->verify($rawBody, $headers)) {
    http_response_code(401);
    exit('Invalid signature');
}

Development

Composer is not vendored. In a clone of this repository, download it into the git-ignored .tools directory:

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php --install-dir=.tools && rm composer-setup.php

Then install and test:

php .tools/composer.phar install
php .tools/composer.phar test

Issues and source

The source for this SDK is public at github.com/usenotix/notix-php. Report a bug or ask for a feature in its issues. For account or delivery questions, write to hey@usenotix.dev.

License

MIT