Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Guides

Managing agreements

Once an agreement is out for signature, your application needs to follow it: whose turn it is, what happened, a nudge for someone who hasn't signed, a corrected message, a cancellation. This page covers every agreements() method apart from creating (Creating agreements) and downloading (Downloading signed files).

At a glance

TaskMethodEndpoint
Read one agreementget($id), status($id)GET /agreements/{id}
List agreementsall()GET /agreements
Who is involved, whose turnmembers($id)GET /agreements/{id}/members
Audit historyevents($id)GET /agreements/{id}/events
Remind now / list reminderssendReminder($id), reminders($id)POST / GET /agreements/{id}/reminders
Change detailsupdate($id, $info)PUT /agreements/{id}
Send a draft, cancel, restartupdateState(), cancel()PUT /agreements/{id}/state
Embedded signing linkssigningUrls(), signingUrlFor()GET /agreements/{id}/signingUrls
Adobe's own pagesviews($id)POST /agreements/{id}/views
Duplicate as a draftcopy($id)POST /agreements/{id}/copy
Form fields and entered dataformFields($id), formData($id)GET /agreements/{id}/formFields
Signature level actually reachedsignatureEvidence($id)see Signature levels

Every method that calls the API throws an ApiException subclass on an error response and TransportException when no response arrives; the exceptions specific to a method are listed with it below. The full list is on Errors & exceptions.

Reading an agreement

agreements()->get(string $agreementId): AgreementGET /agreements/{agreementId}

Returns Conceptlz\AdobeSign\Model\Agreement, a small hand-written model with the fields most callers need, plus the full response. The response's ETag is kept on the model and remembered by the client, so a later update() or updateState() sends it as If-Match (see ETags in practice).

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

$agreement->name;            // "Master Services Agreement"
$agreement->status;          // "OUT_FOR_SIGNATURE"
$agreement->isSigned();      // status === 'SIGNED'
$agreement->isOutForSignature();

$details = $agreement->details();   // Generated\Model\FetchedAgreementInfo, every field in the spec
$details->getParticipantSetsInfo();
$agreement->raw['locale'];          // or the decoded JSON, untouched
PropertyTypeNotes
$id, $namestringMissing strings become "".
$statusstringOUT_FOR_SIGNATURE, SIGNED, CANCELLED, EXPIRED, DOCUMENTS_NOT_YET_PROCESSED, … Kept as a string because Adobe adds values without a version bump.
$signatureType?stringESIGN or WRITTEN.
$senderEmail?string
$createdDate, $expirationTime?stringISO 8601, as Adobe sent them.
$etag?stringThe response's ETag header.
$rawarrayThe decoded response body.

status($agreementId) is a shortcut for get($agreementId)->status. It makes the same request, so it also refreshes the remembered ETag.

The first seconds after sending

Adobe processes documents asynchronously. Straight after create() the status is usually DOCUMENTS_NOT_YET_PROCESSED, some calls answer AGREEMENT_NOT_EXPOSED or AGREEMENT_NOT_SIGNABLE, and even once the status reads OUT_FOR_SIGNATURE the participant data can lag for a few seconds. If you need to act immediately, poll with a short back-off or, better, wait for a webhook.

Listing agreements

agreements()->all(int $pageSize = 100, ?string $externalId = null, ?string $groupId = null, ?bool $showHiddenAgreements = null): \GeneratorGET /agreements

Yields Conceptlz\AdobeSign\Model\AgreementSummary objects. Pages are fetched lazily: nothing is requested until you start iterating, and the next page only when you reach the end of the current one. $pageSize is the number of rows per request, not a limit on the total.

// Every agreement visible to the caller, page by page
foreach ($sign->agreements()->all() as $summary) {
    printf("%s  %-28s %s\n", $summary->displayDate, $summary->status, $summary->name);
}

// Find the agreement(s) you created with ->externalId('crm-deal-42')
foreach ($sign->agreements()->all(externalId: 'crm-deal-42') as $summary) {
    $agreementId = $summary->id;
}

// Only the first 20, without fetching more pages than needed
foreach (new LimitIterator($sign->agreements()->all(pageSize: 20), 0, 20) as $summary) {
    // ...
}

