Skip to content

PHP SDK

  • PHP 8.2 or newer, Guzzle 7.
  • One client, five services: numbers, emails, captchas, webhooks, account.
  • Typed exceptions per API error code.
  • Optional request inspector.
composer require clearance-rest/sdk

Every method maps to one operation of the API reference and is named after its operationId (numbers.rent, emails.wait, webhooks.createEndpoint, …). The guides explain the behaviour behind the calls: Numbers, Emails, Captchas, Webhooks and Billing.

use ClearanceRest\Client;
$client = new Client(['api_key' => 'cr_live_...']);
// Rent a temporary number and wait for the verification SMS.
$rental = $client->numbers->rent(type: 'temporary', country: 'US', service: 'tg');
$number = $rental['item']['phone_number'];
$sms = $client->numbers->waitForSms($number, codeRegex: '(\d{5,6})');
echo $sms['code'];
// Pay-per-use calls return the charged `price` and the remaining `balance`.
echo $rental['price'], ' / ', $rental['balance'];
$client = new Client([
'api_key' => 'cr_live_...', // or CLEARANCE_REST_API_KEY
'base_url' => 'https://api.clearance.rest', // or CLEARANCE_REST_BASE_URL
'headers' => ['X-Request-Source' => 'cron'], // sent with every request
'timeout' => 120_000, // default wait time, ms
'request_timeout' => 30_000, // deadline of non-waiting calls, ms
'grace_ms' => 15_000, // client deadline margin on waiting calls, ms
'http_client' => $guzzleClient, // any GuzzleHttp\ClientInterface
'inspector' => $inspector, // optional, see below
'labels' => ['env' => 'prod'], // inspector labels for every request
]);

Every option is optional. With no options new Client() reads CLEARANCE_REST_API_KEY and CLEARANCE_REST_BASE_URL from the environment. Explicit options win over the environment.

  • Authentication. The key is sent as X-API-Key. If headers contains an Authorization header, api_key is not required. Without either, the constructor throws InvalidArgumentException.
  • Headers. Every request carries X-Client: sdk-php; it cannot be overridden.
  • Waiting calls. numbers->waitForSms, emails->wait and captchas->resolve keep the request open until a result arrives. They send X-Service-Timeout (milliseconds) with the value of the per-call timeout: argument, or the timeout option when omitted. The client gives up grace_ms after the server would, with a RequestTimeoutException, so a call never hangs forever. Other calls use request_timeout as their deadline.
  • No automatic retries. A failed call throws; retrying is your decision.

Per-call timeouts:

