Reference
Errors & exceptions
Every failure the SDK reports is an exception you can catch by type: one for authentication, one for a missing resource, one for a stale ETag, one for an account that doesn't allow what you asked, and so on. This page shows the full hierarchy, exactly how an Adobe error becomes one of those types, and the catch patterns that work well in web requests and queue jobs.
The hierarchy
Every exception implements Conceptlz\AdobeSign\Exception\AdobeSignException, so one
catch covers everything the SDK throws. Below it, exceptions fall into four families:
errors the API returned, input rejected before sending, requests that never got a response, and
rejected inbound webhooks.
Conceptlz\AdobeSign\Exception. Catch a subclass before its parent: OAuthException before AuthenticationException, any of the six before ApiException.Exception reference
| Exception | Extends | Thrown when | What to do |
|---|---|---|---|
ApiException | \RuntimeException | Any error response not covered by a subclass (403 PERMISSION_DENIED, 5xx, an exhausted 503), or a 2xx answer the SDK can't use (invalid JSON, no id after a create, a non-HTTPS apiAccessPoint) | Log it with errorCode; retry 5xx later |
AuthenticationException | ApiException | 401, or an auth code on any status: bad or revoked Integration Key, expired token that couldn't be refreshed, invalid asUser() target | Fix the credential; don't retry blindly |
OAuthException | AuthenticationException | The user denied access, the state didn't match, Adobe rejected a code or refresh token, or no token is stored for the user | Send the user through authorization again |
NotFoundException | ApiException | 404: unknown or deleted id, or an agreement that isn't signable or visible yet | Check the id; right after create, wait and retry |
ValidationException | ApiException | 400 or 415, or a request-validation code: missing parameter, invalid email, bad JSON | Fix the request; retrying won't help |
RateLimitException | ApiException | 429 after the retry middleware gave up, or when Adobe asked for a longer wait than maxRetryDelay | Try again after $retryAfter seconds |
PreconditionFailedException | ApiException | 412: the If-Match ETag is stale | get() again and repeat the write |
AccountCapabilityException | ApiException | The account's settings, plan or quota don't allow the request: mixed signing order, an identity method or feature that's off, a used-up quota (government ID, KBA, SMS, sending, user seats) | Change the Acrobat Sign account settings, or the request |
InvalidArgumentException | \InvalidArgumentException | Rejected locally before any request: empty id, invalid email, signing-order gap, AES for an approver, unreadable file, untrusted api_access_point | Fix the calling code |
TransportException | \RuntimeException | No HTTP response at all: DNS, TLS, timeout, connection refused | Retry later; a POST may have been processed |
WebhookException | \RuntimeException | An inbound webhook had a missing or unknown X-AdobeSign-ClientId, or a body that isn't a notification | Answer 4xx (the handler does this for you) |
How a response becomes an exception
When Adobe answers with anything other than 2xx or 304, an internal ErrorMapper picks the
exception class. It is marked @internal, so you won't call it yourself, but knowing its
rules tells you exactly what to catch.
- Read the error body
Adobe normally sends
{"code": "INVALID_AGREEMENT_ID", "message": "…"}. The spec's examples also use{"reason": "INVALID_AGREEMENT_ID: …"}, and the OAuth endpoints use{"error": "…", "error_description": "…"}; all three are understood. A body that isn't JSON becomes the message, cut to 200 characters. - Look up the Adobe error code
If the code is in the SDK's table, that decides the class, whatever the HTTP status.
INVALID_X_API_USER_HEADERarrives as a 400 but is anAuthenticationException, because it is an authentication problem. LikewiseDYNAMIC_DOCUMENT_EXPIRATION_NOT_ENABLEDarrives as a 412 but is anAccountCapabilityException: no fresh ETag would make it succeed. - Fall back to the HTTP status
An unknown or missing code is mapped by status: 400 and 415 to
ValidationException, 401 toAuthenticationException, 404 toNotFoundException, 412 toPreconditionFailedException, 429 toRateLimitException. Every other status gives a plainApiException. - Check for an account restriction
If the result so far is
ValidationExceptionand the message says something like "not allowed by account settings", "enabled for your account" or "not enabled for this account", the class becomesAccountCapabilityException. Adobe reports some account limits only as a genericBAD_REQUEST, and the wording is the only way to tell them from bad input.
The message always has the same shape, so logs are easy to search:
GET /api/rest/v6/agreements/CBJCHBCAABAAbad failed with HTTP 404 [INVALID_AGREEMENT_ID]: The Agreement ID specified is invalid.
PUT /api/rest/v6/agreements/CBJCHBCAABAAx1aF…/state failed with HTTP 412 [RESOURCE_MODIFIED]: Resource is already modified with newer version.
What happens before the mapper
Two errors get one automatic second chance first, and two kinds of retries happen even earlier, in the middleware:
- A 401 makes the token provider invalidate its token. If it then has a different token (OAuth refresh), the request is repeated once.
- A 403
INVALID_API_ACCESS_POINTmakes the SDK rediscover the data centre and repeat the request once. - 429 and 503 are retried by
RetryMiddlewareup tomaxRetriestimes before the mapper ever sees them.
Only when those are exhausted do you get an exception. Details are on Data centres, retries & ETags.
Properties on API exceptions
ApiException and its subclasses carry everything you need to log, report or decide what to
do next:
| Property or method | Type | Contents |
|---|---|---|
$e->statusCode | int | The HTTP status, e.g. 404. Also returned by getCode(). |
$e->errorCode | ?string | Adobe's error code, e.g. INVALID_AGREEMENT_ID; null when the body had none |
$e->getMessage() | string | METHOD /path failed with HTTP status [CODE]: Adobe's message |
$e->request | ?RequestInterface | The request that failed, with Authorization replaced by Bearer *** |
$e->response | ?ResponseInterface | The full error response, headers and body |
$e->retryAfter | ?int | RateLimitException only: seconds Adobe asked you to wait, when it said |
$e->getPrevious() | ?\Throwable | For an invalid-JSON reply, the \JsonException |
TransportException is simpler: a message and, through getPrevious(), the PSR-18
client's own exception. InvalidArgumentException and WebhookException carry
only a message.
use Conceptlz\AdobeSign\Exception\ApiException;
try {
$sign->agreements()->create($info);
} catch (ApiException $e) {
$logger->error('Adobe Sign rejected the agreement', [
'status' => $e->statusCode,
'code' => $e->errorCode,
'message' => $e->getMessage(),
'body' => (string) $e->response?->getBody(), // Adobe's JSON, for support tickets
]);
throw $e;
}
Credentials never end up in exceptions
Exceptions get logged, dumped in error pages and sent to error trackers, so the SDK makes sure they can't carry a secret:
- The bearer token is replaced.
ApiException's constructor swaps theAuthorizationheader on the stored request forBearer ***, whoever builds the exception.ApiException::redact()does the same for any request you hold. - OAuth request bodies are dropped. Token, refresh and revoke requests carry the client
secret, the code or the refresh token in their body, so an
OAuthExceptionkeeps the request without it. - Keys stay out of dumps.
IntegrationKeyTokenProvider,OAuthConfigandAccessTokenmask their secrets invar_dump(), and secret parameters are marked#[\SensitiveParameter], so they don't appear in stack traces. - The logger never receives headers or bodies (PSR-3 logging).
The test suite enforces this (CredentialSafetyTest, plus a check in the live suite that a
real error's request reads Bearer ***). What remains in an exception is ordinary business
data: resource ids in the path, and Adobe's error body in $response.
Adobe error codes and the exception you get
The left-hand column is the value of $e->errorCode. "By code" means the SDK maps that code
explicitly; "by status" means it falls back to the HTTP status Adobe normally sends with it.
| Error code | Usual status | Exception | Mapped | Meaning and fix |
|---|---|---|---|---|
INVALID_ACCESS_TOKEN | 401 | AuthenticationException | by code | Key or token wrong, revoked or expired. Check the key; OAuth refreshes once on its own first. |
NO_AUTHORIZATION_HEADER, NO_ACCESS_TOKEN_HEADER | 401 | AuthenticationException | by code | No token was sent; usually a custom TokenProvider returning an empty string. |
INVALID_USER, UNVERIFIED_USER, INVALID_ACCOUNT | 401 | AuthenticationException | by code | The user behind the token is inactive, unverified, or the account is not usable. |
INVALID_ON_BEHALF_OF_USER, INVALID_X_API_USER_HEADER | 401 / 400 | AuthenticationException | by code | asUser() named someone who isn't in the account, or the token isn't an admin's. |
INVALID_TOKEN, EXPIRED_TOKEN | 401 | AuthenticationException (OAuthException from the OAuth endpoints) | by code | An OAuth token or refresh token is no longer valid; the user must authorize again. |
THROTTLING_TOO_MANY_REQUESTS | 429 | RateLimitException | by code | Too many calls; already retried. Wait $retryAfter seconds. |
RESOURCE_MODIFIED | 412 | PreconditionFailedException | by code | Stale ETag. get() again and repeat the write. |
INVALID_JSON, MISSING_REQUIRED_PARAM, INVALID_ARGUMENTS | 400 | ValidationException | by code | The payload is incomplete or malformed. The message names the field. |
INVALID_EMAIL, INVALID_FILE_INFO | 400 | ValidationException | by code | A participant address or file reference was rejected (an expired transient document, for example). |
MODIFYING_IMMUTABLE_FIELDS | 400 | ValidationException | by code | An update tried to change a field Adobe doesn't allow to change in this state. |
INVALID_PAGE_SIZE, INVALID_CURSOR | 400 | ValidationException | by code | A list call had a bad pageSize, or a cursor that expired. |
HYBRID_ROUTING_NOT_ALLOWED | 403 | AccountCapabilityException | by code | A mixed sequential and parallel signing order on an account where hybrid routing is off. An admin can turn it on. |
GOV_ID_LIMIT_EXCEEDED | 403 | AccountCapabilityException | by code | No government-ID verification quota left (or none bought). |
KBA_LIMIT_EXCEEDED, PHONE_LIMIT_EXCEEDED, PHONE_AUTH_LIMIT_EXCEEDED, AGREEMENT_PHONE_AUTH_LIMIT_EXCEEDED | 403 | AccountCapabilityException | by code | The KBA or phone (SMS) quota is used up. Top it up, or choose another identity check. |
SEND_LIMIT_EXCEEDED | 403 | AccountCapabilityException | by code | The user's agreement-creation quota is used up. |
ACCOUNT_LIMIT_EXCEEDED, GROUP_LIMIT_EXCEEDED | 400 / 403 | AccountCapabilityException | by code | No free user seat on the account, or the user is already in as many groups as allowed. |
DIGITAL_SIGNATURE_ENFORCEMENT_PER_PARTICIPANT_NOT_ALLOWED, …_DISABLED | 403 | AccountCapabilityException | by code | AES or QES was requested, but the sender's settings don't let senders require a digital signature per signer. An admin can allow it. |
SIGNER_AUTH_NOT_ALLOWED, INTERNAL_SIGNER_AUTH_NOT_ALLOWED, AUTH_METHOD_PROVIDED_IS_NOT_ALLOWED, EXTERNAL_… / INTERNAL_AUTH_METHOD_PROVIDED_IS_NOT_ALLOWED | 403 | AccountCapabilityException | by code | The sender's settings don't allow this identity check (for recipients outside or inside the account). Listed for MegaSign. |
WEBHOOK_CREATION_NOT_ALLOWED, WEBHOOK_PROBLEM_NOTIFICATION_EMAILS_NOT_ENABLED | 403 / 400 | AccountCapabilityException | by code | Webhooks, or problem-notification emails for them, aren't enabled for the account. |
PRIVATE_MESSAGES_NOT_ENABLED, PAYMENT_INTEGRATION_DISABLED, AGREEMENT_SHARING_DISABLED, WIDGET_SHARING_DISABLED, COPY_AGREEMENT_FEATURE_DISABLED, DOCUMENT_ORIGINAL_FORMAT_NOT_ALLOWED, MESSAGE_TEMPLATES_NOT_ENABLED, PHONE_TRANSACTION_REPORTING_DISABLED, DYNAMIC_DOCUMENT_EXPIRATION_NOT_ENABLED | 403 (412) | AccountCapabilityException | by code | The feature the request uses is switched off for the account or user. |
BAD_REQUEST with "not allowed by account settings" | 400 | AccountCapabilityException | by message | An identity method or feature the account doesn't enable. |
WEBHOOK_LIMIT_EXCEEDED | 400 | ValidationException | by status | The resource already has as many webhooks for that event as Adobe allows. Delete or reuse one; it isn't a plan limit. |
INVALID_API_ACCESS_POINT | 403 | ApiException | by status | Wrong data centre. Rediscovered and retried once automatically; you see it only if that fails too. |
PERMISSION_DENIED | 403 | ApiException | by status | The user may not see or change this resource. Check whose token you use, or use asUser(). |
INVALID_AGREEMENT_ID, AGREEMENT_DELETED | 404 | NotFoundException | by status | No such agreement for this user. |
AGREEMENT_NOT_EXPOSED, AGREEMENT_NOT_SIGNABLE | 404 | NotFoundException | by status | Adobe is still processing the document, or nobody can sign right now. Retry after a few seconds. |
UNSUPPORTED_MEDIA_TYPE | 415 | ValidationException | by status | The file type or content type isn't accepted. |
MISC_SERVER_ERROR | 500 | ApiException | by status | A problem on Adobe's side. Retry later. |
NO_TOKEN, NO_REFRESH_TOKEN | 401 | OAuthException | SDK | Set by the SDK: nothing stored for this user, or a token that can't be refreshed. Authorize again. |
STATE_MISMATCH, MISSING_CODE | 400 | OAuthException | SDK | Set by the SDK in handleCallback(): the callback wasn't started by this session, or has no code. |
A code that isn't in this table still reaches you in $e->errorCode; only the class is
chosen by status. Adobe adds codes over time, so compare errorCode strings rather than
relying on a closed list.
Recommended catch patterns
At the edge: catch the interface
In a controller or command, one catch (AdobeSignException $e) is enough to turn any SDK
failure into a friendly message. It covers local validation, network failures and API errors alike.
use Conceptlz\AdobeSign\Exception\AdobeSignException;
try {
$agreementId = $contracts->send($contract);
} catch (AdobeSignException $e) {
$logger->error($e->getMessage(), ['exception' => $e]);
$flash->error('We could not send the contract for signature. Please try again shortly.');
}
Specific types first
PHP uses the first matching catch, so order matters: subclasses before parents.
use Conceptlz\AdobeSign\Exception\{
AccountCapabilityException, AdobeSignException, AuthenticationException, NotFoundException,
OAuthException, PreconditionFailedException, RateLimitException, TransportException,
};
try {
$sign->agreements()->cancel($agreementId, 'Replaced by a new version');
} catch (OAuthException $e) {
$user->markAdobeSignDisconnected(); // must come before AuthenticationException
} catch (AuthenticationException $e) {
$alerts->page('Adobe Sign credentials rejected: ' . $e->errorCode);
} catch (NotFoundException $e) {
// already deleted, or never existed for this user: nothing to cancel
} catch (PreconditionFailedException $e) {
$sign->agreements()->get($agreementId); // fresh ETag
$sign->agreements()->cancel($agreementId, 'Replaced by a new version');
} catch (RateLimitException | TransportException $e) {
throw $e; // let the queue retry the job
} catch (AccountCapabilityException $e) {
$admins->notify("Acrobat Sign account setting needed: {$e->errorCode}");
} catch (AdobeSignException $e) {
$logger->error($e->getMessage(), ['exception' => $e]);
}
In queue jobs: retryable or not
A background job should retry what can succeed later and fail fast on what can't. This helper encodes that split:
use Conceptlz\AdobeSign\Exception\{ApiException, PreconditionFailedException, RateLimitException, TransportException};
function isWorthRetrying(\Throwable $e): bool
{
return $e instanceof RateLimitException
|| $e instanceof TransportException
|| $e instanceof PreconditionFailedException // only if the job calls get() before writing
|| ($e instanceof ApiException && $e->statusCode >= 500)
|| ($e instanceof ApiException && in_array($e->errorCode, ['AGREEMENT_NOT_EXPOSED', 'AGREEMENT_NOT_SIGNABLE'], true));
}
Everything else (authentication, validation, account capability, a 404 for a deleted agreement) needs a person to change something, and retrying only adds load.
Branching on the Adobe code
The class tells you the kind of failure; errorCode tells you the exact one. Branch on it
when two failures of the same class need different handling:
try {
$url = $sign->agreements()->signingUrlFor($agreementId, $signerEmail);
} catch (NotFoundException $e) {
$url = match ($e->errorCode) {
'AGREEMENT_NOT_EXPOSED', 'AGREEMENT_NOT_SIGNABLE' => null, // still processing: show "try again"
default => throw $e,
};
}
Errors raised before any request
InvalidArgumentException means the SDK refused to send something it knows Adobe would
reject, or that would be unsafe. Nothing reached the network. Common causes:
- An empty resource id, or an empty
asUser()argument. - An
AgreementInfowith no file or no participant set, or signing orders with a gap (1, 3). - AES or QES asked of an approver or other non-signing role, or combined with written signatures.
- An upload from a path that can't be read, or from a stream without a file name.
signingUrls()with anexpiryoutside 30 to 315360000 seconds.- A QES check (
meets()orshortfalls()) without a list of qualified issuers. - An OAuth
api_access_pointthat isn't HTTPS on an allowed domain. - A payload that can't be encoded as JSON (invalid UTF-8, for example).
It extends PHP's own \InvalidArgumentException, so existing handlers for that type catch it
too.
Webhook errors
WebhookHandler::verifyClientId() and parse() throw
WebhookException for a missing or unknown X-AdobeSign-ClientId header, or a
body that isn't a JSON notification. handle() never throws for a bad request: it returns a
WebhookResult with the exception in $error and a response with the right status
(403 for an unknown client, 400 for an unreadable body, 405 for another method). See
Webhooks.
Common questions
Is catching AdobeSignException really enough to catch everything?
For everything the SDK throws, yes. The one exception is setup: if no PSR-18 client is installed and
you didn't pass one, php-http/discovery throws its own NotFoundException
while the client is being built. That happens once at boot, not during API calls.
An older README says HYBRID_ROUTING_NOT_ALLOWED is an ApiException. Which is right?
Since 0.3.0 it is an AccountCapabilityException. That class extends
ApiException, so code that catches ApiException keeps working.
KBA_LIMIT_EXCEEDED used to be a plain ApiException. Did my handler break?
Not if it catches ApiException or checks $e->errorCode: both still work.
Versions up to 0.3.1 mapped only HYBRID_ROUTING_NOT_ALLOWED and
GOV_ID_LIMIT_EXCEEDED by code, so the other quota and feature codes in the table above
came through as a plain ApiException. Three of them came through as a sibling class
instead, and a catch for that class no longer sees them:
ACCOUNT_LIMIT_EXCEEDED and WEBHOOK_PROBLEM_NOTIFICATION_EMAILS_NOT_ENABLED
were a ValidationException, and DYNAMIC_DOCUMENT_EXPIRATION_NOT_ENABLED was
a PreconditionFailedException.
Why do I get NotFoundException for an agreement I just created?
Adobe processes documents asynchronously. Until it finishes, some calls answer
AGREEMENT_NOT_EXPOSED or AGREEMENT_NOT_SIGNABLE with a 404. Wait until the
status leaves DOCUMENTS_NOT_YET_PROCESSED, then retry. See
Troubleshooting.
Can I show the exception message to end users?
It contains no credentials, but it is written for developers: method, path, status and Adobe's
wording. Show your own message and keep the exception for your logs. For a
ValidationException on user input, Adobe's message often names the field, which can
help you build a friendlier one.
How do I see Adobe's full error response?
(string) $e->response?->getBody() on any ApiException. It is the JSON
Adobe sent, which is what Adobe support will ask for.