AgreementSummary has $id, $name, $status, $type (AGREEMENT, MEGASIGN_CHILD, WIDGET_INSTANCE), $groupId, $displayDate, $hidden and $raw, plus details() for the generated AgreementAgreement model.

The status in a list row is relative to the caller: the same agreement can be WAITING_FOR_MY_SIGNATURE for one user and OUT_FOR_SIGNATURE for the sender. Use get() for the agreement's own status.

Breaking out of a foreach stops the paging, so searching a large account is cheap when the match is near the top. Avoid iterator_to_array() on a big account: it downloads every page. All list methods in the SDK work the same way (see the paging pattern).

Participants and whose turn it is

agreements()->members(string $agreementId, bool $includeNextParticipantSet = false): arrayGET /agreements/{agreementId}/members

Returns the decoded MembersInfo as an array: every participant set with its role, order, status and members. Pass includeNextParticipantSet: true to also get nextParticipantSets, the sets whose turn it is now. Adobe leaves that key out unless asked, even though the spec lists it.

$members = $sign->agreements()->members($agreementId, includeNextParticipantSet: true);

foreach ($members['participantSets'] as $set) {
    // $set['role'] SIGNER, $set['order'] 1, $set['status'] WAITING_FOR_MY_SIGNATURE / NOT_YET_VISIBLE / COMPLETED
    foreach ($set['memberInfos'] as $member) {
        echo "{$set['order']}. {$member['email']} ({$set['status']})\n";
    }
}

$waitingOn = array_merge(...array_map(
    fn (array $set) => array_column($set['memberInfos'], 'email'),
    $members['nextParticipantSets'] ?? [],
));

Each member has an id (the participant id used by sendReminder() and the participantId download options), an email and a securityOption.authenticationMethod showing the identity check that applies. The sender is under senderInfo.

The event history

agreements()->events(string $agreementId): AgreementEventListGET /agreements/{agreementId}/events

The agreement's audit history as generated models: created, emailed, viewed, identity checked, signed, completed, and so on. It is the same information as the audit report, in structured form.

foreach ($sign->agreements()->events($agreementId)->getEvents() ?? [] as $event) {
    printf(
        "%s  %-24s %s\n",
        $event->getDate(),
        $event->getType(),            // CREATED, EMAIL_OTP_AUTHENTICATED, DOCUMENTS_VIEWED, ACTION_COMPLETED, DIGSIGNED, ...
        $event->getParticipantEmail(),
    );
}

Event types are strings, not an enum. Useful ones include ACTION_COMPLETED (someone signed or approved), DIGSIGNED (a certificate-based signature, see a real AES), EMAIL_OTP_AUTHENTICATED (see Identity verification) and DOCUMENTS_VIEWED. The live API sends some fields the generated model doesn't know (digitalSignatureInfo.providerName, for example); if you need those, decode the raw response yourself or use signatureEvidence(), which reads both spellings.

Reminders

agreements()->sendReminder(string $agreementId, ?array $participantIds = null, ?string $note = null): ReminderCreationResultPOST /agreements/{agreementId}/reminders

Sends a one-off reminder now. With $participantIds left as null, the SDK works out who to remind: it calls members() with includeNextParticipantSet: true and takes the member ids of nextParticipantSets. If Adobe leaves that key out, it falls back to every participant set whose status starts with WAITING_FOR_MY_ (WAITING_FOR_MY_SIGNATURE, WAITING_FOR_MY_APPROVAL, …). That costs one extra request.

Throws InvalidArgumentException when nobody is left to remind (for example, the agreement is already signed), instead of sending an empty request.

// Whoever's turn it is, with a personal note
$result = $sign->agreements()->sendReminder($agreementId, note: 'Friendly nudge: we need this by Friday.');

$result->id;                   // the reminder's id
$result->disabledRecipients;   // participants who won't get it (e.g. they turned reminder emails off)

// Specific people: participant ids from members()
$sign->agreements()->sendReminder($agreementId, ['CBJCHBCAABAAmem2']);