$sms = $client->numbers->waitForSms($number, timeout: 60_000);
// Catalog
$client->numbers->catalogCountries('temporary'); // type: temporary | monthly
$client->numbers->catalogServices('US', q: 'tele'); // q: case-insensitive match on name or code
$client->numbers->catalogOperators('US');
$client->numbers->catalogPrices('temporary', 'US', service: 'tg'); // your price, not a cost
$client->numbers->catalogAvailable('US', lineType: 'local', areaCode: '415');
// Rent
$client->numbers->rent(type: 'temporary', country: 'US', service: 'tg', maxPrice: 0.5, metadata: ['order' => 'o1']);
// For temporary numbers the catalogPrices price is the first price a rental tries, so passing it as maxPrice never fails
// with max_price_too_low. Without maxPrice the cap is about twice that price; the balance is held up to the cap and only
// the actual price is charged.
$client->numbers->rent(type: 'monthly', country: 'US', phoneNumber: '+14155550100');
// Inventory
$page = $client->numbers->listItems(status: ['ACTIVE'], type: 'temporary', metadata: ['order' => 'o1'], limit: 50);
foreach ($client->numbers->iterateItems(status: 'ACTIVE') as $item) { /* every page, one item at a time */ }
$item = $client->numbers->getItem($id)['item'];
// Metadata is a JSON Merge Patch: set keys are merged, null removes a key.
$client->numbers->updateItemMetadata($id, ['owner' => 'org_1', 'old' => null], ifMatch: $item['metadata_version']);
// Release (temporary numbers released before any SMS are refunded) and resend
$client->numbers->releaseItem($id);
$client->numbers->resendItem($id);
// Wait for an SMS
$sms = $client->numbers->waitForSms('+14155550100', sender: 'Service', timestamp: new DateTimeImmutable(), matchRegex: 'code', codeRegex: '(\d{6})');
// Connect a device number: a single-use QR token (30 s) for the Android app, or a ticket (60 s) for the live claim
// socket. Both return apk_url, the download address of the app.
$claim = $client->numbers->deviceClaim('office phone');
echo $claim['qr_payload'], ' ', $claim['apk_url'];
$ticket = $client->numbers->deviceClaimTicket('office phone');
// Wait for an email; `mark_as_read` in the response tells whether marking was scheduled.
$email = $client->emails->wait(
emailAccount: 'me@example.com',
emailSender: 'noreply@example.com',
codeRegex: '(\d{6})',
markAsRead: true,
);
echo $email['code'], $email['subject'];
// Attachments (keys come from $email['attachments'])
$file = $client->emails->getAttachment($email['attachments'][0]['key']);
file_put_contents('attachment.bin', $file['content']);
// Mailboxes
$client->emails->listAccounts(status: 'CONNECTED', emailProvider: ['gmail', 'outlook']);
foreach ($client->emails->iterateAccounts() as $account) {}
$client->emails->getAccount('me@example.com');
$client->emails->updateAccountMetadata('me@example.com', ['team' => 'a'], ifMatch: 3);
$client->emails->disconnectAccount('me@example.com');
// Connect a Gmail or Outlook mailbox: send the user's browser to authorize_url.
$start = $client->emails->oauthStart('gmail', returnUrl: 'https://app.example.com/connected', clientState: $sessionId);
// ... the browser returns to returnUrl with ?result_token=...&client_state=...
$result = $client->emails->oauthResult('gmail', $_GET['result_token']); // single use
// Cloudflare addresses
$domains = $client->emails->cloudflareDomains()['domains'];
$client->emails->createCloudflareAccount('inbox@' . $domains[0]);
// Authorization links: let anyone connect a mailbox without an account.
$link = $client->emails->createAuthLink(expiresIn: 86400, maxUses: 1, providers: ['gmail'], metadata: ['customer' => 'c1']);
echo $link['url'];
$client->emails->listAuthLinks(status: 'active');
$client->emails->getAuthLink($link['id']); // includes the mailboxes connected with it
$client->emails->revokeAuthLink($link['id']);
$result = $client->captchas->resolve('turnstile', [
'website_url' => 'https://example.com',
'website_key' => '0x4AAA...',
]);
match ($result['status']) {
'solved' => $token = $result['solution']['token'],
'failed' => throw new RuntimeException($result['error']['message']), // not charged, e.g. error.code "invalid_solution" when the solver's answer did not match the type's contract
'timeout' => null, // no solution within the wait time; not charged
};
// Solve hCaptcha in a real browser
$client->captchas->resolve('hcaptcha', ['website_url' => '...', 'website_key' => '...'], mode: 'browser');
// Supported types, their fields, solution fields and the price of 1000 solutions
$client->captchas->types();

resolve returns the response, which carries its own status: solved (charged), failed or timeout (not charged). Only HTTP errors and error envelopes throw. The body is closed: unknown fields are rejected with ValidationException. mode: 'browser' throws ProviderUnavailableException when no browser worker is connected.

Subscribe a URL of yours to events; deliveries are signed HTTP POSTs.

