Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Help

Troubleshooting

Find the symptom, read the cause, apply the fix. Most entries come from running the SDK against a real Acrobat Sign account, where the API sometimes behaves differently from its specification. If your problem isn't here, attach a logger, look at $e->errorCode, and check Errors & exceptions.

First, get the facts

Almost every problem is easier with three pieces of information: the exception class, its errorCode, and the request path in its message. A PSR-3 logger on the client adds the status and timing of every call. None of these contain your credentials, so they are safe to paste into an issue.

Find your problem

35 problems in 10 groups. Type an error code, an exception name or a word from the message to narrow them down, or pick one from the index.

Installation and setup

No PSR-18 client found

NotFoundException

What you see

composer require conceptlz/adobe-sign fails because nothing provides psr/http-client-implementation, or building the client throws Http\Discovery\Exception\NotFoundException: "No PSR-18 clients found".

Why it happens

The SDK depends on the PSR-18 and PSR-17 interfaces, not on a specific HTTP library, so your project has to provide one. Discovery can only find a client that is installed.

How to fix it

Install a client and PSR-17 factories, then require the SDK again:

composer require symfony/http-client nyholm/psr7
# or
composer require guzzlehttp/guzzle

Or skip discovery and pass httpClient, requestFactory and streamFactory yourself (Choosing a PSR-18 client). This exception comes from php-http/discovery, not the SDK, so catch (AdobeSignException) doesn't cover it; it happens once at boot.

Composer refuses the package

What you see

Composer reports that conceptlz/adobe-sign requires php ^8.2.

Why it happens

