PHPackages                             beaconbox/beaconbox-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. beaconbox/beaconbox-php

ActiveLibrary

beaconbox/beaconbox-php
=======================

Official PHP SDK for BeaconBox, order updates your customers actually receive.

00PHP

Since Aug 24Pushed yesterdayCompare

[ Source](https://github.com/beaconbox-eu/beaconbox-php)[ Packagist](https://packagist.org/packages/beaconbox/beaconbox-php)[ RSS](/packages/beaconbox-beaconbox-php/feed)WikiDiscussions main Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

beaconbox/beaconbox-php
=======================

[](#beaconboxbeaconbox-php)

Official PHP SDK for [BeaconBox](https://beaconbox.eu), order updates your customers actually receive.

```
composer require beaconbox/beaconbox-php
```

PHP 8.1+. No runtime dependencies beyond `ext-curl`, `ext-json` and `psr/log` (an interface-only package): a library that drags an HTTP stack into a host application is a library that eventually conflicts with it.

Push an update
--------------

[](#push-an-update)

```
use BeaconBox\BeaconBoxClient;
use BeaconBox\Enum\MessageKind;
use BeaconBox\Model\MessagePush;

$client = new BeaconBoxClient(getenv('BEACONBOX_API_KEY'));

$result = $client->messages->push(new MessagePush(
    recipientEmail: 'buyer@example.com',
    subject: 'Your order has shipped',
    body: 'Tracking XY123456789EE. Estimated delivery Thursday.',
    kind: MessageKind::Updateable,
));

echo $result->id;  // m_8sKq2Vd1
```

The customer gets an email whose link opens their inbox already signed in. No password, no account to create.

Every response is a typed, readonly object, so your editor completes the fields and PHPStan checks them. Anything the API adds that this SDK does not know about yet is still readable through `$result->raw`.

Re-push the same `subject` with `MessageKind::Updateable` to overwrite it in place, quietly. Add `notify: true` to force a nudge on an update, or `obsoletes: [...]` to grey out messages this one replaces.

Two things to know before anything else
---------------------------------------

[](#two-things-to-know-before-anything-else)

### 1. Read the response, not the status code

[](#1-read-the-response-not-the-status-code)

A push returns **201 even when the SMS or WhatsApp message was not sent.** The update is already in the customer's inbox and the email nudge has gone, so a paid channel that could not send reports a reason and the request still succeeded.

```
use BeaconBox\Enum\Channel;
use BeaconBox\Enum\SkipReason;

$result = $client->messages->push(new MessagePush(
    recipientEmail: 'buyer@example.com',
    subject: 'Your order has shipped',
    body: 'Tracking XY123456789EE.',
    recipientPhone: '+37255550134',
    channels: [Channel::Sms],
));

if ($result->sms?->skippedReason === SkipReason::InsufficientCredit->value) {
    // Top up, then: $client->messages->resendSms($result->id);
}
```

**This SDK does not throw on a skip**, deliberately. Treating one as an error is what invites a retry, and a retry of a push that already succeeded is a second message to a real person.

### 2. Idempotency is handled for you, and you can do better

[](#2-idempotency-is-handled-for-you-and-you-can-do-better)

Every write carries an `Idempotency-Key`. This SDK generates one per call and **reuses it across its own retries**, so a connection that dies with the answer in flight cannot become a duplicate email and a duplicate charged SMS.

Pass your own whenever you have a natural key:

```
$client->messages->push($message, idempotencyKey: 'order-4711-shipped');
```

Then a retry from *anywhere*, your queue, a cron, a human clicking twice, collapses onto the same key rather than only the retries this SDK makes internally.

Everything else
---------------

[](#everything-else)

```
// Delivery status
$message = $client->messages->get('m_8sKq2Vd1');
$message->delivery->opened;

// Every message, following cursors. A generator, so a year of history is not held in memory
foreach ($client->messages->each(recipientEmail: 'buyer@example.com') as $message) { /* … */ }

// Take one back: withdrawn for the recipient, any queued nudge called off, no credit spent
$client->messages->retract('m_8sKq2Vd1');

// Up to 100 pushes. Always 200: read ->failed, not the status code
$result = $client->messages->pushBatch([$one, $two]);
foreach ($result->failures() as $item) {
    error_log("item {$item->index} rejected: {$item->errorCode}");
}

// Contact details. Reads are masked: a leaked key must not dump a phone book
$client->recipients->setPhone('buyer@example.com', '+37255550134');
$client->recipients->clearPhone('buyer@example.com');

// The prepaid balance. One balance, shared by SMS and WhatsApp
$client->credits->balance()->balance;

// Keys and webhook endpoints
$client->keys->create('orders service');
$client->webhookEndpoints->create('https://example.com/hooks');  // empty list means every event
```

### Pay only for the customers the email did not reach

[](#pay-only-for-the-customers-the-email-did-not-reach)

`escalateIfUnreadAfterMinutes` holds the paid channel back. The SMS is sent only if the recipient still has not opened the message after that long, and if they open it first, nothing is sent and nothing is charged.

```
$client->messages->push(new MessagePush(
    recipientEmail: 'buyer@example.com',
    subject: 'Action needed on your order',
    body: 'We could not process your payment.',
    recipientPhone: '+37255550134',
    channels: [Channel::Sms],
    escalateIfUnreadAfterMinutes: 120,
));
```

### Erasing a recipient's WhatsApp history

[](#erasing-a-recipients-whatsapp-history)

```
$receipt = $client->recipients->eraseWhatsApp('buyer@example.com');

// The half only you can finish: erased replies whose words may already be in *your* mailboxes.
$yours = $receipt->repliesAForwardEmailMayHaveCarried;
```

Verifying webhooks
------------------

[](#verifying-webhooks)

```
use BeaconBox\Enum\WebhookEventType;
use BeaconBox\Exception\WebhookVerificationException;
use BeaconBox\Webhooks;

try {
    $event = Webhooks::verify(
        rawBody: file_get_contents('php://input'),   // the RAW body, byte for byte
        signatureHeader: $_SERVER['HTTP_X_BEACONBOX_SIGNATURE'] ?? '',
        secret: getenv('BEACONBOX_WEBHOOK_SECRET') ?: '',
    );
} catch (WebhookVerificationException) {
    http_response_code(400);
    return;
}

if ($event->type === WebhookEventType::MessageBounced) {
    // ...
}
```

**Pass the raw body.** Decoding to an array and re-encoding changes the bytes over key order and whitespace, and the signature stops matching, in production, on a payload shaped slightly differently from the one you tested with. The helper also checks the timestamp (which is signed, so a captured delivery cannot be replayed) and compares in constant time.

Deliveries are **retried**, so the same `$event->id` can arrive twice. Deduplicate on it.

Errors
------

[](#errors)

Everything extends `BeaconBox\Exception\BeaconBoxException`.

ExceptionWhen`AuthenticationException`401, key missing, malformed or revoked`PermissionException`403`InvalidRequestException`422, a malformed field, or a reused idempotency key with a different body`ResourceMissingException`404, no such id. Also what another business's id looks like, deliberately`ConflictException`409, already sent, or an identical request still in flight`RateLimitException`429, after the SDK has already retried`ServerException`5xx, after the SDK has already retried`ApiConnectionException`no answer at all. **Not** proof the work did not happen`WebhookVerificationException`a delivery could not be proven to be oursBranch on `$exception->errorCode` (a stable dotted string such as `message.not_found`), not on the message text. `$exception->requestId` is what support will ask for.

Configuration
-------------

[](#configuration)

```
$client = new BeaconBoxClient(
    apiKey: getenv('BEACONBOX_API_KEY'),
    baseUrl: 'https://api.beaconbox.eu',
    timeoutMs: 30_000,
    retryPolicy: new BeaconBox\RetryPolicy(maxRetries: 2),
);
```

Inside a job runner that already retries, pass `new RetryPolicy(maxRetries: 0)` so the two schedules do not multiply.

Logging
-------

[](#logging)

PSR-3, and **opt-in**. Pass any `LoggerInterface` and you get output; pass nothing and the SDK is completely silent, because a library that writes to a file or to stderr of its own accord is a library fighting the host application's logging.

```
$client = new BeaconBoxClient(
    apiKey: getenv('BEACONBOX_API_KEY'),
    logger: $monolog,
);
```

LevelWhat`debug`every request and response, with status and elapsed time`warning`a retry (with reason and backoff), and giving up after the last oneNothing is emitted at `info` or above, so a `warning` from this SDK always means something went wrong. Each record carries context (`route`, `attempt`, `status_code`, `idempotency_key`).

**Nothing sensitive is ever logged.** Not the API key or any header, not request or response bodies, not the query string, and not the interpolated URL path. A route template is logged instead, so `/recipients/buyer@example.com/sms` appears as `/recipients/{email}/sms`. The context is an allow-list rather than a redaction pass, because redaction is a list of things somebody remembered to hide and the field added next year is not on it.

### Security defaults you cannot accidentally lose

[](#security-defaults-you-cannot-accidentally-lose)

- **Plain `http` is refused** for anything but localhost, so a misconfigured `baseUrl` cannot put your API key on the wire in clear.
- **Redirects are never followed.** curl would re-send the `Authorization` header to wherever a redirect points.
- `CURLOPT_SSL_VERIFYPEER` and `VERIFYHOST` are set explicitly, so a php.ini or a system curl config that has turned them off cannot silently disable certificate verification.
- The API key is redacted from `var_dump`, and so are `NewApiKey::$key` and a webhook endpoint's secret.
- Webhook signatures are compared with `hash_equals`, over a signed timestamp.

Behind a corporate CA or a private staging certificate? Pass `caBundle:` a path to a PEM file. There is no option to turn verification off, because there is no legitimate production reason to.

Bring your own HTTP client
--------------------------

[](#bring-your-own-http-client)

```
$psr17 = new \Nyholm\Psr7\Factory\Psr17Factory();
$client = new BeaconBoxClient(
    apiKey: getenv('BEACONBOX_API_KEY'),
    httpClient: new \GuzzleHttp\Client(),
    requestFactory: $psr17,
    streamFactory: $psr17,
);
```

The bundled curl transport keeps one persistent handle for connection reuse, which makes it unsafe to share across coroutines. Inject a PSR-18 client if you run Swoole or ReactPHP.

Development
-----------

[](#development)

```
composer install
composer test      # phpunit, hermetic
composer analyse   # phpstan, level 8
```

The live suite runs against a real BeaconBox. See [`tests/Live/README.md`](tests/Live/README.md).

Licence
-------

[](#licence)

MIT. See [LICENSE](LICENSE).

BeaconBox is a product of BloomHarbor OÜ, a company registered in Estonia.

###  Health Score

20

↑

LowBetter than 12% of packages

Maintenance65

Regular maintenance activity

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/96806670?v=4)[beaconbox](/maintainers/beaconbox)[@Beaconbox](https://github.com/Beaconbox)

---

Top Contributors

[![nicholasamorim](https://avatars.githubusercontent.com/u/2200260?v=4)](https://github.com/nicholasamorim "nicholasamorim (1 commits)")

### Embed Badge

![Health badge](/badges/beaconbox-beaconbox-php/health.svg)

```
[![Health](https://phpackages.com/badges/beaconbox-beaconbox-php/health.svg)](https://phpackages.com/packages/beaconbox-beaconbox-php)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