$types = $client->webhooks->eventTypes();
$created = $client->webhooks->createEndpoint('https://app.example.com/hooks', ['number.*', 'email.received']);
$secret = $created['endpoint']['secret']; // returned only here and by rotateSecret
$client->webhooks->listEndpoints();
$client->webhooks->getEndpoint($id);
$client->webhooks->updateEndpoint($id, eventTypes: ['*'], enabled: true); // enabled: true re-enables an auto-disabled endpoint
$client->webhooks->rotateSecret($id); // the previous secret stops working at once
$client->webhooks->testEndpoint($id); // queues a webhook.test event; returns delivery_id
$client->webhooks->deleteEndpoint($id);
$client->webhooks->listDeliveries($id, status: 'failed');
foreach ($client->webhooks->iterateDeliveries($id) as $delivery) {}
$client->webhooks->getDelivery($deliveryId); // includes the event payload
$client->webhooks->retryDelivery($deliveryId); // only failed deliveries

See Verifying signatures for the full explanation.

Each delivery carries X-Webhook-Timestamp and X-Webhook-Signature: v1=<hex>, where the signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"). Verify against the raw request body, before parsing it:

use ClearanceRest\Exceptions\WebhookSignatureException;
use ClearanceRest\Services\Webhooks;
$raw = file_get_contents('php://input');
try {
$event = Webhooks::constructEvent($raw, getallheaders(), $secret); // 300 s tolerance by default
} catch (WebhookSignatureException) {
http_response_code(400);
exit;
}
// $event = ['id' => 'evt_...', 'type' => 'number.rented', 'created_at' => '...', 'data' => [...]]
http_response_code(200); // any 2xx acknowledges the delivery

Webhooks::verifySignature($raw, $headers, $secret, toleranceSec: 300) returns a boolean instead. Both are static and make no network request. Header names are matched case-insensitively. Deliveries are at-least-once and unordered: deduplicate on $event['id'].

$balance = $client->account->balance();
// balance, held, currency, project: { spent_today, spent_this_month, daily_limit, monthly_limit }

Everything the SDK throws extends ClearanceRest\Exceptions\ClearanceRestException.

Exception When
ApiException Base of all API errors: getStatus(), getErrorCode() (error.code), getDetails(), getBody(). Thrown directly for unmapped codes.
ValidationException HTTP 400, validation_error and other invalid input.
AuthenticationException HTTP 401, missing or invalid API key.
InsufficientBalanceException insufficient_balance (402).
SpendLimitExceededException spend_limit_exceeded (402).
PermissionException HTTP 403, for example team_suspended.
NotFoundException HTTP 404.
ConflictException HTTP 409, for example no_numbers_available, number_not_active.
PreconditionFailedException metadata_version_mismatch (412): ifMatch is stale; getCurrentVersion().
RateLimitException HTTP 429.
ProviderUnavailableException provider_unavailable, supply_unavailable (503): nothing can serve the request right now; retry shortly.
ServerException Other HTTP 5xx, for example upstream_error.
WaitTimeoutException wait_timeout: nothing matched within the wait time.
NetworkException The request did not reach the API.
RequestTimeoutException Extends NetworkException; the client deadline passed. getWaitedMs().
WebhookSignatureException Webhooks::constructEvent rejected a delivery.

Calling a method with an empty identifier throws InvalidArgumentException before any request is made.

use ClearanceRest\Exceptions\{InsufficientBalanceException, WaitTimeoutException, ApiException};
try {
$sms = $client->numbers->waitForSms($number, timeout: 30_000);
} catch (WaitTimeoutException) {
// The API answers HTTP 200 with error.code "wait_timeout"; extend the wait or ask again.
} catch (InsufficientBalanceException $e) {
// top up
} catch (ApiException $e) {
error_log($e->getErrorCode() . ': ' . $e->getMessage());
}

Pass an inspector to have every request’s lifecycle (request, response, body, error, settled) reported. Any object with onRequest, onResponse, onBody, onError and onSettled methods works, for example InspectorRest\Inspector:

composer require inspector-rest/sdk
$inspector = new InspectorRest\Inspector(['server_key' => '...', 'service' => 'my-app']);
$client = new Client(['api_key' => '...', 'inspector' => $inspector]);
// Extra labels for a call tree (needs the inspector package):
InspectorRest\Labels::with(['job' => 'signup'], fn () => $client->numbers->rent(type: 'temporary', country: 'US', service: 'tg'));

