Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Guides

Signing order

"The client signs first, then our CFO and legal team in parallel, then the CEO countersigns." This page shows how to express any order like that with one order() call per step, which orders Adobe accepts and which need an account setting, how to find out whose turn it is, and how to hand the current signer a link for embedded signing.

The rule in one sentence

Each participant set gets an order number starting at 1; sets with the same number act at the same time, and a number only starts when every set with a lower number has finished. Adobe enforces this on its side: people in a later step aren't emailed, can't see the document and get no signing link until their turn comes.

ORDER 1 ORDER 2 · PARALLEL ORDER 3 1 · Client SIGNER 2a · CFO SIGNER 2b · Legal APPROVER 3 · CEO SIGNER waits for both time
Orders 1, 2, 2, 3. The client is emailed first. When they sign, the CFO and legal are emailed together and can act in either order. The CEO is emailed only after both are done.
The order in the diagram
use Conceptlz\AdobeSign\Builder\{AgreementInfo, ParticipantSetInfo};

$agreement = AgreementInfo::create('Master Services Agreement')
    ->file($documentId)
    ->participantSet(ParticipantSetInfo::signer('client@customer.example')->order(1))
    ->participantSet(ParticipantSetInfo::signer('cfo@company.example')->order(2))
    ->participantSet(ParticipantSetInfo::approver('legal@company.example')->order(2))
    ->participantSet(ParticipantSetInfo::signer('ceo@company.example')->order(3));

Common patterns

You wantOrdersHowAccount setting
A, then B, then C1, 2, 3->signers('a@x.com', 'b@x.com', 'c@x.com')None
A, B and C at the same time1, 1, 1->order(1) on every setNone
A first, then B and C in any order, then D1, 2, 2, 3Explicit order() per setHybrid routing
Approval before signature1, 2approver(...)->order(1), signer(...)->order(2)None
Anyone in a team signs, once1One set, several members: signer('a@x.com', 'b@x.com')None

Sequential

The default. Each set waits for the one before. When the order expresses authority (an employee signs, then a manager countersigns), sequential is usually what the business means.

// Three equivalent ways to write 1, 2, 3
$agreement->signers('employee@example.com', 'manager@company.example', 'hr@company.example');

$agreement->participantSet(ParticipantSetInfo::signer('employee@example.com'))
          ->participantSet(ParticipantSetInfo::signer('manager@company.example'))
          ->participantSet(ParticipantSetInfo::signer('hr@company.example'));

$agreement->participantSet(ParticipantSetInfo::signer('employee@example.com')->order(1))
          ->participantSet(ParticipantSetInfo::signer('manager@company.example')->order(2))
          ->participantSet(ParticipantSetInfo::signer('hr@company.example')->order(3));

Parallel

Everyone is emailed at once and can act in any order; the agreement completes when the last one has acted. Good for co-signers of equal standing (two founders, joint tenants) and the fastest way to collect signatures.

$agreement->participantSet(ParticipantSetInfo::signer('tenant1@example.com')->order(1))
          ->participantSet(ParticipantSetInfo::signer('tenant2@example.com')->order(1))
          ->participantSet(ParticipantSetInfo::signer('landlord@example.com')->order(1));

Mixed ("hybrid")

Some steps sequential, some parallel, as in the diagram above. This is the most natural fit for many real processes and the one that needs an account setting, covered in hybrid routing.

"Any one of these people"

Not an ordering feature, but often confused with parallel signing: a single set with several members is completed by whichever member acts first. Use it for "any director may sign for the company". Use parallel sets when you need everyone's signature.

// ONE signature from any director, then the customer
$agreement->participantSet(ParticipantSetInfo::signer('dir1@company.example', 'dir2@company.example')->order(1))
          ->participantSet(ParticipantSetInfo::signer('customer@example.com')->order(2));

How sets without order() are numbered