The SDK targets PHP 8.2 and later (it relies on features such as #[\SensitiveParameter]) and is tested on PHP 8.2 to 8.5.

How to fix it

Upgrade PHP, and check that config.platform.php in your composer.json, if set, says 8.2 or later.

Authentication

401 INVALID_ACCESS_TOKEN

HTTP 401AuthenticationException

What you see

AuthenticationException with errorCode INVALID_ACCESS_TOKEN. With an Integration Key it often happens on the very first call, GET /api/rest/v6/baseUris.

Why it happens

Adobe doesn't accept the token: the Integration Key is mistyped, was revoked or belongs to a deactivated user; or an OAuth access token expired and the refresh didn't produce a working one.

How to fix it

  • Integration Key: copy it again from Acrobat Sign (Account → Acrobat Sign API → API Information → Integration Key), with no surrounding spaces or quotes. Check that the user it belongs to is still active.
  • OAuth: the SDK already refreshed once and retried. If it still fails, the user's grant is gone; send them through authorization again.
  • Don't retry in a loop: a rejected credential stays rejected.

OAuthException NO_TOKEN or NO_REFRESH_TOKEN

OAuthException

What you see

OAuthException with errorCode NO_TOKEN ("No OAuth token stored for …") or NO_REFRESH_TOKEN.

Why it happens

These codes are set by the SDK, not Adobe. NO_TOKEN: your TokenStore has nothing under the key you passed to withOAuth(). NO_REFRESH_TOKEN: the stored token expired and has no refresh token to renew it with.

How to fix it

Check that you put() and get() with the same key, and that your store round-trips tokens with AccessToken::toArray() and fromArray() without losing refreshToken. Otherwise, the user has to connect again.

OAuth users are disconnected after a while

OAuthException

What you see

Users who connected weeks ago start failing with an OAuthException such as INVALID_TOKEN or EXPIRED_TOKEN during refresh.

Why it happens

Adobe expires refresh tokens after 60 days without use, and a user or admin can revoke access at any time.

How to fix it

Catch OAuthException, mark the connection as broken, and ask the user to reconnect. To keep dormant connections alive, use them (any call refreshes as needed) within the 60 days.

STATE_MISMATCH on the OAuth callback

OAuthException

What you see

handleCallback() throws OAuthException with STATE_MISMATCH.

Why it happens

The state in the callback doesn't match the one you stored when sending the user to Adobe. Usually the session was lost between the two requests (a different domain or port, a cookie with SameSite=Strict, a new session), the user used the back button, or the callback really is forged.

How to fix it

Keep the state in the user's session from OAuthClient::generateState(), make sure the redirect URI is on the same host as the page that started the flow, and use a session cookie that survives a top-level redirect (SameSite=Lax). Don't skip the check.

InvalidArgumentException about api_access_point

InvalidArgumentException

What you see

An InvalidArgumentException mentioning the access point during the OAuth callback or a refresh.

Why it happens

The api_access_point isn't HTTPS on one of OAuthConfig::$allowedDomains (adobesign.com, echosign.com, adobesigngov.us). The SDK refuses to send your client secret to any other host.

How to fix it

If someone tampered with the callback URL, this is the SDK doing its job. If your account genuinely uses another Adobe domain, add it to allowedDomains in OAuthConfig.

asUser() is rejected

AuthenticationException

What you see

AuthenticationException with INVALID_X_API_USER_HEADER or INVALID_ON_BEHALF_OF_USER, even though the same client works without asUser().

Why it happens

The person isn't a user in the token's account, the address is mistyped, or the token isn't an account admin's.

How to fix it

Check the address with $sign->users()->all(), and use an Integration Key or OAuth token that belongs to an account administrator.

Data centres

403 INVALID_API_ACCESS_POINT

HTTP 403ApiException

What you see

ApiException with status 403 and errorCode INVALID_API_ACCESS_POINT.

Why it happens

The request went to a data centre that doesn't hold the account. The SDK already handled the common case: it dropped the cached data centre, rediscovered it and retried once. Seeing the exception means the retry failed too, typically because the bootstrap host is wrong for this account (a Government environment with the default bootstrapUrl), or because a custom middleware rewrites hosts.

How to fix it

Check ClientOptions::$bootstrapUrl and any middleware that touches the URI. Clearing the shared cache does no harm. See Rediscovery.

GET /baseUris on every request

What you see

Every web request or job makes an extra call to api.adobesign.com before its real work.

Why it happens

No shared cache was passed, so each new client discovers its data centre again, and in PHP-FPM every request builds a new client.

How to fix it

Pass a shared PSR-16 cache (Redis, APCu, files) as cache:. See PSR-16 cache.

Updates and cancellations

412 on update or cancel

HTTP 412PreconditionFailedException

What you see

PreconditionFailedException (RESOURCE_MODIFIED) from update(), updateState() or cancel().

Why it happens

The ETag sent in If-Match is older than the resource. Either someone else changed it since your get(), or, very often, you are within a few seconds of sending the agreement: Adobe keeps updating it in the background while it delivers emails, so an ETag read in that window is already stale.

How to fix it

Fetch again and repeat, once or twice with a short pause:

try {
    $sign->agreements()->cancel($agreementId, 'Sent in error');
} catch (PreconditionFailedException) {
    sleep(3);
    $sign->agreements()->get($agreementId);                      // remembers the current ETag
    $sign->agreements()->cancel($agreementId, 'Sent in error');
}

If you are on 0.2.0 and see this on a cancel() right after another state change from the same client, upgrade: 0.2.1 fixed a case where the ETag from a state change was reused. How the ETag memory works: ETags and If-Match.

403 PERMISSION_DENIED

HTTP 403ApiException

What you see

ApiException with status 403 and PERMISSION_DENIED.

Why it happens

The user behind the token may not see or change this resource: someone else's agreement, or an operation reserved for the sender or an admin. For OAuth, the token may also lack the scope (for example agreement_write).

How to fix it

Act as the right user with asUser() (admin token required), or request the missing scope and have the user reconnect.

Rate limits and slowness

RateLimitException or frequent 429s

RateLimitException

What you see

RateLimitException, or warnings "Adobe Sign throttled request, retrying" in your log.

Why it happens

Your account made more calls than Adobe allows in the current period. The SDK retried (default three times) and either ran out of retries or was asked to wait longer than maxRetryDelay.

How to fix it

  • Defer the work by $e->retryAfter seconds (a delayed queue job is ideal) rather than retrying immediately.
  • Make fewer calls: use webhooks instead of polling status, raise pageSize on lists, share the data-centre cache, and reuse fetched data within a request.
  • Move bulk operations to background jobs so a burst doesn't hit user-facing requests.

A call takes a long time before failing

What you see

A page or command hangs for tens of seconds, then fails or succeeds.

Why it happens

Either the retry middleware is sleeping between attempts (up to maxRetryDelay, 60 s by default, per retry), or the HTTP client has no timeout and is waiting on the network.

How to fix it

In web requests, use new ClientOptions(maxRetries: 1, maxRetryDelay: 5.0) and move slow work to jobs. Set a timeout on your PSR-18 client; the SDK sets none. A logger shows which it is: retry warnings, or one request with a long duration_ms.

Right after creating an agreement

Status is DOCUMENTS_NOT_YET_PROCESSED

What you see

status() returns DOCUMENTS_NOT_YET_PROCESSED instead of OUT_FOR_SIGNATURE.

Why it happens

Adobe converts and prepares documents asynchronously after create(). That usually takes a few seconds, longer for big files.

How to fix it

Nothing is wrong. Poll every couple of seconds until the status changes, or better, subscribe to a webhook and react when the agreement is out for signature. The example scripts poll every 2 seconds, 10 to 15 times.

AGREEMENT_NOT_EXPOSED or AGREEMENT_NOT_SIGNABLE

NotFoundException

What you see

signingUrls() or signingUrlFor() throws NotFoundException with one of these codes for an agreement you just created.

Why it happens

The agreement isn't ready to be signed yet, because the document is still processing, or nobody can sign at this moment.

How to fix it

Wait until the status is OUT_FOR_SIGNATURE, then retry after a few seconds. In a web flow, show "preparing your document" and retry from the browser rather than blocking the request.

nextParticipantSets is missing or empty

What you see

members($id) has no nextParticipantSets, so you can't tell whose turn it is.

Why it happens

Two things. The live API leaves the key out unless you ask for it, even though the spec lists it. And for a moment after the status turns OUT_FOR_SIGNATURE, participant data can still lag (sets reading NOT_YET_VISIBLE).

How to fix it

Call members($id, includeNextParticipantSet: true), and if the list is empty right after sending, poll again a few seconds later. Each participant set's own status (WAITING_FOR_MY_SIGNATURE, …) is another way to find the current step.

sendReminder() says nobody is left to remind

InvalidArgumentException

What you see

InvalidArgumentException from sendReminder($id) without participant ids.

Why it happens

Without ids, the SDK reminds whoever's turn it is, and found nobody: the agreement is completed or cancelled, or still processing. Before 0.2.1 this also happened on active agreements, because nextParticipantSets wasn't requested.

How to fix it

Check the status first, and upgrade if you are on 0.2.0. To remind specific people, pass their participant ids.

The transient document id is rejected

ValidationException

What you see

create() fails with a ValidationException such as INVALID_FILE_INFO for an id you uploaded earlier.

Why it happens

Transient documents expire 7 days after upload, and an id belongs to the account (and user) that uploaded it.

How to fix it

Upload again right before creating the agreement. For documents you reuse, create a library document and reference it with FileInfo::libraryDocument().

Account settings and plan

403 HYBRID_ROUTING_NOT_ALLOWED

HTTP 403AccountCapabilityException

What you see

AccountCapabilityException with HYBRID_ROUTING_NOT_ALLOWED when creating an agreement.

Why it happens

The signing order mixes sequential and parallel steps, for example 1, then 2 and 2 together, then 3. Adobe calls this hybrid routing and it is an account setting, off on some accounts.

How to fix it

An Acrobat Sign administrator can enable it in the account's send settings. Until then, use a purely sequential (1, 2, 3) or purely parallel (1, 1, 1) order. Versions before 0.3.0 reported this as a plain ApiException. See Signing order.

"Not allowed by account settings"

AccountCapabilityException

What you see

AccountCapabilityException whose message contains "not allowed by account settings", "enabled for your account" or "not enabled for this account", often with Adobe's generic BAD_REQUEST code.

Why it happens

The request is valid, but the account doesn't enable the feature: typically an identity method (phone, KBA, government ID, a digital identity provider) or digital signatures. Adobe reports some of these only as a 400, and the SDK recognises them by their wording.

How to fix it

Ask an Acrobat Sign administrator to enable the method or feature, or check that your plan includes it. It can't be fixed in code; choosing another method can work around it.

GOV_ID_LIMIT_EXCEEDED

AccountCapabilityException

What you see

AccountCapabilityException with GOV_ID_LIMIT_EXCEEDED when a signer must verify with a government ID.

Why it happens

Government-ID verification uses a paid quota, and the account has none left or never had any. Developer accounts usually have none.

How to fix it

Buy or top up the quota through Adobe, or use another identity check, such as Verification::emailOtp(), for this agreement.

KBA_LIMIT_EXCEEDED, PHONE_LIMIT_EXCEEDED, SEND_LIMIT_EXCEEDED

AccountCapabilityExceptionApiException

What you see

AccountCapabilityException with KBA_LIMIT_EXCEEDED, PHONE_LIMIT_EXCEEDED, PHONE_AUTH_LIMIT_EXCEEDED, AGREEMENT_PHONE_AUTH_LIMIT_EXCEEDED or SEND_LIMIT_EXCEEDED, usually when creating or sending an agreement. Versions up to 0.3.1 reported these as a plain ApiException.

Why it happens

KBA, phone (SMS) checks and agreement sending are metered, and the account or user has used up its quota.

How to fix it

Buy or top up the quota through Adobe. For the identity checks, another method such as Verification::emailOtp() works around it for this agreement.

DIGITAL_SIGNATURE_ENFORCEMENT_PER_PARTICIPANT_NOT_ALLOWED

AccountCapabilityExceptionApiException

What you see

Creating an AES or QES agreement fails with AccountCapabilityException and DIGITAL_SIGNATURE_ENFORCEMENT_PER_PARTICIPANT_NOT_ALLOWED or …_DISABLED. Versions up to 0.3.1 reported these as a plain ApiException.

Why it happens

AES and QES send isDigitalSignatureRequired for the signer, and the sender's account or group settings don't let senders require a digital signature per signer.

How to fix it

Ask an Acrobat Sign administrator to allow digital signatures and per-recipient enforcement, with a cloud signature provider configured, or send at SES. See Signature levels.

[DEMO USE ONLY] in agreement names

What you see

Agreement names come back as [DEMO USE ONLY] Master Services Agreement.

Why it happens

The agreement was created on a free developer account, and Adobe prefixes every agreement name there.

How to fix it

Expected behaviour. Production accounts don't do this. If you compare names in tests, strip the prefix or compare externalId instead.

Signature levels and evidence

signingProvider is empty on digital signatures

What you see

signatureEvidence() reports digitallySigned: true and a certificateIssuer, but SignerEvidence::$signingProvider is null.

Why it happens

In 0.3.0 the SDK read the spec's digitalSignatureInfo.cloudProviderName; the live API sends providerName.

How to fix it

Upgrade to 0.3.1 or later, which reads both.

Evidence says SES although you asked for AES

What you see

shortfalls(SignatureLevel::AES) says a signer "signed with an electronic signature … not a digital one".

Why it happens

Check $signer->digitalSignatureRequired. If it is false, the requirement never reached Adobe: the agreement was sent as a raw array or generated model (the default level applies only to AgreementInfo), or the level was set on a different participant set. If it is true, compare with the agreement's events: the signature may have been completed in a way Adobe records differently.

How to fix it

Build agreements with AgreementInfo and set the level there or in ClientOptions; for raw payloads, set isDigitalSignatureRequired: true on each signer set yourself. See Signature levels.

QES check throws InvalidArgumentException

What you see

meets(SignatureLevel::QES) or shortfalls(SignatureLevel::QES) throws.

Why it happens

Whether a certificate is qualified depends on who issued it, which the SDK can't know. Checking for QES without the list of issuers you accept as qualified would be meaningless.

How to fix it

Pass your qualified issuers: $evidence->meets(SignatureLevel::QES, ['DigiCert', 'TrustPro QTSP']). In Laravel, set ADOBE_SIGN_QUALIFIED_ISSUERS.

InvalidArgumentException when setting a level

What you see

The builder throws before anything is sent: a set "does not sign, so it cannot be held to AES", or a digital level combined with written signatures.

Why it happens

AES and QES are properties of a signature. Approvers, acceptors and form fillers don't sign, and a written (wet ink) signature can't carry a digital certificate.

How to fix it

Set the level only on signer sets, and keep signatureType at ESIGN for digital levels.

Webhooks

Creating a webhook fails verification

What you see

webhooks()->create() fails, with Adobe's message saying the URL couldn't be verified or reached.

Why it happens

Before creating the webhook, Adobe sends a GET to your URL with an X-AdobeSign-ClientId header and expects a 2xx answer that echoes the value within 5 seconds. The URL isn't public HTTPS, the endpoint rejected the client id, or it didn't echo.

How to fix it

Make the URL reachable over HTTPS (a tunnel for local development), allowlist the right client id in WebhookHandler, and return the handler's response unchanged. Test the endpoint first with a curl GET carrying the header (example).

Notifications stopped arriving

What you see

Events used to arrive and then stopped; webhooks()->get($id) shows the webhook is no longer active.

Why it happens

Adobe retries a failed delivery with growing intervals for 72 hours, then disables the webhook. Failures include non-2xx answers, a missing echo, and answers slower than 5 seconds.

How to fix it

Fix the endpoint first: acknowledge immediately and do slow work on a queue. Then reactivate with $sign->webhooks()->updateState($id, 'ACTIVE'). Notifications from the disabled period are lost, so reconcile by fetching the affected agreements. One webhook per agreement limits the damage from a failure to that agreement.

Your endpoint answers 403

HTTP 403

What you see

WebhookHandler::handle() returns a 403 and $result->error says the client id is unexpected or missing.

Why it happens

The X-AdobeSign-ClientId header doesn't match the allowlist, often because the webhook was created by a different API application than the one whose id you configured.

How to fix it

Allowlist the client id of every application that creates your webhooks (the handler accepts a list). Requests without the header aren't from Adobe.

The same event arrives twice

What you see

Your handler processes one signature twice.

Why it happens

Adobe retries deliveries it considers failed, including slow ones, so at-least-once delivery is normal.

How to fix it

Store $event->notificationId and skip ones you have seen, and make your processing idempotent (re-fetch the agreement and act on its current status).

Downloads

The saved PDF is empty

What you see

The file you wrote has zero bytes, or getContents() returns an empty string.

Why it happens

A PSR-7 stream has a read position. If something already read it (a debug dump, a size check), the position is at the end.

How to fix it

Read it once, or call $stream->rewind() first when isSeekable() is true. Write with a chunked loop or detach() as shown in Downloads.

Memory runs out on large downloads

What you see

"Allowed memory size exhausted" while saving a combined PDF.

Why it happens

Converting the stream to a string ((string) $stream, getContents()) loads the whole file into memory.

How to fix it

Copy in chunks (read(65536) in a loop) or pass detach() to your storage layer. Also check the PSR-18 client: some buffer whole responses unless configured to stream.

Common questions

What should I include when reporting a bug?

The SDK version (AdobeSign::VERSION), the PHP version, the exception class, its errorCode and message, and Adobe's response body ((string) $e->response?->getBody()). Remove personal data such as signer emails. Never include your Integration Key, tokens or client secret; the SDK keeps them out of exceptions, so you shouldn't have to.

Is there a sandbox environment?

Adobe's free developer account serves that purpose for most teams: it is a real account that sends real emails, with agreement names marked [DEMO USE ONLY], meant for building and testing. Ask your Adobe contact whether your plan includes a separate sandbox environment.

It works on my machine but not on the server. Where do I look?

Usually the environment: a different key or account, outbound HTTPS blocked by a firewall or proxy (you will see TransportException), an old CA bundle breaking TLS, or a missing PSR-18 package because dev dependencies weren't installed. A logger on the client shows quickly whether requests leave the server at all.

The API returned something the spec doesn't describe. Is that a bug?

Sometimes it is just Adobe's API: several such differences are known and handled (missing nextParticipantSets, providerName versus cloudProviderName). The generated models ignore unknown fields, and $raw holds the full response. If the SDK mishandles it, please report it with the response body.