Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

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.

AdobeSignException interface: catches everything ApiException error response InvalidArgumentException rejected before sending TransportException no response at all WebhookException webhook rejected AuthenticationException OAuthException NotFoundException ValidationException RateLimitException PreconditionFailedException AccountCapabilityException 401, or an auth code such as INVALID_ACCESS_TOKEN the OAuth flow failed: authorize again 404: unknown id, or not signable yet 400 or 415, or a validation code 429 once retries give up; carries $retryAfter 412: stale ETag (RESOURCE_MODIFIED) the account doesn't allow the request
All classes live in Conceptlz\AdobeSign\Exception. Catch a subclass before its parent: OAuthException before AuthenticationException, any of the six before ApiException.

Exception reference

ExceptionExtendsThrown whenWhat to do
ApiException\RuntimeExceptionAny 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
AuthenticationExceptionApiException401, or an auth code on any status: bad or revoked Integration Key, expired token that couldn't be refreshed, invalid asUser() targetFix the credential; don't retry blindly
OAuthExceptionAuthenticationExceptionThe user denied access, the state didn't match, Adobe rejected a code or refresh token, or no token is stored for the userSend the user through authorization again
NotFoundExceptionApiException404: unknown or deleted id, or an agreement that isn't signable or visible yetCheck the id; right after create, wait and retry
ValidationExceptionApiException400 or 415, or a request-validation code: missing parameter, invalid email, bad JSONFix the request; retrying won't help
RateLimitExceptionApiException429 after the retry middleware gave up, or when Adobe asked for a longer wait than maxRetryDelayTry again after $retryAfter seconds
PreconditionFailedExceptionApiException412: the If-Match ETag is staleget() again and repeat the write
AccountCapabilityExceptionApiExceptionThe 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\InvalidArgumentExceptionRejected locally before any request: empty id, invalid email, signing-order gap, AES for an approver, unreadable file, untrusted api_access_pointFix the calling code
TransportException\RuntimeExceptionNo HTTP response at all: DNS, TLS, timeout, connection refusedRetry later; a POST may have been processed
WebhookException\RuntimeExceptionAn inbound webhook had a missing or unknown X-AdobeSign-ClientId, or a body that isn't a notificationAnswer 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.

  1. 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.

  2. 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_HEADER arrives as a 400 but is an AuthenticationException, because it is an authentication problem. Likewise DYNAMIC_DOCUMENT_EXPIRATION_NOT_ENABLED arrives as a 412 but is an AccountCapabilityException: no fresh ETag would make it succeed.

  3. Fall back to the HTTP status

    An unknown or missing code is mapped by status: 400 and 415 to ValidationException, 401 to AuthenticationException, 404 to NotFoundException, 412 to PreconditionFailedException, 429 to RateLimitException. Every other status gives a plain ApiException.

  4. Check for an account restriction

    If the result so far is ValidationException and the message says something like "not allowed by account settings", "enabled for your account" or "not enabled for this account", the class becomes AccountCapabilityException. Adobe reports some account limits only as a generic BAD_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_POINT makes the SDK rediscover the data centre and repeat the request once.
  • 429 and 503 are retried by RetryMiddleware up to maxRetries times 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 methodTypeContents
$e->statusCodeintThe HTTP status, e.g. 404. Also returned by getCode().
$e->errorCode?stringAdobe's error code, e.g. INVALID_AGREEMENT_ID; null when the body had none
$e->getMessage()stringMETHOD /path failed with HTTP status [CODE]: Adobe's message
$e->request?RequestInterfaceThe request that failed, with Authorization replaced by Bearer ***
$e->response?ResponseInterfaceThe full error response, headers and body
$e->retryAfter?intRateLimitException only: seconds Adobe asked you to wait, when it said
$e->getPrevious()?\ThrowableFor 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 the Authorization header on the stored request for Bearer ***, 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 OAuthException keeps the request without it.
  • Keys stay out of dumps. IntegrationKeyTokenProvider, OAuthConfig and AccessToken mask their secrets in var_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 codeUsual statusExceptionMappedMeaning and fix