You can leave order() out. The builder then numbers each unnumbered set one more than the set added just before it (the first set gets 1). Explicit numbers are kept as they are. That gives intuitive results when you number everything or nothing, and surprising ones when you mix:

Sets addedOrders sentMeaning
signer(a), signer(b), signer(c)1, 2, 3Sequential, as expected
signer(a), signer(b)->order(1), signer(c)1, 1, 2a and b together, then c
signers(a, b), approver(c)->order(2)1, 2, 2c approves in parallel with b, which may not be what you meant
signer(a)->order(2), signer(b)2, 3Rejected: there is no step 1

Number all or nothing

For anything beyond a simple sequence, call order() on every set. Reviewers can then read the workflow straight off the code, and moving a line doesn't silently change who signs when. $set->getOrder() returns the explicit number (or null), and $agreement->toArray()['participantSetsInfo'] shows the final numbers.

The consecutive-order rule

Adobe's spec requires the distinct order numbers to form "a valid consecutive increasing sequence": exactly 1, 2, …, n with no gaps, repeats allowed. Adobe rejects anything else, but with an error that doesn't say which number is wrong. The builder checks the rule locally, so a bad order never reaches the API:

OrdersValid?Why
1, 2, 3ValidSequential
1, 1, 2ValidRepeats are parallel steps
2, 1, 2ValidThe order you add sets in doesn't matter, only the numbers
1, 3InvalidGap: there is no 2
2, 2InvalidMust start at 1
0, 1Invalidorder(0) throws immediately: orders start at 1
Conceptlz\AdobeSign\Exception\InvalidArgumentException:
Signing orders must run 1..n without gaps (parallel sets share a number); got 1, 3.

The check runs when the agreement is built (toArray(), and so create()). It applies to builder agreements only; if you send a raw array or a generated model, Adobe does the checking.

Gaps usually appear when steps are optional: "legal reviews at step 2, but only for contracts over $100k". Build the list of steps first and number them afterwards, rather than hard-coding numbers:

Optional steps without gaps
$steps = [[ParticipantSetInfo::signer($deal->customerEmail)]];
if ($deal->value > 100_000) {
    $steps[] = [ParticipantSetInfo::approver('legal@company.example')];
}
$steps[] = [
    ParticipantSetInfo::signer('cfo@company.example'),        // these two share a step
    ParticipantSetInfo::signer('sales-director@company.example'),
];

foreach ($steps as $i => $sets) {
    foreach ($sets as $set) {
        $agreement->participantSet($set->order($i + 1));       // always 1..n
    }
}

Mixed orders need hybrid routing

Purely sequential orders (1, 2, 3) and purely parallel ones (1, 1, 1) always work. Mixing them (1, 2, 2, 3) is what Acrobat Sign calls hybrid routing, and it is an account setting that is off on many accounts, including new developer accounts. When it is off, Adobe refuses the agreement:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{"code":"HYBRID_ROUTING_NOT_ALLOWED","message":"Hybrid routing is not enabled for this user"}

The SDK maps that code to AccountCapabilityException (a subclass of ApiException). The name says what to do: the request is valid, but the account doesn't allow it, so the fix is in Acrobat Sign's admin settings, not in your code. Nothing was created, and nobody was emailed.

Falling back to a sequential order
use Conceptlz\AdobeSign\Exception\AccountCapabilityException;

try {
    $agreementId = $sign->agreements()->create($mixedOrderAgreement);
} catch (AccountCapabilityException $e) {
    if ($e->errorCode !== 'HYBRID_ROUTING_NOT_ALLOWED') {
        throw $e;
    }
    // Same people, strictly one after another: always allowed
    $logger->notice('Hybrid routing is off; sending sequentially. Ask an Acrobat Sign admin to enable it.');
    $agreementId = $sign->agreements()->create($sequentialAgreement);
}

