Skip to the documentation
PHP

Errors

Every failure throws. One class for a refusal, one for no answer, and a request id on every API error.

Catching one

catch_errors.php
use OpenEmail\Exception\ApiException;use OpenEmail\Exception\NetworkException; $message = [    'from' => '[email protected]',    'to' => '[email protected]',    'subject' => 'Your September invoice',    'text' => 'Invoice attached.',]; try {    $client->emails->send($message);} catch (ApiException $error) {    if ($error->isValidation()) {        error_log($error->errorCode . ' ' . $error->param . ' ' . $error->getMessage());    }     if ($error->isPermission()) {        $book = $client->addresses->listAll();        error_log('this key may send as ' . implode(', ', array_column($book->addresses, 'address')));    }     if ($error->isRateLimited()) {        error_log('try again in ' . $error->retryAfterSeconds . ' seconds');    }     error_log($error->status . ' ' . $error->requestId);     throw $error;} catch (NetworkException $error) {    if ($error->isTimeout()) {        error_log('no answer in time');    }     throw $error;}

A permission_error on a send is usually the KEY’s send scope, a domain or an address it was not given, rather than the workspace, which is why the sample logs what addresses->listAll() says this key may send as.

Each kind of refusal has a subclass of its own, so a catch can pick the ones it handles by class and let the rest go on up.

catch_by_class.php
use OpenEmail\Exception\AuthenticationException;use OpenEmail\Exception\OpenEmailException;use OpenEmail\Exception\PermissionException;use OpenEmail\Exception\ValidationException; $message = [    'from' => '[email protected]',    'to' => '[email protected]',    'subject' => 'Your September invoice',    'text' => 'Invoice attached.',]; try {    $client->emails->send($message);} catch (ValidationException $error) {    error_log($error->param . ': ' . $error->getMessage());} catch (AuthenticationException|PermissionException $error) {    error_log('the key cannot do this: ' . $error->errorCode);     throw $error;} catch (OpenEmailException $error) {    error_log($error::class . ': ' . $error->getMessage());     throw $error;}

The classes

Every class lives in OpenEmail\Exception.

ClassWhen
OpenEmailExceptionThe interface every exception the package throws implements, so catch (OpenEmailException $error) catches all of them, InvalidArgumentException included.
ApiExceptionThe API answered, and not with a success. Carries status, type, errorCode, param, docUrl, requestId, retryAfterSeconds, fields and body. Thrown as itself when type is api_error, as a server fault is, and as the subclass for its type otherwise. It extends RuntimeException.
InvalidRequestException, AuthenticationException, PermissionException, NotFoundException, ConflictException, ValidationException and RateLimitExceptionSubclasses of ApiException, one for each type: invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, validation_error and rate_limit_error.
NetworkExceptionNo response arrived: DNS, TLS, a refused or dropped connection, or the timeout. getPrevious() holds the exception underneath, and isTimeout() is true when the timeout was the reason. It extends RuntimeException.
WebhookSignatureExceptionOpenEmail::verifyWebhookSignature() refused a delivery. It extends UnexpectedValueException.
InvalidArgumentExceptionThrown before anything is sent: a missing or malformed key, an unusable baseUrl:, an empty id. It extends PHP’s own InvalidArgumentException, because it means the call itself is wrong.

What an ApiException carries

getMessage()string
The API’s own sentence, written for a person and naming the offending value where there is one. Not a stable identifier, so switch on `errorCode`.
statusint or null
The HTTP status of the answer, which `getCode()` returns too. null only when a success came back in a shape the client could not read.
typestring
One of the eight values in `OpenEmail\Constants\ErrorTypes`, a set that is fixed and will not grow. When the body names none, it is inferred from the status.
errorCodestring
The specific failure, such as `from_address_forbidden` or `invalid_email_address`. It is named `errorCode` because PHP keeps `code` for the number `getCode()` returns. Open and additive, so treat one you do not recognise as its `type`. It is `unrecognised_response` when the body was not the API’s error envelope.
paramstring or null
The field that was refused, as a dotted path such as `to.0`, when the failure names one.
docUrlstring or null
A page about this failure, when the API names one.
requestIdstring or null
The id the server logged the request under, from the body or the `x-request-id` header.
retryAfterSecondsint, float or null
The wait the server asked for in `Retry-After`, in seconds, whether it sent a number or a date. null when it sent none.
fieldsarray or null
One array per problem, each with `key` and `error`, such as `['key' => 'email', 'error' => 'email']` when `forms->subscribe` refused the answers with a 422 `invalid_form_submission`. null when the error lists none.
bodymixed
The whole error response, decoded. null when it was empty or not JSON.
MethodTrue when
isAuth()type is authentication_error, a 401: no key, the wrong kind of credential, or a key we did not issue.
isPermission()permission_error, a 403: a real key without the scope or the From address it needs.
isScopeMissing()errorCode is insufficient_scope, the 403 that names a missing scope.
isInvalidRequest()invalid_request_error, a 400: a request that could not be understood. A message over the size ceiling comes back as a 422 message_too_large, so isValidation() is the method that catches it.
isValidation()validation_error, a 422: the schema refused it, and param names the field.
isNotFound()not_found_error, a 404: no such resource.
isConflict()conflict_error, a 409: the resource is past the point where this could be done to it.
isRateLimited()rate_limit_error, a 429. retryAfterSeconds holds the wait when the server named one.
isServerError()status is 500 or above. Quote requestId if you contact support.
isRetryable()status is 408, 429, 500, 502, 503 or 504.
isStepUpRequired()errorCode is step_up_required, the 403 an OAuth access token gets before a sensitive change until the person has verified a code.

Most of these read type, the fixed half of the envelope, and each subclass stands for one type. errorCode stays a string, because the API guarantees it is open and additive, so treat one you do not recognise as its type. A closed list would make a package upgrade the price of reading a new failure mode.

A body that is not the API’s error envelope still throws an ApiException, with type inferred from the status and errorCode set to unrecognised_response. A success whose body is not JSON throws one too.

isRetryable() describes the status, not your call. A call that is safe to repeat has already been retried by the time it throws, and a 429 such as send_quota_exceeded or ai_quota_exceeded fails the same way until its allowance resets, so show it to a person rather than looping on it.

An exception your HTTP client throws becomes a NetworkException once any retries the call allows are spent, with the original as getPrevious(). A LogicException and any Error, such as a TypeError, mean a bug in it, so they are thrown unchanged and never retried. Psr18HttpClient keeps only the message of an exception from the client it wraps, because that exception holds the request and its Authorization header.

requestId

Every ApiException carries the request id the server sent, from the error body or the x-request-id header, and it is the only thing tying your failure to a line in the server’s log. A success returns the decoded body alone, so there is no request id to read on one.