Events carry the labels service (numbers, emails, …) and op (<service>.<method in snake_case>, for example numbers.wait_for_sms, emails.create_auth_link), plus the labels client option. Nothing is reported unless you pass an inspector; the SDK works without the inspector package installed. An inspector that throws never breaks a request.

Inject a Guzzle client with a MockHandler to run without network access:

use GuzzleHttp\{Client as Guzzle, HandlerStack};
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], json_encode(['success' => true, 'balance' => 10.0])),
]);
$client = new Client([
'api_key' => 'cr_live_test',
'http_client' => new Guzzle(['handler' => HandlerStack::create($mock)]),
]);
Service Method Endpoint
numbers catalogCountries(type) GET /numbers/catalog/countries
catalogServices(?country, ?q) GET /numbers/catalog/services
catalogOperators(country) GET /numbers/catalog/operators
catalogPrices(type, country, ?service, ?lineType) GET /numbers/catalog/prices
catalogAvailable(country, ?lineType, ?areaCode, ?contains, ?mms, ?voice) GET /numbers/catalog/available
rent(type, country, ?service, ?operator, ?phoneNumber, ?lineType, ?maxPrice, ?metadata) POST /numbers/rent
listItems(...), iterateItems(...) GET /numbers/items
getItem(id) GET /numbers/items/{id}
updateItemMetadata(id, metadata, ?ifMatch) PATCH /numbers/items/{id}
releaseItem(id) POST /numbers/items/{id}/release
resendItem(id) POST /numbers/items/{id}/resend
waitForSms(number, ...) GET /numbers/sms/wait
deviceClaim(label?) POST /numbers/device/claim/rest
deviceClaimTicket(label?) POST /numbers/device/claim/ticket
emails wait(...) GET /emails/wait
getAttachment(key) GET /emails/attachments/{key}
listAccounts(...), iterateAccounts(...) GET /emails/accounts
getAccount(email) GET /emails/accounts/{email}
updateAccountMetadata(email, metadata, ?ifMatch) PATCH /emails/accounts/{email}
disconnectAccount(email) POST /emails/accounts/{email}/disconnect
oauthStart(provider, returnUrl, ?clientState) GET /emails/{provider}/oauth/start
oauthResult(provider, token) GET /emails/{provider}/oauth/result
cloudflareDomains() GET /emails/cloudflare/domains
createCloudflareAccount(email) POST /emails/cloudflare/accounts
createAuthLink(...) POST /emails/auth-links
listAuthLinks(...), iterateAuthLinks(...) GET /emails/auth-links
getAuthLink(id) GET /emails/auth-links/{id}
revokeAuthLink(id) DELETE /emails/auth-links/{id}
captchas resolve(type, fields, ?mode, ?timeout) POST /captchas/resolve
types() GET /captchas/types
webhooks eventTypes() GET /webhooks/event-types
listEndpoints(), createEndpoint(...) GET, POST /webhooks/endpoints
getEndpoint(id), updateEndpoint(id, ...), deleteEndpoint(id) GET, PATCH, DELETE /webhooks/endpoints/{id}
rotateSecret(id) POST /webhooks/endpoints/{id}/rotate-secret
testEndpoint(id) POST /webhooks/endpoints/{id}/test
listDeliveries(id, ...), iterateDeliveries(id, ...) GET /webhooks/endpoints/{id}/deliveries
getDelivery(id) GET /webhooks/deliveries/{id}
retryDelivery(id) POST /webhooks/deliveries/{id}/retry
Webhooks::verifySignature(...), Webhooks::constructEvent(...) local, no request
account balance() GET /account/balance

List methods return the response array (items, accounts, links, deliveries plus next_cursor); pass cursor: to continue, or use the matching iterate* generator. Methods return decoded JSON as associative arrays with the API’s snake_case field names.