zadarma/user-api-v1
A PHP client for the Zadarma User API v1, making it easy to integrate Zadarma's VoIP, PBX, SMS, and call-management features into your applications. It simplifies authentication and HTTP requests, returning API responses in a convenient format for PHP and Laravel projects.
zadarma/user-api-v1 is a single-class wrapper around the Zadarma REST API (VoIP, PBX, SMS, call tracking, balance, documents). It is not Laravel-aware — no service provider, no facade, no config, no namespaced PSR-4 code. Treat it as a low-level HTTP client you drop behind your own abstraction.
Install and inspect first
composer require zadarma/user-api-v1
Then open vendor/zadarma/user-api-v1/ZadarmaApi.php before writing any code. It is ~200 lines: a constructor, a call() method, and two private transports (runCurl, runFileGetContents). Reading it once tells you everything you need to know about signing, error shapes, and limits — there is no documentation beyond the README and the Zadarma API docs.
The constructor is effectively:
$api = new \ZadarmaApi($key, $secret, 'https://api.zadarma.com', 'en', true);
// api url (optional) lang useCurl
useCurl = true is strongly recommended; the fallback path uses file_get_contents with stream context and is harder to configure (no timeouts you control, no easy debugging).
First use case: verify credentials and balance
Zadarma’s docs recommend starting with /v1/info/balance/. It is read-only, cheap, and proves your key/secret/signature are correct:
$result = $api->call('/v1/info/balance/', [], 'GET');
if ($result['status'] === 'ok') {
logger()->info('Zadarma balance', ['amount' => $result['balance']]);
}
Where to look when things break
sign header/query parameter. If you get 401 Unauthorized or signature mismatch, the problem is almost always parameter ordering, URL-encoding, or a body/query mismatch — not your credentials.format=json. The package injects this; if you forget to call through the class and build your own request, you get XML by default.status field in the decoded response body (ok vs error) — HTTP 200 does not mean success.Register as a singleton behind an interface
Do not inject ZadarmaApi directly. Wrap it, so you can swap it out (the package has 0 dependents and no release since Jan 2023 — assume you may need to replace it).
// app/Services/Zadarma/ZadarmaClient.php
namespace App\Services\Zadarma;
use ZadarmaApi;
class ZadarmaClient
{
public function __construct(private array $config) {}
public function raw(): ZadarmaApi
{
return new ZadarmaApi(
$this->config['key'],
$this->config['secret'],
$this->config['url'] ?? 'https://api.zadarma.com',
$this->config['lang'] ?? 'en',
true // always curl
);
}
public function call(string $path, array $params = [], string $method = 'GET'): array
{
$response = $this->raw()->call($path, $params, $method);
// Normalise: the package returns a decoded array already
if (($response['status'] ?? null) === 'error') {
throw new ZadarmaException($response['message'] ?? 'Zadarma error', $response);
}
return $response;
}
}
Bind in AppServiceProvider::register():
$this->app->singleton(ZadarmaClient::class, fn ($app) => new ZadarmaClient(
config('services.zadarma') // key, secret, lang, url
));
Config in config/services.php:
'zadarma' => [
'key' => env('ZADARMA_KEY'),
'secret' => env('ZADARMA_SECRET'),
'lang' => env('ZADARMA_LANG', 'en'),
],
Thin, focused service methods
Don’t expose call('/v1/sms/send/', ...) from controllers. Wrap each business operation:
class SmsService
{
public function __construct(private ZadarmaClient $client) {}
public function send(string $from, string $to, string $message): array
{
return $this->client->call('/v1/sms/send/', [
'sender' => $from,
'number' => $to,
'message' => $message,
], 'POST');
}
}
GET vs POST matters: Zadarma signs the concatenation of method + params + md5(params), so switching verbs changes the signature — always match the documented verb for each endpoint.
Queue outbound side effects
SMS sending, PBX callbacks (/v1/pbx/...), and call tracking triggers are network-bound and rate-limited. Push them through a job:
class SendSmsJob implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = [10, 60, 300];
public function handle(SmsService $sms): void
{
$sms->send($this->from, $this->to, $this->message);
}
}
Wrap calls in ZadarmaException so failed() can log and alert without leaking secrets.
Inbound webhooks — verify before trusting
Zadarma’s call tracking and SMS delivery callbacks arrive as application/x-www-form-urlencoded POST with a sign field. The package does not provide inbound verification. Implement it yourself:
$params = $request->post();
$sign = $params['sign'] ?? '';
unset($params['sign']);
// Per Zadarma docs: sort params, concatenate values, HMAC-SHA1 with SECRET
$check = base64_encode(hash_hmac('sha1', implode('', $params), config('services.zadarma.secret'), true));
abort_unless(hash_equals($check, $sign), 403);
This is exactly the class of thing the package does not abstract away — read the Zadarma webhook docs, not the package source, for this logic.
Logging and observability
You lose all visibility inside ZadarmaApi (it silently builds the URL and calls curl). Wrap call() with Log::debug() of the endpoint (never the secret) and elapsed time, and emit a metric on failure. This is the only practical observability hook the package gives you.
ZadarmaApi without use ZadarmaApi; at the top of each file — or wrap it once (recommended) so the name appears only in one file.call() returns an array, not an object. It decodes JSON internally. status => error responses are not exceptions — always check status and treat absence of the key as a problem too (some 4xx bodies are empty).+ and %2F are rewritten). Do not pre-encode values, do not nest arrays, and do not rely on PHP’s array ordering. If signing fails, dump the exact query string the class builds (temporarily) and compare with Zadarma’s signer sample in their docs.format=json is injected as a param. Do not pass format yourself — you will confuse the signature.curl is missing, file_get_contents will hang on a slow endpoint. Force true for $curl and pin CURLOPT_TIMEOUT by subclassing or rewriting rather than relying on the default.'ru' changes the message field text. Parse on Zadarma error codes, not message strings.error status. Add backoff in your job wrapper.call() method) so the swap is a one-file change.\Exception with a message that may include the URL containing the sign parameter. Never surface ZadarmaException raw to clients — wrap with a domain exception and log the detail server-side only.call(), and the job layer. Design those three layers well and the package becomes an implementation detail.How can I help you explore Laravel packages today?