// Reminders on record, optionally by status: ACTIVE, CANCELED or COMPLETE
foreach ($sign->agreements()->reminders($agreementId, 'ACTIVE')->getReminderInfoList() ?? [] as $reminder) {
    echo $reminder->getReminderId(), ' next: ', $reminder->getNextSentDate(), "\n";
}

For automatic reminders, set them when you create the agreement: ->reminderFrequency(ReminderFrequency::DAILY_UNTIL_SIGNED) (also WEEKDAILY_UNTIL_SIGNED, EVERY_OTHER_DAY_UNTIL_SIGNED, EVERY_THIRD_DAY_UNTIL_SIGNED, EVERY_FIFTH_DAY_UNTIL_SIGNED, WEEKLY_UNTIL_SIGNED and ONCE). When it isn't set, the account's default applies.

Updating an agreement

agreements()->update(string $agreementId, AgreementInfo|FetchedAgreementInfo|array $info, ?bool $documentUpdateOnly = null): voidPUT /agreements/{agreementId}

Adobe's PUT replaces the agreement's details with what you send, so it expects the whole object, not a patch. The reliable pattern is fetch, modify, put. (The first AgreementInfo in the signature is the generated model of that name, not the builder.) Throws PreconditionFailedException when the agreement changed since you fetched it.

  1. Fetch

    get() returns the current details and remembers the ETag.

  2. Modify

    Change only what you need on the generated FetchedAgreementInfo.

  3. Put

    update() sends it back with If-Match, so it only succeeds if nobody changed the agreement in between.

$details = $sign->agreements()->get($agreementId)->details();

$details->setMessage('Updated: the price in clause 4 now includes VAT.');
$details->setExpirationTime('2026-12-31T23:59:59Z');

$sign->agreements()->update($agreementId, $details);

What Adobe lets you change depends on the agreement's state and on who has already acted; it answers with codes such as MODIFYING_IMMUTABLE_FIELDS (a ValidationException) or AGREEMENT_ALREADY_SIGNED when you try something it doesn't allow. Pass documentUpdateOnly: true when you only replace documents.

ETags in practice

Two people (or a person and a background job) can change the same agreement at nearly the same time. Without protection, the second write silently overwrites the first. Acrobat Sign prevents this with optimistic concurrency: every read returns an ETag, a version stamp, and a write that carries If-Match: <etag> is rejected with 412 Precondition Failed if the agreement has changed since. The SDK does the bookkeeping for you.