Enabling it

  1. Find an account administrator

    Only an Acrobat Sign account (or group) admin can change send settings. On a developer account, that is you.

  2. Open the send settings

    In the Acrobat Sign web app, go to the account settings and open the Send Settings page. Look for the signing-order options and allow hybrid (mixed sequential and parallel) routing. Adobe's labels and menu layout change over time; Adobe's help pages on "hybrid signing order" describe the current location.

  3. Check the group, too

    Settings can be overridden per group, and the error is about the sending user. If the account allows it but one user still gets the error, check the settings of that user's group.

  4. Try again

    Re-run the request; no change in your code is needed. The package's live-lifecycle example ("a@x.com,b@x.com+c@x.com") is a quick way to confirm.

Whose turn is it?

Your UI will want to say "waiting for the CFO and legal", and your reminder job needs to know who to nudge. The answer comes from the participants endpoint.

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

Returns the raw MembersInfo array: participantSets (every step, each with order, role, status and memberInfos), senderInfo, ccsInfo and sharesInfo. With includeNextParticipantSet: true it also contains nextParticipantSets, the steps whose turn it is now. Adobe omits that key unless you ask, even though its spec lists it.

Two ways to find the current step
$members = $sign->agreements()->members($agreementId, includeNextParticipantSet: true);

// 1. Ask Adobe directly
$waitingFor = [];
foreach ($members['nextParticipantSets'] ?? [] as $set) {
    foreach ($set['memberInfos'] ?? [] as $member) {
        $waitingFor[] = $member['email'];
    }
}

// 2. Or read each step's status (works without the flag)
foreach ($members['participantSets'] ?? [] as $set) {
    $emails = implode(', ', array_column($set['memberInfos'] ?? [], 'email'));
    printf("%d  %-9s %-26s %s\n", $set['order'], $set['role'], $set['status'], $emails);
}
1  SIGNER    WAITING_FOR_OTHERS         client@customer.example
2  SIGNER    WAITING_FOR_MY_SIGNATURE   cfo@company.example
2  APPROVER  WAITING_FOR_MY_APPROVAL    legal@company.example
3  SIGNER    NOT_YET_VISIBLE            ceo@company.example

A step whose status starts with WAITING_FOR_MY_ is up now; NOT_YET_VISIBLE is a later step whose members haven't been told about the agreement yet. Match people by email or by member id, not by the order field of nextParticipantSets entries, which doesn't always mirror the step number.

Give it a few seconds after creating

Right after create(), the status is DOCUMENTS_NOT_YET_PROCESSED, and even once it reads OUT_FOR_SIGNATURE, participant data can lag for a moment, so nextParticipantSets may briefly be empty. Code that shows "whose turn" right after sending should retry for a few seconds, or wait for the first webhook.

The SDK uses the same information for reminders: agreements()->sendReminder($id) with no participant ids reminds whoever's turn it is, falling back to WAITING_FOR_MY_* statuses if Adobe leaves out nextParticipantSets. With webhooks, AGREEMENT_ACTION_COMPLETED tells you a step finished and AGREEMENT_ACTION_REQUESTED that the next one was asked to act; see Webhooks.

Signing URLs for embedded signing

By default, Adobe emails each person when their turn comes. For embedded signing, you show the signing page inside your own application instead: the customer finishes checkout and signs on the next screen, or a field agent hands a tablet to a client. Adobe gives you a signing URL for each person whose turn it currently is.

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

Returns the generated SigningUrlResponse: getSigningUrlSetInfos() gives one entry per current step, each with getSigningUrls() (objects with getEmail() and getEsignUrl()). $expiry is the link lifetime in seconds, from 30 to 315,360,000 (ten years); outside that range throws InvalidArgumentException before the call. Leave it out to use the account's setting. $frameParent is the domain that will host the page in an iframe.

Throws NotFoundException (AGREEMENT_NOT_SIGNABLE, or AGREEMENT_NOT_EXPOSED) when nobody can sign yet, typically in the first seconds after creation; retry shortly.

