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
| Task | Method | Endpoint |
|---|---|---|
| Read one agreement | get($id), status($id) | GET /agreements/{id} |
| List agreements | all() | GET /agreements |
| Who is involved, whose turn | members($id) | GET /agreements/{id}/members |
| Audit history | events($id) | GET /agreements/{id}/events |
| Remind now / list reminders | sendReminder($id), reminders($id) | POST / GET /agreements/{id}/reminders |
| Change details | update($id, $info) | PUT /agreements/{id} |
| Send a draft, cancel, restart | updateState(), cancel() | PUT /agreements/{id}/state |
| Embedded signing links | signingUrls(), signingUrlFor() | GET /agreements/{id}/signingUrls |
| Adobe's own pages | views($id) | POST /agreements/{id}/views |
| Duplicate as a draft | copy($id) | POST /agreements/{id}/copy |
| Form fields and entered data | formFields($id), formData($id) | GET /agreements/{id}/formFields |
| Signature level actually reached | signatureEvidence($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
| Property | Type | Notes |
|---|---|---|
$id, $name | string | Missing strings become "". |
$status | string | OUT_FOR_SIGNATURE, SIGNED, CANCELLED, EXPIRED, DOCUMENTS_NOT_YET_PROCESSED, … Kept as a string because Adobe adds values without a version bump. |
$signatureType | ?string | ESIGN or WRITTEN. |
$senderEmail | ?string | |
$createdDate, $expirationTime | ?string | ISO 8601, as Adobe sent them. |
$etag | ?string | The response's ETag header. |
$raw | array | The 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.
- Fetch
get()returns the current details and remembers the ETag. - Modify
Change only what you need on the generated
FetchedAgreementInfo. - Put
update()sends it back withIf-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
GETthat 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
PUTorDELETEwithout anIf-Matchheader gets the ETag remembered for the same path or, failing that, for the nearest parent.PUT /agreements/{id}/statetherefore uses the ETag fromGET /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/statestores nothing, because Adobe changes the parent's ETag too. Reusing it caused 412s in live testing. -
A 412 becomes
PreconditionFailedException(Adobe's code is usuallyRESOURCE_MODIFIED).
What that means for your code
| You call | Header sent | Result |
|---|---|---|
get() then update() | If-Match with the fetched ETag | Succeeds, or 412 if someone else changed it in between. |
get() then cancel() | If-Match with the agreement's ETag | Same protection for state changes. |
update() without a get() first | no If-Match | An unconditional write: no conflict check. |
updateState() twice in a row | first with If-Match, second without | The first write made the ETag stale, so the SDK drops it rather than send it and fail. |
get() right after sending, then update() | If-Match | Can 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:
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.