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/sdkEvery 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.
Quick start
Section titled “Quick start”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 options
Section titled “Client options”$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. Ifheaderscontains anAuthorizationheader,api_keyis not required. Without either, the constructor throwsInvalidArgumentException. - Headers. Every request carries
X-Client: sdk-php; it cannot be overridden. - Waiting calls.
numbers->waitForSms,emails->waitandcaptchas->resolvekeep the request open until a result arrives. They sendX-Service-Timeout(milliseconds) with the value of the per-calltimeout:argument, or thetimeoutoption when omitted. The client gives upgrace_msafter the server would, with aRequestTimeoutException, so a call never hangs forever. Other calls userequest_timeoutas their deadline. - No automatic retries. A failed call throws; retrying is your decision.
Per-call timeouts:
$sms = $client->numbers->waitForSms($number, timeout: 60_000);Numbers
Section titled “Numbers”// 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');Emails
Section titled “Emails”// 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']);Captchas
Section titled “Captchas”$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.
Webhooks
Section titled “Webhooks”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 deliveriesVerifying deliveries
Section titled “Verifying 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 deliveryWebhooks::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'].
Account
Section titled “Account”$balance = $client->account->balance();// balance, held, currency, project: { spent_today, spent_this_month, daily_limit, monthly_limit }Errors
Section titled “Errors”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());}Inspector (optional)
Section titled “Inspector (optional)”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.
Testing your code
Section titled “Testing your code”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)]),]);Method reference
Section titled “Method reference”| 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.