Guides
Signature levels
Some contracts are fine with a typed name; others need a certificate-based or even a qualified signature. This page shows how a Symfony project sets its default level in configuration, overrides it for one agreement or one signer, and proves afterwards what each signer actually reached.
The three levels in brief
The SDK uses the vocabulary of the EU's eIDAS Regulation because it is the most widely referenced tiered model; many other legal systems use comparable tiers or are technology-neutral. In short:
SES
Simple electronic signature
Any electronic sign of agreement: a typed name, a drawn signature, a click. Acrobat Sign's default. Adobe seals the finished PDF with its own certificate and keeps an audit trail. Enough for most commercial contracts.
AES
Advanced electronic signature
Uniquely linked to and identifying the signer, under their sole control, and detecting later changes. In Acrobat Sign: a certificate-based digital signature from a cloud signature provider, with the signer's own certificate embedded in the PDF.
QES
Qualified electronic signature
An AES made with a qualified certificate from a qualified trust service provider on a qualified device. Under eIDAS it has the legal effect of a handwritten signature. The API request is the same as AES; the provider makes it qualified.
Which level a document needs is a legal question for you and your counsel, not a technical one, and the answer depends on the jurisdiction and the type of document. The core documentation's signature levels page explains the background in the EU and in other jurisdictions, and which kinds of documents typically need which level.
What the SDK can and can't tell you
The SDK can request a level and report what each signer actually did, including the certificate issuer. Whether that signature satisfies a legal requirement in a given jurisdiction is for your legal or compliance team to decide. For a formal check of a qualified signature, validate the signed PDF against the relevant trusted list.
Set the project's default level
adobe_sign:
signature_level: aes # ses (default), aes or qes; any case
qualified_issuers: [] # needed for QES checks, see below
The manager turns signature_level into the core's ClientOptions::$defaultSignatureLevel on
every client it builds: the default client and every forUser() client. From then on, each agreement
created from an AgreementInfo builder asks Adobe for that level on every signer set that doesn't choose
its own. For AES and QES that means isDigitalSignatureRequired: true on the signer's participant set, so
Adobe makes the signer pick a cloud signature provider and sign with a certificate.
Read the configured value anywhere the manager is injected:
use Conceptlz\AdobeSign\Enum\SignatureLevel;
$adobeSign->signatureLevel(); // SignatureLevel::AES
$adobeSign->signatureLevel()->requiresDigitalSignature(); // true
$adobeSign->signatureLevel()->satisfies(SignatureLevel::SES); // true: AES is at least as strong
$adobeSign->qualifiedIssuers(); // the configured list
A value other than ses, aes or qes makes client creation throw
MisconfiguredException ("adobe_sign.signature_level must be ses, aes or qes; got …"), so a typo fails
loudly instead of quietly sending SES. To vary it per environment, use an environment variable with a fallback:
'%env(default:adobe_sign_level:ADOBE_SIGN_SIGNATURE_LEVEL)%'.
What the Acrobat Sign account needs
- SES works on every account.
- AES needs digital signatures enabled in Acrobat Sign admin, with at least one cloud signature provider available to signers. Without it, Adobe refuses the agreement and the SDK throws
AccountCapabilityExceptionorValidationException. - QES additionally needs a qualified trust service provider as that provider, with qualified certificates for your signers. Without one, signers can only produce an AES, and the check below will say so.
Override per agreement
The configured level is only a default. A builder can ask for a different level for one agreement, in either direction:
use Conceptlz\AdobeSign\Builder\AgreementInfo;
use Conceptlz\AdobeSign\Enum\SignatureLevel;
$info = AgreementInfo::create('Share purchase agreement')
->file($documentId)
->signers('buyer@example.com', 'seller@example.com')
->signatureLevel(SignatureLevel::QES); // this agreement only
$adobeSign->client()->agreements()->create($info);
A typical Symfony pattern is to decide the level in one place, from your own domain, rather than scattering it over controllers:
final class SignatureLevelPolicy
{
public function __construct(private readonly AdobeSignManager $adobeSign) {}
public function for(Contract $contract): SignatureLevel
{
return match ($contract->getType()) {
ContractType::Employment, ContractType::Nda => SignatureLevel::SES,
ContractType::Loan => SignatureLevel::AES,
default => $this->adobeSign->signatureLevel(), // the project default
};
}
}
Override per signer
Levels can differ between signers of the same agreement. A set's own level beats the agreement's, again in either direction:
use Conceptlz\AdobeSign\Builder\{AgreementInfo, ParticipantSetInfo};
AgreementInfo::create('Supplier framework agreement')
->file($documentId)
->signatureLevel(SignatureLevel::SES) // default for this agreement
->participantSet(ParticipantSetInfo::signer('supplier@example.com')->order(1)) // SES
->participantSet(ParticipantSetInfo::signer('cfo@example.com')
->signatureLevel(SignatureLevel::AES)->order(2)); // AES
Only signing roles can be held to AES or QES. Asking it of an approver throws
InvalidArgumentException locally, before any request.
Identity checks are a separate dial
Email OTP, SMS, knowledge-based questions, government ID and digital identity checks control who may open
the agreement. They don't change the signature level: an SES with a government ID check is still an SES. Adobe
allows one identity method per participant, set with ParticipantInfo::verifyWith(). The core's
identity verification page covers
the options.
Check what each signer reached
Requesting a level is not proof of the result: only the evidence shows whether a certificate was used, and only its issuer tells an AES from a QES. After signing, ask the manager:
signatureShortfalls(string $agreementId, ?SignatureLevel $required = null): list<string>
Loads the agreement's signature evidence with the default client (agreements()->signatureEvidence(): the agreement, its members and its events) and checks every signer against $required, or the configured signature_level when null, using qualified_issuers. Returns one plain-English line per signer who fell short; an empty list means everyone made it.
$shortfalls = $this->adobeSign->signatureShortfalls($agreementId);
if ($shortfalls !== []) {
$contract->flagForReview($shortfalls);
// e.g. ["jane@example.com signed with an electronic signature (TYPED), not a digital one: SES, AES required."]
return;
}
$contract->markSigned($path);
The messages you can get:
| Message | Meaning |
|---|---|
… has not signed yet. | The signer hasn't signed; call this only once the agreement is complete. |
… signed with an electronic signature (TYPED), not a digital one: SES, AES required. | No certificate was used, so the signature is SES. |
… signed digitally, but the certificate issuer "…" is not in your list of qualified issuers: AES, QES required. | A certificate was used, but its issuer isn't one you accept as qualified. |
The agreement has no signers. | Only approvers, acceptors or similar. |
The check uses the default client. For an agreement sent through a user's OAuth connection, call the core
directly with that user's client:
$adobeSign->forUser($key)->agreements()->signatureEvidence($id)->shortfalls($adobeSign->signatureLevel(), $adobeSign->qualifiedIssuers()).
Telling a QES from an AES: qualified_issuers
The API request for QES is the same as for AES; the difference is which provider issued the signer's certificate. The SDK can't know which issuers are qualified for your purposes, so you tell it:
adobe_sign:
signature_level: qes
qualified_issuers:
- 'Example Trust Qualified CA' # matched case-insensitively, as a substring of the issuer
- Matching. Each entry is compared case-insensitively as a substring of the certificate issuer Adobe reports, so
'Example Trust Qualified'matches'Example Trust Qualified CA 2'. - Required for QES checks. Checking against QES with an empty list throws the core's
InvalidArgumentException("Checking for QES needs the certificate issuers you accept as qualified"): without a list, no signature could ever count as QES. - Get the exact names from a signed test agreement:
signatureEvidence($id)->signers[0]->certificateIssuer. Test providers often have "Test" in the name and are not qualified.
The full evidence
$evidence = $adobeSign->client()->agreements()->signatureEvidence($agreementId);
$evidence->allSigned(); // every signer signed
$evidence->meets(SignatureLevel::AES); // bool
$evidence->lowestLevel($adobeSign->qualifiedIssuers()); // weakest level reached, or null
foreach ($evidence->signers as $signer) {
$signer->email; // "cfo@example.com"
$signer->signedAt; // ISO 8601
$signer->signatureInputType; // TYPED, SCRIBBLE, or a digital type
$signer->digitallySigned; // a certificate was used
$signer->certificateIssuer; // e.g. "Example Trust Qualified CA"
$signer->signingProvider; // the cloud signature provider
$signer->verification; // NONE, EMAIL_OTP, KBA, ...
$signer->achievedLevel($adobeSign->qualifiedIssuers()); // SignatureLevel or null
}
Store the evidence (or at least the shortfalls and issuers) with the contract. If a signature is ever disputed, you want to show what you checked and when, not reconstruct it.
Testing level-dependent code
Override signature_level in the test environment, and queue canned responses for the agreement,
members and events calls. The bundle's own test does exactly this: with signature_level: qes and a
qualified_issuers entry, it checks that a typed signature and an unsigned signer are reported, and a
signer whose certificate came from the listed issuer is not. Assert on the request too: with AES or QES, the
created agreement's participant set carries isDigitalSignatureRequired: true. See
the request order.
Common questions
Why is SES the default?
It works on every account, needs nothing from signers beyond an email, and is legally sufficient for most commercial contracts in many jurisdictions. AES and QES add friction for signers and need account features; turn them on where your counsel says they are needed.
Does the level apply when I create agreements from arrays?
No. The default level is applied when an AgreementInfo builder is turned into a request. If you pass a raw array or a generated model to agreements()->create(), set isDigitalSignatureRequired yourself.
An agreement mixes SES and AES signers. How do I check it?
signatureShortfalls() checks every signer against one level. For mixed agreements, loop over signatureEvidence($id)->signers and compare each achievedLevel() with the level you requested for that signer.
Creating an AES agreement throws AccountCapabilityException. What now?
Digital signatures aren't enabled for the account, or no cloud signature provider is available. An Acrobat Sign administrator has to enable them; the code is fine. See Troubleshooting.
We are not in the EU. Do these levels apply to us?
The labels come from eIDAS, but the mechanics (a plain e-signature versus a certificate-based one from a provider you trust) exist everywhere. Map your jurisdiction's requirements onto them with counsel; the core's signature levels page gives an overview beyond the EU.