cloudme / notify-php
Official framework-independent PHP SDK for the Notify multi-channel notification API (SMS, Telegram, WhatsApp, Voice, Email, Push).
Requires
- php: ^8.1
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.8|^8.0
Requires (Dev)
- laravel/pint: ^1.32
- pestphp/pest: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Official, framework-independent PHP SDK for the Notify multi-channel notification API — SMS, Telegram, WhatsApp, Voice, Email, and Push through one client.
Authentication, token refresh, request signing, nonce/timestamp handling, retries, idempotency, and error mapping are all handled internally, so you don't have to implement any of Notify's security protocol yourself.
Requirements
- PHP 8.1+
- The
opensslandjsonextensions (bundled with PHP by default) - Your Notify
client_id,api_key, and RSA private key (generated when you created your API client in the Notify dashboard)
Installation
composer require cloudme/notify-php
Quick start
use CloudMe\Notify\NotifyClient; $notify = new NotifyClient( clientId: env('NOTIFY_CLIENT_ID'), apiKey: env('NOTIFY_API_KEY'), privateKey: storage_path('notify/private.pem'), // a file path, or the PEM content directly baseUrl: 'https://api.notify.cloudme.uz/api/v1', ); $notify->sms()->send( to: '+998901234567', message: 'Buyurtmangiz tayyor', );
That's it — the SDK signs and exchanges your credentials for an access token on the first call, caches it, and refreshes it automatically from then on.
Sandbox mode
The exact same client_id/api_key also work against the sandbox environment — only baseUrl changes:
baseUrl: 'https://sandbox.notify.cloudme.uz/api/v1',
A sandbox send() never reaches a real SMS/Telegram/WhatsApp/Email/Push/Voice provider and never charges your wallet balance — every response still carries a real messageId/status/price so you can test your own integration end-to-end, just with environment reported back as "sandbox" instead of "production":
$response = $notify->sms()->send(to: '+998901234567', message: 'Test'); $response->environment; // "sandbox"
Point a second NotifyClient instance at the sandbox baseUrl for your test suite/staging, and keep your production instance on api.notify.cloudme.uz — don't send real traffic to test the integration and don't send test traffic expecting it to be free on the production host, since environment is determined purely by which host you called.
Sending messages
Every channel exposes the same send() shape:
$notify->sms()->send(to: '+998901234567', message: 'Salom!'); $notify->telegram()->send(to: '82736192', message: 'Salom!'); $notify->whatsapp()->send(to: '+998901234567', message: 'Salom!'); $notify->email()->send(to: 'customer@example.com', message: 'Your invoice is attached.', subject: 'Invoice #A-12891'); $notify->push()->send(to: '+998901234567', message: 'Your order shipped.', subject: 'Order update'); $notify->voice()->call(to: '+998901234567', message: 'Your order has been delivered.'); // call() is an alias of send()
Using a template instead of a plain message
$notify->sms()->send( to: '+998901234567', templateId: 42, variables: ['order_id' => 'A-12891'], );
Photos and formatting (Telegram, WhatsApp)
Pass a public JPEG/PNG URL as photoUrl - the message text becomes the photo's caption (text over 1024 characters follows as a separate message). Other channels ignore it.
// Telegram renders HTML: <b>, <i>, <u>, <a href="..."> $notify->telegram()->send( to: '+998901234567', message: "<b>Service started</b>\nModel: DAF XF106", photoUrl: 'https://example.com/photos/truck-1855.jpg', ); // WhatsApp does not render HTML - use *bold*, _italic_, ~strike~ $notify->whatsapp()->send(to: '+998901234567', message: '*Service started*', photoUrl: 'https://example.com/photos/truck-1855.jpg');
With a WhatsApp template (templateId), photoUrl fills the template's IMAGE header, so the template must be approved by Meta with one.
A channel not built into this SDK version yet
$notify->channel('sms')->send(to: '+998901234567', message: 'Salom!'); // same as ->sms()
Registering a push device
Call this whenever your own mobile app obtains or rotates an FCM token for a logged-in customer, before sending them a push notification:
$notify->push()->registerDevice(phone: '+998901234567', fcmToken: $fcmToken);
Idempotency
Every send() call is automatically tagged with an idempotency key, so a network blip that makes the SDK (or you) retry the exact same call can never result in the message being sent twice. Pass your own key — e.g. your own order ID — if you want retries across separate PHP processes/requests to also dedupe against each other:
$notify->sms()->send(to: '+998901234567', message: 'Salom!', idempotencyKey: 'order-8912-sms-1');
Response
send() returns a SendMessageResponse:
$response = $notify->sms()->send(to: '+998901234567', message: 'Salom!'); $response->messageId; // "b6e1f2b0-..." $response->status; // "queued" $response->price; // "120.0000" $response->currency; // "UZS" $response->balance; // "999880.00" $response->environment; // "production" or "sandbox"
Moderation of free-text messages
A message sent with a template (templateId) goes out immediately. Plain text (message:) is checked against the platform's system templates and your company's own templates — if it matches one of them (the {{variables}} may hold any value), it goes out immediately too. Text that matches no template is held until a Notify moderator approves it:
$response = $notify->sms()->send(to: '+998901234567', message: 'Summer sale -50%!'); $response->isHeldForModeration(); // true -> status "moderation", already charged
Once reviewed it moves on to queued (approved) or rejected (refused, refunded — you also get a message.rejected webhook). Check it later with $notify->message($id)->isRejected(). A moderator can turn an approved text into a template for your company, so the same kind of text is not held again. For anything time-sensitive — in particular one-time codes — use a template or the OTP API.
One-time codes (OTP)
The platform generates the code, delivers it and checks it — your application never sees or stores the code. Code length, validity and allowed attempts come from your company's OTP settings in the Notify cabinet, or the platform defaults set by the Notify administrator when you have not set your own. OTP messages are billed like normal messages and are never held for moderation.
// 1. Send - keep $otp->otpId (e.g. in the user's session) $otp = $notify->otp()->send(to: '+998901234567'); // channel: 'sms' (default), 'telegram', 'whatsapp' or 'email' $otp->otpId; // "0f6c1a52-..." $otp->codeLength; // 6 $otp->expiresIn; // 300 (seconds) $otp->maxAttempts; // 3 $otp->code; // null in production; the code itself in the sandbox, for testing // 2. Verify what the user typed $result = $notify->otp()->verify($otpId, $request->input('code')); if ($result->verified) { // phone confirmed } elseif ($result->canRetry()) { // wrong code - $result->attemptsLeft tries left } else { // $result->errorCode: OtpVerifyResponse::EXPIRED, ::ATTEMPTS_EXCEEDED or ::ALREADY_VERIFIED -> ask for a new code }
- A wrong, expired or used-up code is returned as a result, not thrown. A malformed request still throws
ValidationException, and an unknownotp_idthrowsNotFoundException. - A new code for the same recipient makes the previous one invalid.
- Requesting a new code too soon throws
RateLimitExceptionwitherrorCodeOTP_RESEND_TOO_SOONandretryAfterSeconds. send()andverify()are never retried automatically: a retried send could deliver a second code, and a retried verify could count as two attempts.send()needs the channel's send scope on your API client (e.g.sms:send).
Debt collection
Hand a debt over and Notify reminds the debtor channel by channel following a collection strategy (e.g. Telegram on day 1 after the due date, SMS on day 2, then WhatsApp and a voice call) until you report it paid. Requires the debts:manage scope.
$debt = $notify->debts()->create( name: 'Aziz Karimov', phone: '+998901234567', amount: 1250000, dueDate: '2026-10-15', // or any DateTimeInterface externalId: 'INV-1001', // your own reference - makes create() idempotent // strategyId: '...', // dashboard -> Collection -> Strategies -> API ID; omit for your default strategy ); $debt->id; // keep it - every other call needs it $debt->status; // "active" $debt->nextReminderAt; // "2026-10-16T10:00:00+05:00" // When the debtor pays (partial payments are fine - reminders continue for the rest): $result = $notify->debts()->recordPayment($debt->id, 1250000, externalId: 'PAY-5001'); $result->debt->isPaid(); // true -> no more reminders $result->payment->attributedChannel; // "sms" - the last delivered reminder before the payment $result->payment->daysLate; // 2 (negative = paid early) $notify->debts()->find($debt->id)->payments; // DebtPaymentResponse[] $notify->debts()->cancel($debt->id); // stop collecting
recordPayment()throwsValidationExceptionwitherrorCodeEXCEEDS_REMAINING(more than is owed),DEBT_CLOSED(already paid or cancelled) orINVALID_AMOUNT.create()andrecordPayment()are retried automatically only when you passexternalId- without it a retry could create a duplicate.- Every reminder is a normal, billed message; its status events reach this API client's webhook.
Receiving webhooks
Register a webhook per API client in the dashboard (API clients -> Webhook). Verify every request with the signing secret shown there:
use CloudMe\Notify\Exceptions\InvalidWebhookSignatureException; use CloudMe\Notify\Webhooks\WebhookVerifier; $verifier = new WebhookVerifier(getenv('NOTIFY_WEBHOOK_SECRET')); try { // Always the RAW body - never a re-encoded array. $event = $verifier->parse(file_get_contents('php://input'), getallheaders()); } catch (InvalidWebhookSignatureException) { http_response_code(401); exit; } // A retried delivery carries the same $event->id - skip ones you already handled. match ($event->event) { 'message.delivered' => markDelivered($event->messageId), 'message.failed' => markFailed($event->messageId, $event->errorCode), default => null, // message.sent, message.rejected, webhook.test }; http_response_code(200); // respond 2xx within 5 seconds
X-Signature is the hex HMAC-SHA256 of "{X-Webhook-Timestamp}.{raw body}"; parse() also rejects timestamps older than 5 minutes (replay protection - adjust with new WebhookVerifier($secret, toleranceSeconds: ...)).
Checking message status
$status = $notify->message($response->messageId); $status->status; // "delivered" $status->deliveredAt; // "2026-09-16T10:00:05Z"
Balance and reports
$balance = $notify->balance(); $balance->balance; // "999880.00" $rows = $notify->reports()->daily(new DateTime('2026-09-01'), new DateTime('2026-09-16')); $rows = $notify->reports()->monthly(); // omit the range for the platform's own default window
Error handling
Every failure the API reports is thrown as a typed exception, all extending CloudMe\Notify\Exceptions\NotifyException (which carries errorCode, statusCode, and the raw responseBody):
| Exception | When |
|---|---|
AuthenticationException |
Bad credentials, signature, or nonce (401, excluding an expired access token — that's refreshed transparently) |
InsufficientBalanceException |
Not enough wallet balance to send (402) |
ForbiddenException |
Revoked client, a client switched off because it exceeds your plan's API key limit, blocked company, disallowed IP, missing scope (403) |
NotFoundException |
Unknown message/resource/OTP (404) |
ValidationException |
Invalid request fields (422) — carries errors (field → messages) when the server sent field-level detail |
RateLimitException |
Too many requests, or an OTP requested again too soon (429) — carries retryAfterSeconds when the server sent one |
ServerException |
The API itself failed (5xx) — already retried a few times before this surfaces |
NetworkException |
Couldn't reach the API at all (timeout, DNS, connection refused) |
ApiException |
Any other non-2xx response |
ConfigurationException |
A problem on your side — bad private key, missing baseUrl, etc. — thrown immediately, no request was made |
use CloudMe\Notify\Exceptions\InsufficientBalanceException; use CloudMe\Notify\Exceptions\ValidationException; try { $notify->sms()->send(to: '+998901234567', message: 'Salom!'); } catch (InsufficientBalanceException $e) { // top up and retry later } catch (ValidationException $e) { $e->errors; // ['to' => ['The to field is required.']] }
Configuration
$notify = new NotifyClient( clientId: '...', apiKey: '...', privateKey: '...', baseUrl: '...', tokenStorage: $storage, // see below - defaults to in-memory, non-persistent options: [ 'timeout' => 10, // seconds, default 10 'connect_timeout' => 5, // seconds, default 5 'max_retries' => 3, // default 3 ], );
Persisting tokens across requests
By default, tokens only live for the current PHP process (ArrayTokenStorage) — a short-lived script or web request re-authenticates every single run. For a long-running app, supply persistent storage so the SDK reuses the same access/refresh token pair instead:
use CloudMe\Notify\Auth\FileTokenStorage; $notify = new NotifyClient( // ... tokenStorage: new FileTokenStorage(storage_path('notify/tokens.json')), );
Or implement CloudMe\Notify\Auth\TokenStorage yourself to back it with Redis, your framework's cache, or a database row.
Testing
composer install vendor/bin/pest
License
MIT