agreements()->signingUrlFor(string $agreementId, string $email, ?int $expiry = null): ?string

The signing link for one person, comparing emails case-insensitively, or null if it isn't their turn (or they aren't a signer on this agreement). The usual call in a web app, where you know who is logged in.

Signing links point at your account's data centre, for example https://secure.na4.adobesign.com/public/apiesign?pid=….

  1. Create the agreement

    Optionally switch Adobe's initial emails off and set a post-sign redirect back to your app (see email options).

  2. Wait until it is signable

    Retry signingUrlFor() on NotFoundException for a few seconds while Adobe processes the documents.

  3. Authenticate your user, then fetch the link

    Request the link when the person opens the page, not in advance, and only after your own login has confirmed who they are.

  4. Redirect or embed

    Redirect the browser to the link, or load it in an iframe on the $frameParent domain. After signing, Adobe sends them to your redirect URL.

app/Http/SignController.php (framework-neutral)
use Conceptlz\AdobeSign\Exception\NotFoundException;

public function sign(string $contractId): Response
{
    $contract = $this->contracts->findForUser($contractId, $this->auth->user());   // your access check

    for ($attempt = 1; $attempt <= 5; $attempt++) {
        try {
            $url = $this->sign->agreements()->signingUrlFor(
                $contract->adobeAgreementId,
                $this->auth->user()->email,
                expiry: 600,                                     // 10 minutes is plenty to start
            );
            break;
        } catch (NotFoundException) {
            sleep(2);                                            // still processing; try again shortly
            $url = null;
        }
    }

    if ($url === null) {
        return $this->view('contracts/not-your-turn', ['contract' => $contract]);
    }

    return $this->redirect($url);
}

A signing URL is a key to someone's signature

Whoever opens the link can sign as that person, unless an identity check is configured. Don't log signing URLs, don't put them in analytics, and don't send them over channels you wouldn't trust with a password. Generate them on demand, after your own authentication, with a short $expiry. For higher assurance, add an identity check such as Verification::emailOtp() or phone() to the participant, so the link alone isn't enough.

Where next

Common questions

Why do I get HYBRID_ROUTING_NOT_ALLOWED?

Your order mixes sequential and parallel steps (for example 1, 2, 2, 3), and the sending user's account or group doesn't allow that. An Acrobat Sign admin can enable hybrid routing in the send settings; until then, use a purely sequential or purely parallel order. The SDK raises it as AccountCapabilityException, and nothing was sent. See Hybrid routing.

Can I skip an order number, say 1 then 3?

No. Adobe requires the numbers to run 1 to n without gaps, and the builder rejects gaps before sending. Build the list of steps first and number them afterwards, as in the optional-steps example.

Can I change the order after sending?

Not reliably through this SDK. Adobe allows some participant changes in flight, but reordering steps that may already have acted isn't something to depend on. Cancel and send a new agreement; agreements()->copy($id) gives you a draft with the same documents and participants to start from.

nextParticipantSets is missing or empty. Why?

Either you didn't pass includeNextParticipantSet: true (Adobe only includes the key on request), the agreement is still processing (wait a few seconds after creation), or it is finished, cancelled or expired, so nobody is up. Each step's status in participantSets tells you which.

signingUrlFor() returns null for a signer. What's wrong?

It isn't that person's turn yet (an earlier step hasn't finished), they have already signed, or the email doesn't match a signer on the agreement. Signing URLs exist only for people who can act right now. Check members() to see where the agreement is.

How do I make our company always sign last?

Give your signatory the highest order number. A common reason is legal: the counterparty's signature is their offer, and your signature last is the acceptance, so the contract is only binding once you have checked their side. The audit trail records the sequence.

What's the difference between two sets with order 1 and one set with two members?

Two sets with order 1 collect two signatures, in any order. One set with two members collects one signature, from whichever member acts first. Use the first for co-signers, the second for "any authorised person".