INVALID_ACCESS_TOKEN401AuthenticationExceptionby codeKey or token wrong, revoked or expired. Check the key; OAuth refreshes once on its own first.
NO_AUTHORIZATION_HEADER, NO_ACCESS_TOKEN_HEADER401AuthenticationExceptionby codeNo token was sent; usually a custom TokenProvider returning an empty string.
INVALID_USER, UNVERIFIED_USER, INVALID_ACCOUNT401AuthenticationExceptionby codeThe user behind the token is inactive, unverified, or the account is not usable.
INVALID_ON_BEHALF_OF_USER, INVALID_X_API_USER_HEADER401 / 400AuthenticationExceptionby codeasUser() named someone who isn't in the account, or the token isn't an admin's.
INVALID_TOKEN, EXPIRED_TOKEN401AuthenticationException (OAuthException from the OAuth endpoints)by codeAn OAuth token or refresh token is no longer valid; the user must authorize again.
THROTTLING_TOO_MANY_REQUESTS429RateLimitExceptionby codeToo many calls; already retried. Wait $retryAfter seconds.
RESOURCE_MODIFIED412PreconditionFailedExceptionby codeStale ETag. get() again and repeat the write.
INVALID_JSON, MISSING_REQUIRED_PARAM, INVALID_ARGUMENTS400ValidationExceptionby codeThe payload is incomplete or malformed. The message names the field.
INVALID_EMAIL, INVALID_FILE_INFO400ValidationExceptionby codeA participant address or file reference was rejected (an expired transient document, for example).
MODIFYING_IMMUTABLE_FIELDS400ValidationExceptionby codeAn update tried to change a field Adobe doesn't allow to change in this state.
INVALID_PAGE_SIZE, INVALID_CURSOR400ValidationExceptionby codeA list call had a bad pageSize, or a cursor that expired.
HYBRID_ROUTING_NOT_ALLOWED403AccountCapabilityExceptionby codeA mixed sequential and parallel signing order on an account where hybrid routing is off. An admin can turn it on.
GOV_ID_LIMIT_EXCEEDED403AccountCapabilityExceptionby codeNo government-ID verification quota left (or none bought).
KBA_LIMIT_EXCEEDED, PHONE_LIMIT_EXCEEDED, PHONE_AUTH_LIMIT_EXCEEDED, AGREEMENT_PHONE_AUTH_LIMIT_EXCEEDED403AccountCapabilityExceptionby codeThe KBA or phone (SMS) quota is used up. Top it up, or choose another identity check.
SEND_LIMIT_EXCEEDED403AccountCapabilityExceptionby codeThe user's agreement-creation quota is used up.
ACCOUNT_LIMIT_EXCEEDED, GROUP_LIMIT_EXCEEDED400 / 403AccountCapabilityExceptionby codeNo free user seat on the account, or the user is already in as many groups as allowed.
DIGITAL_SIGNATURE_ENFORCEMENT_PER_PARTICIPANT_NOT_ALLOWED, …_DISABLED403AccountCapabilityExceptionby codeAES 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_ALLOWED403AccountCapabilityExceptionby codeThe 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_ENABLED403 / 400AccountCapabilityExceptionby codeWebhooks, 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_ENABLED403 (412)AccountCapabilityExceptionby codeThe feature the request uses is switched off for the account or user.
BAD_REQUEST with "not allowed by account settings"400AccountCapabilityExceptionby messageAn identity method or feature the account doesn't enable.
WEBHOOK_LIMIT_EXCEEDED400ValidationExceptionby statusThe 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_POINT403ApiExceptionby statusWrong data centre. Rediscovered and retried once automatically; you see it only if that fails too.
PERMISSION_DENIED403ApiExceptionby statusThe user may not see or change this resource. Check whose token you use, or use asUser().
INVALID_AGREEMENT_ID, AGREEMENT_DELETED404NotFoundExceptionby statusNo such agreement for this user.
AGREEMENT_NOT_EXPOSED, AGREEMENT_NOT_SIGNABLE404NotFoundExceptionby statusAdobe is still processing the document, or nobody can sign right now. Retry after a few seconds.
UNSUPPORTED_MEDIA_TYPE415ValidationExceptionby statusThe file type or content type isn't accepted.
MISC_SERVER_ERROR500ApiExceptionby statusA problem on Adobe's side. Retry later.
NO_TOKEN, NO_REFRESH_TOKEN401OAuthExceptionSDKSet by the SDK: nothing stored for this user, or a token that can't be refreshed. Authorize again.
STATE_MISMATCH, MISSING_CODE400OAuthExceptionSDKSet 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 AgreementInfo with 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 an expiry outside 30 to 315360000 seconds.
  • A QES check (meets() or shortfalls()) without a list of qualified issuers.
  • An OAuth api_access_point that 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.