Search by

meser10 / api-client

meser10

PHP client for the Meser 10 JSON API: transactional SMS, transactional email and contacts. No dependencies.

v1.0.1 2026-09-26 08:55 UTC

This package is auto-updated.

Last update: 2026-09-26 08:56:27 UTC


README

Transactional SMS and email from PHP, through the Meser 10 JSON API. One-time passwords, order confirmations, receipts, delivery notices.

No dependencies. PHP 7.4 and up. It installs inside a WordPress plugin or somebody else's shared hosting without dragging a dependency tree along.

composer require meser10/api-client
use Meser10\Client;

$client = new Client(getenv('MESER10_API_KEY'), ['userAgent' => 'my-app/1.0']);

$client->sendSms('0501234567', 'Your code is 481902.', 'MyShop');

What it covers

Five functions, which is what the JSON gateway exposes:

$client->sendSms($to, $body, $from);                       // one SMS, immediately
$client->sendEmail($to, $subject, $html, $from, $replyTo); // one email, immediately
$client->createContact($listName, $fields);                // add or update
$client->changeContactStatus($emailOrPhone, $status);      // Active, Unsubscribed, Bounced
$client->status($email);                                   // read a contact's state
$client->verifyKey();                                      // is this key accepted?

Campaigns, mailing lists, groups, reporting and attachments are on the SOAP service, not on this gateway, so they are not here either.

The three things this package exists to handle

Each one has cost somebody a working day.

Every call answers HTTP 200, including a rejected key. Success lives in ErrorCode, which the gateway serialises as a number on some functions and as a string on others. This client normalises it and throws on anything but zero, so if ($response->ok) cannot quietly be wrong.

An authentication failure must never be retried. Repeated failures block the calling IP address for several hours. The block is on the address rather than the key, so reissuing the key and trying again makes it worse, and on shared hosting it takes down every other integration sending from that machine. So this package has no retry logic anywhere, and after one AuthenticationException the client latches shut and refuses to reach the network again, even if your code loops:

for ($i = 0; $i < 25; $i++) {
    try { $client->sendSms(...); } catch (AuthenticationException $e) { /* naive */ }
}
// Exactly one request left the process. There is a test for this.

When you suspect a key problem, call verifyKey() instead of retrying. It probes an address no account holds, so it creates nothing, sends nothing and spends nothing.

The hosts sit behind Cloudflare with Browser Integrity Check on, which reads the User-Agent header and blocks default library signatures. A User-Agent is always sent. If Cloudflare refuses anyway, you get a TransportException that says so rather than a JSON parse error, which usually means a proxy is rewriting the header.

Things the gateway will not do, checked before a request is spent

sendEmail() refuses a comma separated recipient list, and an empty or malformed ReplyToEMail, locally. The gateway accepts no From address (a display name only), no CC, no BCC, no attachments and no second recipient, and there is deliberately no way to pass any of them through this method. If a message needs them, this is not the transport for it.

sendSms() validates the sender identity first. An alphanumeric sender name is at most 11 characters, Latin letters, digits and spaces only, with at least one letter; or you give a number. A too-long name is not truncated by the network, the call is simply rejected, so catching it locally saves a wasted request:

Sms::assertSenderIsWellFormed('Meser10 Ltd');    // fine, exactly 11
Sms::assertSenderIsWellFormed('Meser10 Israel'); // throws: 14 characters
Sms::assertSenderIsWellFormed('מסר 10');         // throws: Hebrew cannot be a sender name
Sms::assertSenderIsWellFormed('0501234567');     // fine, a number

The 11-character limit is a GSM constraint on alphanumeric sender IDs, not a Meser 10 one. Support varies by destination: alphanumeric sender IDs are not available in the United States or Canada, where a number is used instead.

createContact() refuses an unknown field name rather than dropping it silently, because email instead of EMail is the single easiest mistake to make here, and it fails in a way that looks like nothing happened.

Hebrew

Sms::parts() tells you what a message will be billed as. One Hebrew letter anywhere pushes the whole message to Unicode, which takes a single part from 160 characters down to 70:

Sms::parts(str_repeat('a', 160));        // 1
Sms::parts(str_repeat('a', 100) . 'א');  // 2

Hebrew is sent unescaped, not as ק sequences.

For email, set dir="rtl" in your own HTML. Nothing does it for you, and Hebrew mail sent without it gets left aligned by some clients. That is the usual cause of "the email looks broken".

Reading a contact's status

Two ids mean active. A contact created through the API is 10; one moved back to Active after a bounce or an unsubscribe is 30. Code that checks for 10 alone silently drops every reactivated contact, so use isMailable():

$status = $client->status('person@example.com');

$status->exists();         // false when the address is not on the account
$status->isMailable();     // true for 10 and for 30
$status->isUnsubscribed(); // 50
$status->isBounced();      // 40
$status->name();           // a stable English name for your logs
$status->label;            // the gateway's own Hebrew text, for display only

changeContactStatus() answers success even for an address that is not on the account, so it cannot tell you whether the contact existed. status() is the only real check.

Errors

Everything thrown implements Meser10\Exception\Meser10Exception, so one catch covers the lot. When you want to tell them apart:

Exception Means What to do
InvalidRequestException Refused here, before the network. Nothing sent, nothing spent. Fix the call.
AuthenticationException ErrorCode 1, the key was rejected. Stop. Alert. Never retry.
ApiException Any other ErrorCode. Read ->errorCode, ->gatewayMessage, ->isTransient(). ErrorCode 3 is worth one retry; 4 means a parameter, most often a list that does not exist or a sender not yet approved.
TransportException No readable answer: network, timeout, or Cloudflare. Log and move on.

The gateway's own messages arrive in Hebrew on most failures, so show your own wording to users and keep ->gatewayMessage for the log.

Webhooks

There are no webhooks on this gateway. Anything that has to react to an event polls for it.

Bringing your own HTTP client

The bundled transport is curl and nothing else. To route calls through your framework's client, or to test without a network, implement one interface:

use Meser10\Response;
use Meser10\Transport;

final class MyTransport implements Transport
{
    public function send(string $method, string $url, array $headers, ?string $body): Response
    {
        // ... your client here
        return new Response($statusCode, $responseBody);
    }
}

$client = new Client($key, ['transport' => new MyTransport()]);

Examples

Tests

php tests/run.php

46 tests, no dev dependencies, and nothing touches the network or sends a message. They cover the ErrorCode contract in both serialisations, the latch that stops a retry loop, the sender name rules, Hebrew part counting, and the status ids.

The machine-readable contract

The same five functions are published as an OpenAPI 3.1 description and a Postman collection.

Support

Never include your API key in a support message, a bug report or an issue. If one has been shared anywhere, reissue it in the Meser 10 interface.

Licence

MIT. See LICENSE.