Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Guides

Creating agreements

AgreementInfo builds the request that sends documents for signature. This page goes through every method it has, the participant builders it works with, what each option does in Acrobat Sign, how to reach the API fields the builder doesn't model, and which mistakes it catches before anything leaves your server.

At a glance

Here is a realistic agreement using most options. HR fills in the salary, the new employee signs after confirming a code sent to their email, then the CEO countersigns. Payroll gets a copy.

app/Contracts/SendEmploymentContract.php
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantInfo, ParticipantSetInfo, Verification};
use Conceptlz\AdobeSign\Enum\{ParticipantRole, ReminderFrequency};

$agreement = AgreementInfo::create('Employment contract - Sam Lee')
    ->file(FileInfo::transientDocument($documentId))
    ->participantSet(
        ParticipantSetInfo::role(ParticipantRole::FORM_FILLER, 'hr@company.example')->name('HR fills in salary'),
    )
    ->participantSet(
        ParticipantSetInfo::signer(
            ParticipantInfo::email('sam.lee@example.com', 'Sam Lee')->verifyWith(Verification::emailOtp()),
        )->privateMessage('Welcome aboard, Sam!'),
    )
    ->participantSet(ParticipantSetInfo::signer('ceo@company.example'))
    ->cc('payroll@company.example')
    ->message('Please review and sign your employment contract.')
    ->externalId('hr-offer-2291')
    ->reminderFrequency(ReminderFrequency::EVERY_OTHER_DAY_UNTIL_SIGNED)
    ->expiresAt(new DateTimeImmutable('2026-11-17 18:00', new DateTimeZone('Asia/Dubai')))
    ->locale('en_GB');

$agreementId = $sign->agreements()->create($agreement);