What the SDK does

  • Every successful GET that returns an ETag is remembered for its path, and any ETag remembered for paths underneath it is forgotten, because the fresh read supersedes them.
  • Every PUT or DELETE without an If-Match header gets the ETag remembered for the same path or, failing that, for the nearest parent. PUT /agreements/{id}/state therefore uses the ETag from GET /agreements/{id}, which is what Adobe expects.
  • After a successful write, everything remembered for that resource is forgotten. Only a write to the resource itself (PUT /agreements/{id}) stores the new ETag from its response. A write to a sub-resource such as /state stores nothing, because Adobe changes the parent's ETag too. Reusing it caused 412s in live testing.
  • A 412 becomes PreconditionFailedException (Adobe's code is usually RESOURCE_MODIFIED).

What that means for your code

You callHeader sentResult
get() then update()If-Match with the fetched ETagSucceeds, or 412 if someone else changed it in between.
get() then cancel()If-Match with the agreement's ETagSame protection for state changes.
update() without a get() firstno If-MatchAn unconditional write: no conflict check.
updateState() twice in a rowfirst with If-Match, second withoutThe first write made the ETag stale, so the SDK drops it rather than send it and fail.
get() right after sending, then update()If-MatchCan get a 412: Adobe is still updating the agreement in the background while it delivers emails.

The ETag memory belongs to the AdobeSign instance (and the copies made with asUser(), which share it) and keeps the most recent 500 paths. In a PHP-FPM request that means "this request"; in a long-running queue worker it lasts longer, which is harmless because an outdated ETag only leads to a 412 and a re-fetch. The ETag of the last read is also on Agreement::$etag if you want to log it. To turn the behaviour off, use new ClientOptions(autoEtag: false); writes are then always unconditional. See Data centres, retries & ETags for the rest of the HTTP pipeline.

Handling a 412

A conflict is not an error in your data; it means "look again". Re-fetch, re-apply your change and retry a couple of times:

Retry on a stale ETag
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Exception\PreconditionFailedException;
use Conceptlz\AdobeSign\Generated\Model\FetchedAgreementInfo;

/** @param callable(FetchedAgreementInfo): void $change */
function updateAgreement(AdobeSign $sign, string $agreementId, callable $change, int $attempts = 3): void
{
    for ($attempt = 1; ; $attempt++) {
        $details = $sign->agreements()->get($agreementId)->details();   // fresh data and ETag
        $change($details);

        try {
            $sign->agreements()->update($agreementId, $details);
            return;
        } catch (PreconditionFailedException $e) {
            if ($attempt >= $attempts) {
                throw $e;
            }
            usleep(500_000 * $attempt);   // Adobe may still be busy with the agreement
        }
    }
}

updateAgreement($sign, $agreementId, fn (FetchedAgreementInfo $d) => $d->setMessage('Please sign by Friday.'));

Changing state

agreements()->updateState(string $agreementId, string $state, array $extra = []): voidPUT /agreements/{agreementId}/state

$state is one of AUTHORING, CANCELLED, IN_PROCESS, RESTART, IN_REVISION or PAUSED. $extra is merged into the body for states that need more information. Throws PreconditionFailedException when the agreement changed since it was fetched.

// Send an agreement created as a draft or in AUTHORING
$sign->agreements()->updateState($agreementId, 'IN_PROCESS');

// Restart with a message to the participants
$sign->agreements()->updateState($agreementId, 'RESTART', [
    'agreementRestartInfo' => ['message' => 'We corrected the delivery address. Please sign again.'],
]);

Which transitions are allowed depends on the current state and the account; Adobe refuses the others with an ApiException whose $errorCode says why (for example OPERATION_NOT_ALLOWED or UNABLE_TO_RESTART_AGREEMENT). Call get() first if you want the write guarded by If-Match.

Cancelling an agreement

agreements()->cancel(string $agreementId, ?string $comment = null, bool $notifyOthers = true): voidPUT /agreements/{agreementId}/state

Recalls an agreement that is out for signature: a CANCELLED state change with an agreementCancellationInfo. $comment is shown to participants. $notifyOthers (required by the spec, so the SDK always sends it) controls whether participants are emailed about the cancellation.

$sign->agreements()->get($agreementId);   // optional: guard the cancel with If-Match
$sign->agreements()->cancel($agreementId, 'Sent to the wrong address, a corrected version follows.');

// Quietly, e.g. cleaning up test agreements
$sign->agreements()->cancel($agreementId, notifyOthers: false);

A cancelled agreement stays in the account with status CANCELLED and its history. You cannot cancel an agreement that is already signed; Adobe answers with an error.

Embedded signing links

agreements()->signingUrls(string $agreementId, ?int $expiry = null, ?string $frameParent = null): SigningUrlResponseGET /agreements/{agreementId}/signingUrls

E-sign links for whoever's turn it is, so you can send people to the signing page from your own application or embed it. $expiry is the link lifetime in seconds, from 30 to 315,360,000 (the account setting applies when null); a value outside that range throws InvalidArgumentException before any request. $frameParent is the domain that will host the page in an iframe.

Throws NotFoundException when nobody can sign yet: Adobe answers AGREEMENT_NOT_SIGNABLE while it is still processing documents straight after create(). Retry after a few seconds.

agreements()->signingUrlFor(string $agreementId, string $email, ?int $expiry = null): ?stringGET /agreements/{agreementId}/signingUrls

The link for one signer (email compared case-insensitively), or null if it isn't their turn.

use Conceptlz\AdobeSign\Exception\NotFoundException;

try {
    $url = $sign->agreements()->signingUrlFor($agreementId, $user->email, expiry: 900);
} catch (NotFoundException $e) {
    // $e->errorCode === 'AGREEMENT_NOT_SIGNABLE': still processing; try again shortly
    $url = null;
}

if ($url === null) {
    return view('contract.waiting');      // not their turn (yet)
}
return redirect($url);

// All current links, grouped by participant set
foreach ($sign->agreements()->signingUrls($agreementId)->getSigningUrlSetInfos() ?? [] as $set) {
    foreach ($set->getSigningUrls() ?? [] as $link) {
        echo $link->getEmail(), ': ', $link->getEsignUrl(), "\n";
    }
}

Treat signing links like passwords: anyone holding one can open the signing page (identity checks still apply). Generate them on demand for the logged-in user and keep the expiry short.

Adobe's pages: views

agreements()->views(string $agreementId, string $name = 'ALL', array $configuration = []): AgreementViewsPOST /agreements/{agreementId}/views

URLs for Acrobat Sign's own pages about this agreement, to redirect to or embed. $name is one of COMPOSE, MODIFY, PREFILL, AUTHORING, SEND_PROGRESS, POST_CREATE, DOCUMENT, MANAGE, SIGNING or ALL. $configuration adds other AgreementViewInfo fields.

// Let a sender place fields on an agreement created with ->state(AgreementState::AUTHORING)
$views = $sign->agreements()->views($agreementId, 'AUTHORING', [
    'commonViewConfiguration' => ['noChrome' => true, 'autoLoginUser' => false, 'locale' => 'en_GB'],
]);

foreach ($views->getAgreementViewList() ?? [] as $view) {
    echo $view->getName(), ' ', $view->getUrl(), ' (expires ', $view->getExpiration(), ")\n";
}

Copying an agreement

agreements()->copy(string $agreementId): stringPOST /agreements/{agreementId}/copy

Creates a new DRAFT with the same documents and participants and returns its id. The original is untouched. Useful for "send again" and for recurring agreements: copy, adjust with update(), then send with updateState($newId, 'IN_PROCESS').

$newId = $sign->agreements()->copy($agreementId);
$sign->agreements()->updateState($newId, 'IN_PROCESS');

Form fields and entered data

agreements()->formFields(string $agreementId): AgreementFormFieldsResponseGET /agreements/{agreementId}/formFields

The fields placed on the agreement (signature boxes, text fields, checkboxes) and their groups.

foreach ($sign->agreements()->formFields($agreementId)->getFields() ?? [] as $field) {
    echo $field->getName(), ' (', $field->getInputType(), ')', $field->getRequired() ? ' required' : '', "\n";
}

The values signers typed come from formData($agreementId), a CSV stream that only the agreement's creator can read. It is covered with the other downloads in Downloading signed files.

Acting for another user

Agreements belong to the user who sent them. With an admin-level token, asUser() gives you a client that acts as someone else in the account, which you need to manage agreements your integration didn't send itself:

$asSales = $sign->asUser('email:sales-lead@example.com');
$asSales->agreements()->cancel($agreementId, 'Superseded by the renewal.');

See Authentication for the details.

Common questions

Why did my update fail with PreconditionFailedException right after sending?

In the first seconds after create(), Adobe keeps changing the agreement while it processes documents and sends emails, so the ETag you just read can already be stale. Wait a moment, fetch again and retry, as in Handling a 412.

How do I find out who still has to sign?

Call members($id, includeNextParticipantSet: true) and read nextParticipantSets, or look for participant sets whose status starts with WAITING_FOR_MY_. Without the flag Adobe omits nextParticipantSets.

sendReminder() threw "has no participants awaiting action". Why?

Nobody's turn is open: the agreement is signed, cancelled or expired, or still being processed. The SDK checks before sending so you don't fire an empty reminder. Check status() first, or pass explicit participant ids.

Can I change a signer's email after sending?

Adobe models that as modifying participants, which it allows only in some states and only for people who haven't acted. If update() is refused, the dependable route is to cancel() the agreement and send a corrected one (or copy() it, fix the copy, and send that).

Can I delete an agreement?

The SDK doesn't wrap deletion. Cancel the agreement instead; it stays in the account, marked CANCELLED, with its audit history. Retention and deletion policies are account settings in Acrobat Sign admin.

Should I poll status() to find out when an agreement is signed?

Polling works for scripts and small volumes, but it costs API calls and adds delay. Subscribe to a webhook and confirm the status with get() when a notification arrives. Keep a slow poll (say, nightly) as a safety net for missed notifications.

Where next