That produces this POST /agreements body (Adobe's AgreementCreationInfo). Notice the order numbers 1, 2, 3 the builder filled in, the expiry converted to UTC, and the defaults ESIGN and IN_PROCESS:

{
    "name": "Employment contract - Sam Lee",
    "fileInfos": [{ "transientDocumentId": "3AAABLblqZhC..." }],
    "participantSetsInfo": [
        { "role": "FORM_FILLER", "order": 1, "memberInfos": [{ "email": "hr@company.example" }], "name": "HR fills in salary" },
        {
            "role": "SIGNER", "order": 2,
            "memberInfos": [{ "email": "sam.lee@example.com", "name": "Sam Lee", "securityOption": { "authenticationMethod": "EMAIL_OTP" } }],
            "privateMessage": "Welcome aboard, Sam!"
        },
        { "role": "SIGNER", "order": 3, "memberInfos": [{ "email": "ceo@company.example" }] }
    ],
    "signatureType": "ESIGN",
    "state": "IN_PROCESS",
    "message": "Please review and sign your employment contract.",
    "externalId": { "id": "hr-offer-2291" },
    "ccs": [{ "email": "payroll@company.example" }],
    "reminderFrequency": "EVERY_OTHER_DAY_UNTIL_SIGNED",
    "expirationTime": "2026-11-17T14:00:00Z",
    "locale": "en_GB"
}

You can always see this yourself: $agreement->toArray() returns the exact array the SDK will send, which is handy in tests and when comparing with Adobe's documentation.

Sending it: agreements()->create()

agreements()->create(AgreementInfo|AgreementCreationInfo|array $info): stringPOST /agreements

Returns the new agreement's id. With a builder, the SDK first calls toArray() with the client's default signature level, so every validation rule runs before the request. A generated AgreementCreationInfo model or a raw array is sent as you wrote it (see raw payloads).

Adobe's own rejections arrive as exceptions: ValidationException for a payload it refuses, AccountCapabilityException when the account doesn't allow something you asked for (a mixed signing order, an identity check that is switched off), AuthenticationException for credential problems.

AgreementInfo, method by method

Name and files

AgreementInfo::create(string $name): AgreementInfo

Starts a builder. The name appears in email subjects, on the signing page and in the sender's list, so make it specific ("NDA - Acme Ltd", not "Contract"). A blank name throws InvalidArgumentException immediately. On a developer account Adobe prefixes it with [DEMO USE ONLY].

->file(FileInfo|string $file): AgreementInfo

Adds a document. A string is taken as a transient document id, so ->file($documentId) and ->file(FileInfo::transientDocument($documentId)) are the same. Call it once per file; documents appear in the order added. Uploading documents covers library and URL documents.

Participants

->participantSet(ParticipantSetInfo $set): AgreementInfo

Adds one step of the workflow. A set without an explicit order() is numbered one after the previous set, so sets added one after another sign one after another. ParticipantSetInfo is below.

->signers(string ...$emails): AgreementInfo

Shortcut for the most common case: one signer set per address, in the order given. ->signers('a@x.com', 'b@x.com') means a signs, then b. For parallel or mixed orders, see Signing order.

Message and language

->message(string $message): AgreementInfo
->locale(string $locale): AgreementInfo

message() is shown to every participant in the email and on the signing page: say what the document is and why their signature is needed. For a note that only one step should see, use ParticipantSetInfo::privateMessage(). locale() sets the language of Adobe's emails and signing page (en_US, en_GB, fr_FR, de_DE…); without it, the sender's language applies.

CCs

->cc(string ...$emails): AgreementInfo

Copies people on the agreement: they get an email when it is sent and when it completes, with the signed PDF attached, but take no action. Each address is validated immediately; an invalid one throws InvalidArgumentException. Call it as often as you like; addresses accumulate.

Reminders

->reminderFrequency(ReminderFrequency|string $frequency): AgreementInfo

Automatic reminders to whoever hasn't acted yet. Without it, the account's default applies (often none). A string is passed through unchanged, in case Adobe adds values before the enum does.

ReminderFrequencySends a reminder
DAILY_UNTIL_SIGNEDEvery day
WEEKDAILY_UNTIL_SIGNEDEvery weekday
EVERY_OTHER_DAY_UNTIL_SIGNEDEvery second day
EVERY_THIRD_DAY_UNTIL_SIGNEDEvery third day
EVERY_FIFTH_DAY_UNTIL_SIGNEDEvery fifth day
WEEKLY_UNTIL_SIGNEDEvery week
ONCEOnce

Every value except ONCE repeats until the participant acts. To delay the first reminder, set Adobe's firstReminderDelay (in hours) through with(): ->with(['firstReminderDelay' => 48]). To nudge someone right now, use agreements()->sendReminder($id) after sending; see Managing agreements.

Expiry

->expiresAt(DateTimeInterface $at): AgreementInfo

The moment after which the agreement can no longer be signed and its status becomes EXPIRED. Pass any DateTimeInterface in any time zone; the SDK converts it to UTC in the format Adobe expects (2026-11-17T14:00:00Z). Draft and authoring agreements don't expire; the countdown starts when the agreement is sent.

Your own reference

->externalId(string $id): AgreementInfo

Stores your identifier (a deal number, an order id) on the agreement. You can find agreements by it later with agreements()->all(externalId: 'hr-offer-2291'), and it comes back on the agreement wherever Adobe returns agreement details, so you can match it to your record without a lookup table. Participants can see it through the API, so never put a secret or a token in it.

Signature type: ESIGN or WRITTEN

->signatureType(SignatureType|string $type): AgreementInfo

SignatureType::ESIGN (the default) means participants sign electronically. SignatureType::WRITTEN means they print, sign by hand and the signed copy is uploaded back to Adobe afterwards. Use WRITTEN only when a wet-ink signature is legally required. The builder refuses WRITTEN together with an AES or QES signature level, since those are electronic by definition.

Signature level

->signatureLevel(SignatureLevel $level): AgreementInfo
->getSignatureLevel(): ?SignatureLevel

Requires SES, AES or QES for every signer that doesn't set its own level. Without it, the client's ClientOptions::$defaultSignatureLevel applies (SES unless you configure otherwise). AES and QES make Adobe require a certificate-based digital signature from each signer. getSignatureLevel() returns what was set on this builder, or null. The full story, including what makes a signature qualified, is in Signature levels.

State: send now, draft or authoring

->state(AgreementState|string $state): AgreementInfo

AgreementState::IN_PROCESS (the default) sends immediately. AgreementState::AUTHORING creates the agreement unsent, ready for someone to place fields in Adobe's editor. AgreementState::DRAFT saves an incomplete draft. Strings pass through for the rarer values in the spec (RESTART, IN_REVISION, PAUSED).

The authoring route is useful when documents come without text tags and a person should position the signature fields. Create the agreement in AUTHORING, send your user to Adobe's authoring page, and let them press Send there, or send it from your code afterwards:

Let a person place fields, then send
use Conceptlz\AdobeSign\Enum\AgreementState;

$agreementId = $sign->agreements()->create($agreement->state(AgreementState::AUTHORING));

// A URL for Adobe's authoring page, to open in a new tab or redirect to
foreach ($sign->agreements()->views($agreementId, 'AUTHORING')->getAgreementViewList() ?? [] as $view) {
    $authoringUrl = $view->getUrl();
}

// Later, if your own UI has the "Send" button:
$sign->agreements()->updateState($agreementId, 'IN_PROCESS');

Adobe allows the transitions DRAFT → AUTHORING → IN_PROCESS → CANCELLED through PUT /agreements/{agreementId}/state. The state is only accepted on creation and through that endpoint; a full update() ignores it.

Raw fields: with()

->with(array $fields): AgreementInfo

Merges fields from Adobe's AgreementCreationInfo into the payload. It is applied last and replaces top-level keys, so it can add anything the builder doesn't model and override anything it does. Repeated calls merge with each other. Validation still runs on the builder's own data before the merge.

Inspecting the result: toArray() and toModel()

->toArray(?SignatureLevel $defaultLevel = null): array
->toModel(?SignatureLevel $defaultLevel = null): AgreementCreationInfo

toArray() validates and returns the request body. toModel() returns the same data as the generated Conceptlz\AdobeSign\Generated\Model\AgreementCreationInfo, with a typed getter and setter for every field in Adobe's spec. Both take the default signature level to apply; create() passes the client's. Both throw the same InvalidArgumentExceptions as create().

ParticipantSetInfo

A participant set is one step: a role, an order and one or more members, any one of whom can act for the step (why). Build one with a named constructor:

ParticipantSetInfo::signer(ParticipantInfo|string ...$members): ParticipantSetInfo
ParticipantSetInfo::approver(ParticipantInfo|string ...$members): ParticipantSetInfo
ParticipantSetInfo::role(ParticipantRole|string $role, ParticipantInfo|string ...$members): ParticipantSetInfo

Strings are email addresses and are validated straight away. role() takes any ParticipantRole case, or a string for roles Adobe adds later. A set must end up with at least one member; an empty set is reported when the agreement is built.

MethodWhat it does
member(ParticipantInfo|string $member)Adds another member after construction (useful in loops).
order(int $order)Position in the signing order, from 1. Sets with the same number act in parallel. Below 1 throws immediately. See Signing order.
name(string $name)A name for the step ("Finance approval"), shown in Adobe's UI. Must be unique within the agreement; max 255 characters.
label(string $label)A unique label that maps the set to a step of a custom workflow defined in Acrobat Sign.
privateMessage(string $message)A message only this set's members see, in addition to the agreement's message.
signatureLevel(SignatureLevel $level)Overrides the agreement's level for this set, up or down. AES or QES on a role that doesn't sign throws immediately.
with(array $fields)Raw ParticipantSetInfo fields, merged last (for example signatureInputTypes, visiblePages).
getOrder(), getSignatureLevel()What was set explicitly, or null.
signs()True for SIGNER sets: the only role a signature level applies to.
effectiveSignatureLevel(?SignatureLevel $agreementLevel)The level this set will be held to: its own, else the agreement's if it signs, else null.
toArray(?int $defaultOrder, ?SignatureLevel $defaultLevel)The set's request array. AgreementInfo calls it for you.

ParticipantInfo: one person

Passing a plain email string is enough most of the time. Use ParticipantInfo when you want to give a name, require an identity check or set member-level options.

ParticipantInfo::email(string $email, ?string $name = null): ParticipantInfo
->verifyWith(Verification $verification): ParticipantInfo
->password(string $password): ParticipantInfo
->with(array $fields): ParticipantInfo

email() validates the address immediately. The name is used in Adobe's emails and pre-fills the signer's name. verifyWith() sets the identity check the person must pass before opening the document: Verification::emailOtp(), phone(), whatsApp(), knowledgeBased(), governmentId(), digitalIdentity(), adobeAccount(), password() or none(). Adobe allows one method per participant, so a second call replaces the first. password() is shorthand for verifyWith(Verification::password(...)). See Identity verification.

Members with names, checks and an SMS delivery option
use Conceptlz\AdobeSign\Builder\{ParticipantInfo, ParticipantSetInfo, Verification};

$borrower = ParticipantInfo::email('ali@example.ae', 'Ali Hassan')
    ->verifyWith(Verification::phone('501234567', '971', 'AE'));

$guarantor = ParticipantInfo::email('mona@example.ae', 'Mona Saeed')
    ->verifyWith(Verification::emailOtp())
    ->with(['phoneDeliveryInfo' => ['countryCode' => '971', 'phone' => '507654321']]);   // also send the link by SMS

$agreement->participantSet(ParticipantSetInfo::signer($borrower)->order(1))
          ->participantSet(ParticipantSetInfo::signer($guarantor)->order(1));

Roles in practice

The role changes what the person is asked to do. Some common combinations:

Approval before signature, a form filler, a delegator
use Conceptlz\AdobeSign\Enum\ParticipantRole;

AgreementInfo::create('Purchase order PO-7781')
    ->file($documentId)
    // 1. Sales ops completes the price fields
    ->participantSet(ParticipantSetInfo::role(ParticipantRole::FORM_FILLER, 'salesops@company.example'))
    // 2. Either finance manager approves (one set, two members: whoever is first)
    ->participantSet(ParticipantSetInfo::approver('fin-a@company.example', 'fin-b@company.example'))
    // 3. The customer's assistant forwards it to whoever signs for the customer
    ->participantSet(ParticipantSetInfo::role(ParticipantRole::DELEGATE_TO_SIGNER, 'assistant@customer.example'));

The full role list is on How Acrobat Sign works. Specialised roles (WITNESS, NOTARY_SIGNER, ELECTRONIC_SEALER…) depend on account features and often on extra fields; send those fields with with(). An ELECTRONIC_SEALER set has no members in Adobe's model, which the builder doesn't allow, so build that agreement as a raw array.

Email, redirect and other options

The builder covers the options most integrations need. Everything else in AgreementCreationInfo is reachable through with(). These are the ones people ask about most:

You want toFieldExample
Stop Adobe's emails (you notify people yourself, e.g. with embedded signing)emailOption.sendOptions['emailOption' => ['sendOptions' => ['initEmails' => 'NONE', 'inFlightEmails' => 'NONE', 'completionEmails' => 'ALL']]]
Send signers to your site after signingpostSignOption['postSignOption' => ['redirectUrl' => 'https://app.example.com/signed', 'redirectDelay' => 3]]
Password-protect the final PDFsecurityOption.openPassword['securityOption' => ['openPassword' => $pdfPassword]]
Pre-fill form fieldsmergeFieldInfo['mergeFieldInfo' => [['fieldName' => 'salary', 'defaultValue' => '120000']]]
Delay the first reminderfirstReminderDelay['firstReminderDelay' => 48] (hours)
File the agreement under a groupgroupId['groupId' => $groupId]
Embedded signing without Adobe's emails
$agreement = AgreementInfo::create('Tenancy agreement')
    ->file($documentId)
    ->signers('tenant@example.com')
    ->with([
        'emailOption' => ['sendOptions' => ['initEmails' => 'NONE', 'inFlightEmails' => 'NONE', 'completionEmails' => 'ALL']],
        'postSignOption' => ['redirectUrl' => 'https://portal.example.com/tenancy/signed'],
    ]);

initEmails covers the "please sign" and "agreement created" emails, inFlightEmails delegation and replacement notices, and completionEmails the completed, cancelled, expired and declined notices. With initial emails off, nobody learns about the agreement unless you show them a signing link; see signing URLs.

with() replaces whole keys

with(['ccs' => [...]]) replaces the CCs added with cc(); it doesn't append to them. The same goes for participantSetsInfo and fileInfos. For nested options, pass the complete object.

Raw arrays and generated models

create() accepts three kinds of input. They all reach the same endpoint, but they differ in what the SDK does first:

InputLocal validationDefault signature levelUse it when
AgreementInfo builderYesAppliedAlmost always; add with() for extras.
AgreementCreationInfo modelNoNot appliedYou want typed access to every field in Adobe's spec.
ArrayNoNot appliedYou already have the JSON (from Adobe's docs, a stored template, another system).
Start from the builder, finish with the typed model
use Conceptlz\AdobeSign\Generated\Model\{PostSignOption, SecurityOption};

$model = AgreementInfo::create('Board resolution')
    ->file($documentId)
    ->signers('chair@company.example', 'secretary@company.example')
    ->toModel();                                  // validated here, default level applied

$model->setFirstReminderDelay(24);
$model->setPostSignOption((new PostSignOption())->setRedirectUrl('https://intranet.example/board'));
$model->setSecurityOption((new SecurityOption())->setOpenPassword($pdfPassword));

$agreementId = $sign->agreements()->create($model);
A raw array, straight from Adobe's documentation
$agreementId = $sign->agreements()->create([
    'name' => 'Company seal on certificate',
    'fileInfos' => [['transientDocumentId' => $documentId]],
    'participantSetsInfo' => [
        ['role' => 'ELECTRONIC_SEALER', 'order' => 1, 'electronicSealId' => $sealId],
    ],
    'signatureType' => 'ESIGN',
    'state' => 'IN_PROCESS',
]);

Arrays and models skip your project's signature level

If you configured ClientOptions::$defaultSignatureLevel as AES or QES, only builder agreements pick it up. A raw array or a model you built yourself is sent exactly as written, so set isDigitalSignatureRequired on each signer set yourself, or go through the builder and toModel() as in the first example.

Validation before sending

The builders check everything they can know locally and throw Conceptlz\AdobeSign\Exception\InvalidArgumentException (which implements AdobeSignException) before any request. Some checks run as you call the method; the ones that need the whole agreement run in toArray(), which create() calls.

MessageThrown byWhen
Agreement name must not be empty.AgreementInfo::create()Immediately
"x" is not a valid participant email.ParticipantInfo::email(), and set constructors given stringsImmediately
"x" is not a valid CC email.cc()Immediately
Participant set order starts at 1.order()Immediately
A APPROVER set does not sign, so it cannot be held to AES.ParticipantSetInfo::signatureLevel()Immediately
FileInfo transientDocumentId must not be empty.FileInfo constructorsImmediately
An agreement needs at least one file.toArray() / create()When built
An agreement needs at least one participant set.toArray() / create()When built
Participant set "SIGNER" has no members.toArray() / create()When built
Signing orders must run 1..n without gaps (parallel sets share a number); got 1, 3.toArray() / create()When built
AES and QES need electronic signatures; this agreement uses signatureType WRITTEN.toArray() / create()When built
AES was requested but the agreement has no signer to apply it to.toArray() / create()When built

Because these are thrown before any network traffic, they are safe to show to the user who built the agreement (for example in a form that collects signer emails). Errors from Adobe are different: they arrive after the request as ApiException subclasses and can mention internal details, so log them and show something generic.

Separating "you typed something wrong" from "Adobe said no"
use Conceptlz\AdobeSign\Exception\{AccountCapabilityException, ApiException, InvalidArgumentException};

try {
    $agreementId = $sign->agreements()->create($agreement);
} catch (InvalidArgumentException $e) {
    return $form->withError($e->getMessage());                  // user can fix it
} catch (AccountCapabilityException $e) {
    $logger->warning('Acrobat Sign account setting blocks this', ['code' => $e->errorCode]);
    return $form->withError('This option is not enabled on our e-signature account.');
} catch (ApiException $e) {
    $logger->error('Acrobat Sign rejected the agreement', ['code' => $e->errorCode, 'status' => $e->statusCode]);
    throw $e;
}

What happens after create()

  1. You get an id at once

    Store it with your record straight away. Every later operation needs it.

  2. Adobe processes the documents

    For a few seconds the status is DOCUMENTS_NOT_YET_PROCESSED and some calls (signing links, members) aren't ready yet.

  3. The first step is notified

    The status becomes OUT_FOR_SIGNATURE (or another OUT_FOR_*) and the first participants get their email, unless you turned it off.

  4. You follow progress

    Through webhooks, or by reading status() and members(); then download the signed PDF.

Where next

Common questions

How do I create an agreement now and send it later?

Create it with ->state(AgreementState::AUTHORING) (or DRAFT), keep the id, and call agreements()->updateState($id, 'IN_PROCESS') when it should go out. Draft and authoring agreements don't expire, and the expiry countdown starts when you send.

How do I make the sender sign too?

Add the sender's email as a signer set at the right position, like any other participant. Adobe also has a senderSigns field (FIRST, LAST, …) that you can pass with with(), but an explicit participant set is clearer and is validated by the builder.

The builder has no method for the option I need. Am I stuck?

No. with() on AgreementInfo, ParticipantSetInfo and ParticipantInfo merges any field from Adobe's spec, and toModel() gives you the generated model with a setter for every field. create() also accepts a raw array. The builder only adds convenience and validation; it never limits what you can send.

Can the same person appear in two steps?

Yes, for example someone who approves at step 1 and signs at step 3. Each appearance is its own participant set with its own role and order. The builder doesn't forbid it; Adobe decides whether a particular combination is allowed.

Why does the name start with [DEMO USE ONLY]?

You are on a free developer account, and Adobe marks everything sent from one. The SDK sends the name you gave. The prefix disappears when you use a key from a paid account.

How do I unit-test the agreement my code builds?

Call toArray() on the builder and assert on the array: it is exactly the body create() sends (with toArray(SignatureLevel::AES) if your client uses a non-default level). No HTTP mock needed. For end-to-end tests with a mocked HTTP client, see Testing your integration.

Can each signer get a different message?

Yes. message() goes to everyone; ParticipantSetInfo::privateMessage() adds a note that only that set's members see. Use it for instructions that only concern one party, such as "please also initial page 4".