Guides
Walkthrough: send & store
Build the complete loop a contract takes through your app: a form uploads the PDF, a controller sends it in signing order, Adobe calls your webhook when everyone has signed, and a queued job saves the signed PDF to your storage. Every class is complete and copy-pasteable.
The loop at a glance
The code below comes from the package's examples/ directory. It isn't just illustration:
the package's test suite (SigningLifecycleTest) runs these exact classes end to end against
a fake Acrobat Sign, from the form upload to the PDF landing in storage.
The split matters. Adobe gives a webhook 5 seconds to answer, and a signed PDF with its audit report can take longer than that to download. So the webhook route only acknowledges and fires an event, the listener runs on the queue, and the heavy work happens in a job that can retry on its own schedule.
Before you start
- The package is installed and
php artisan adobe-sign:verifysucceeds (Installation). APP_URLis a public HTTPS address. Locally that means a tunnel; see local development. Adobe only calls HTTPS URLs, andWebhookInforejects anything else before sending.ADOBE_SIGN_WEBHOOK_CLIENT_IDS(orADOBE_SIGN_CLIENT_ID) holds the client id Adobe will send, so the route accepts the callbacks.- A queue connection other than
sync, and a worker running:php artisan queue:work. - A user to sign as. In a developer account, send to addresses you control; Adobe marks the agreements as demo documents.
Step 1: Routes
Your app needs routes for its own controller. The package registers the webhook route
(adobe-sign.webhook) itself, so it doesn't appear here.
use App\Http\Controllers\AgreementController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth'])->group(function () {
Route::post('/agreements', [AgreementController::class, 'store'])->name('agreements.store');
Route::get('/agreements/{agreementId}', [AgreementController::class, 'show'])->name('agreements.show');
Route::get('/agreements/{agreementId}/sign', [AgreementController::class, 'signingUrl'])->name('agreements.sign');
Route::get('/agreements/{agreementId}/pdf', [AgreementController::class, 'download'])->name('agreements.pdf');
});
In a standard app routes/web.php already has the web group; listing it explicitly does no harm and matches the example file.
Step 2: The upload form
A plain multipart form: the PDF, a name, the signers in signing order, and a way to mark a signer as signing at the same step as the one before them.
<form method="POST" action="{{ route('agreements.store') }}" enctype="multipart/form-data">
@csrf
<input type="file" name="contract" accept="application/pdf" required>
<input type="text" name="name" value="Master Services Agreement" required>
{{-- Signing order: one row per signer, top to bottom --}}
<input type="email" name="signers[]" value="jane@client.example.com">
<input type="email" name="signers[]" value="legal@example.com">
<label><input type="checkbox" name="parallel[]" value="legal@example.com"> signs together with the signer above</label>
<input type="email" name="signers[]" value="ceo@example.com">
<button type="submit">Send for signature</button>
</form>
With the box ticked, Jane and legal sign in parallel at step 1 and the CEO signs at step 2.
Step 3: The controller
The controller validates the request, uploads the PDF, builds the signing order, creates the agreement and registers a webhook for it. It receives the client by injection, so it works with whichever auth mode you configured.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Builder\AgreementInfo;
use Conceptlz\AdobeSign\Builder\FileInfo;
use Conceptlz\AdobeSign\Builder\ParticipantSetInfo;
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Exception\NotFoundException;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class AgreementController
{
/**
* POST /agreements: multipart form with `contract` (PDF), `name`, and `signers[]` in signing order.
* Signers listed in `parallel[]` sign at the same step as the signer before them.
*/
public function store(Request $request, AdobeSign $sign): JsonResponse
{
$data = $request->validate([
'contract' => ['required', 'file', 'mimes:pdf', 'max:25600'],
'name' => ['required', 'string', 'max:255'],
'signers' => ['required', 'array', 'min:1'],
'signers.*' => ['required', 'email'],
'parallel' => ['sometimes', 'array'],
'parallel.*' => ['email'],
]);
// 1. Upload the PDF (streamed, never fully loaded into memory).
$file = $request->file('contract');
$documentId = $sign->transientDocuments()->upload(
$file->getRealPath(),
fileName: $file->getClientOriginalName(),
mimeType: 'application/pdf',
);
// 2. Build the signing order: each signer is the next step, unless marked parallel.
// Mixing sequential and parallel steps needs "hybrid routing" enabled on the Acrobat Sign
// account; otherwise Adobe answers 403 HYBRID_ROUTING_NOT_ALLOWED.
$agreement = AgreementInfo::create($data['name'])
->file(FileInfo::transientDocument($documentId))
->message('Please review and sign.');
$parallel = $data['parallel'] ?? [];
$order = 0;
foreach ($data['signers'] as $email) {
$order = in_array($email, $parallel, true) && $order > 0 ? $order : $order + 1;
$agreement->participantSet(ParticipantSetInfo::signer($email)->order($order));
}
// 3. Create it in IN_PROCESS state (the default) = sent to the first signer(s) now.
$agreementId = $sign->agreements()->create($agreement);
// 4. Ask Adobe to notify our webhook endpoint about this agreement (one webhook per
// agreement is Adobe's recommendation). The package registers the route.
$sign->webhooks()->create(
WebhookInfo::forAgreement('Agreement ' . $agreementId, route('adobe-sign.webhook'), $agreementId)
->events(EventType::AGREEMENT_ALL),
);
return new JsonResponse(['agreement_id' => $agreementId], 201);
}
/**
* GET /agreements/{agreementId}: current status, e.g. OUT_FOR_SIGNATURE or SIGNED.
*/
public function show(string $agreementId, AdobeSign $sign): JsonResponse
{
$agreement = $sign->agreements()->get($agreementId);
return new JsonResponse([
'id' => $agreement->id,
'name' => $agreement->name,
'status' => $agreement->status,
]);
}
/**
* GET /agreements/{agreementId}/sign: the signer's e-sign page (embedded signing).
*/
public function signingUrl(Request $request, string $agreementId, AdobeSign $sign): JsonResponse
{
$user = $request->user() ?? abort(401);
try {
$url = $sign->agreements()->signingUrlFor($agreementId, $user->email);
} catch (NotFoundException $e) {
// Right after creation Adobe may still be processing the PDF (AGREEMENT_NOT_SIGNABLE).
return new JsonResponse(['message' => 'Not ready for signing yet, try again shortly.'], 409);
}
return $url === null
? new JsonResponse(['message' => "It isn't your turn to sign."], 403)
: new JsonResponse(['url' => $url]);
}
/**
* GET /agreements/{agreementId}/pdf: stream the current PDF straight from Adobe to the browser.
*/
public function download(string $agreementId, AdobeSign $sign): StreamedResponse
{
$pdf = $sign->agreements()->combinedDocument($agreementId);
return new StreamedResponse(function () use ($pdf) {
while (!$pdf->eof()) {
echo $pdf->read(65536);
}
}, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $agreementId . '.pdf"',
]);
}
}
What each part of store() does
-
Validation before anything leaves your app
mimes:pdfandmax:25600(25 MB) reject bad uploads with a normal 422, and no request reaches Adobe. The package's test suite checks exactly that. -
Upload as a transient document
The upload is streamed through a temporary file, so memory use stays flat for large PDFs. The original file name and MIME type are passed because the temporary path has no extension.
-
Turn the form into a signing order
The loop gives each signer the next order number, unless they appear in
parallel[], in which case they reuse the current number. With the form above the result is Jane 1, legal 1, CEO 2. The builder checks that the numbers run 1..n without gaps before sending anything. -
Create, which also sends
agreements()->create()returns the new agreement id. Its default state,IN_PROCESS, means Adobe emails the first step's signers immediately. The project's signature level fromADOBE_SIGN_SIGNATURE_LEVELis applied here too. -
Register a webhook for this agreement
route('adobe-sign.webhook')builds the public URL of the package's route fromAPP_URLor the current request. Adobe verifies that URL immediately by calling it, and the package answers the handshake, so this call only succeeds when your app is reachable. One webhook per agreement is Adobe's recommendation: if deliveries keep failing, Adobe disables only that webhook.
Keep a record on your side
The example returns the agreement id as JSON. In a real app, store it on your own model (say contracts.adobe_agreement_id) and set ->externalId((string) $contract->id) on the builder, so both systems can find each other. See Create and send an agreement.
Step 4: The webhook route (already done)
You don't write this part. When the last signer finishes, Adobe sends a POST to
/adobe-sign/webhook with an X-AdobeSign-ClientId header and a JSON body whose
event is AGREEMENT_WORKFLOW_COMPLETED. The package's
WebhookController:
- rejects the call with 403 if the client id isn't on your allow-list;
- parses the body into a typed event (
AgreementEventhere), or answers 400 if it can't; - adds the notification id to the cache, and only fires
WebhookReceivedif it wasn't there already; - answers 200 with the client id echoed in a header and the JSON body, which is how Adobe knows the delivery succeeded.
All of that takes milliseconds, well inside Adobe's 5 seconds, provided your listeners are queued. Details on the Webhooks page.
Step 5: The queued listener
The listener decides what each event means for your app. It implements ShouldQueue, so
Laravel serialises the event onto the queue and the webhook request returns straight away.
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Jobs\DownloadSignedAgreement;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
final class ProcessAdobeSignWebhook implements ShouldQueue
{
public function handle(WebhookReceived $received): void
{
$event = $received->event;
if (!$event instanceof AgreementEvent) {
return; // web form, Send in Bulk or library events: handle them here if you subscribe to them
}
Log::info('Acrobat Sign: ' . $event->event, [
'agreement' => $event->agreementId(),
'by' => $event->actingUserEmail,
]);
match (true) {
// Every participant has acted: fetch and keep the signed PDF.
$event->isWorkflowCompleted() => DownloadSignedAgreement::dispatch((string) $event->agreementId()),
// One signer finished; others may still be pending.
$event->is(EventType::AGREEMENT_ACTION_COMPLETED) => null,
// Declined or cancelled: update your records / notify someone.
$event->is(EventType::AGREEMENT_REJECTED, EventType::AGREEMENT_RECALLED, EventType::AGREEMENT_EXPIRED) => null,
default => null,
};
}
}
Laravel 11 and later discover listeners in app/Listeners from the type-hint on
handle(). If you turned discovery off, register it in a service provider:
use App\Listeners\ProcessAdobeSignWebhook;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Illuminate\Support\Facades\Event;
Event::listen(WebhookReceived::class, ProcessAdobeSignWebhook::class);
AGREEMENT_ALL subscribes to every agreement event, so the listener also sees views,
reminders and individual signatures. The match makes the interesting ones explicit and
ignores the rest. The null arms are where you'd update your own records, e.g. mark the
contract as declined.
Step 6: The download job
The job does the slow, fallible part, with its own retry budget. It is the only place that touches Adobe after signing.
<?php
declare(strict_types=1);
namespace App\Jobs;
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Exception\RateLimitException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Storage;
final class DownloadSignedAgreement implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public int $tries = 5;
public function __construct(public readonly string $agreementId)
{
}
public function handle(AdobeSign $sign): void
{
try {
// Webhook payloads aren't signed, so confirm the status with the API before acting on it.
$agreement = $sign->agreements()->get($this->agreementId);
if (!$agreement->isSigned()) {
return;
}
// A PSR-7 stream: written to storage in chunks, never held in memory as a whole.
$pdf = $sign->agreements()->combinedDocument($this->agreementId, attachAuditReport: true);
} catch (RateLimitException $e) {
$this->release($e->retryAfter ?? 60);
return;
}
$resource = $pdf->detach() ?? throw new \RuntimeException('Download stream is not readable.');
Storage::disk(config('filesystems.default'))->writeStream("agreements/{$this->agreementId}.pdf", $resource);
}
}
Why the job is written this way
- It re-checks the status. Adobe doesn't sign webhook payloads. The client-id allow-list stops casual forgeries, but the safe pattern is to treat the webhook as a hint and ask the API for the truth. Only a real
SIGNEDstatus leads to a download. - It asks for the audit report.
attachAuditReport: trueappends Adobe's audit trail (who signed, when, from which IP, with which verification) to the PDF. Keep it with the document: it is what you show when a signature is challenged. - It streams.
combinedDocument()returns a PSR-7 stream;detach()hands its underlying PHP resource toStorage::writeStream(), which copies it to local disk, S3 or any other driver in chunks. - It cooperates with throttling. Short
Retry-Afterwaits are retried inside the SDK. A long one surfaces asRateLimitException, and the job puts itself back on the queue for that many seconds instead of burning a try. - It injects the default client. That is right for an Integration Key. With OAuth there is no signed-in user in a worker, so pass the user key into the job and use
AdobeSignManager::forUser(); see queued jobs.
Step 7: Try it
-
Start a worker
php artisan queue:work -
Submit the form
Signed in to your app, upload a PDF with two or three addresses you control. The response contains the agreement id, and the first signers receive an email from Acrobat Sign.
-
Watch progress
Open
/agreements/{id}to see the status move fromOUT_FOR_SIGNATUREtoSIGNED. Each signature also produces anAGREEMENT_ACTION_COMPLETEDline in your log from the listener. -
Find the signed PDF
After the last signature, the worker runs the listener, then the job, and
storage/app/agreements/{id}.pdf(or the same path on your default disk) appears, with the audit report on its last pages.
Step 8: Test it without Adobe
The package's own SigningLifecycleTest runs this whole loop with a fake HTTP client and the
sync queue: it posts the form, checks the upload body, the signing order and the webhook
URL, posts Adobe's completion webhook to the route, and asserts the PDF is in the faked disk. The
Testing page shows how to write the same test in your
app.
Hardening for production
- Idempotency. The package drops duplicate deliveries for 72 hours, but a job can still run twice (a retry after a timeout, a manual replay). Writing the same file twice is harmless here; if the job also sends emails or bills, check your own record first or use
ShouldBeUniquekeyed on the agreement id. - Failure handling. Add a
failed()method to the job that flags the contract for attention. After 72 hours of failed webhook deliveries Adobe disables the webhook, so a scheduled sweep that checks agreements still marked "sent" is a cheap safety net. - Timeouts. Large agreements with many pages can take a while to download. Give the job a
$timeoutthat fits, and make sure your HTTP client timeout (see the HTTP client) is shorter than the job timeout. - Storage layout. Store under a path you control (
agreements/{year}/{id}.pdf) and keep the agreement id on your model. Adobe also keeps the document, but your retention rules may differ from theirs. - Signature level. If the project requires AES or QES, check it in the job before marking the contract complete:
AdobeSign::signatureShortfalls($this->agreementId). See checking after signing.
Common questions
webhooks()->create() fails while creating the agreement works. Why?
Adobe calls your URL to verify it before creating the webhook. It must be public HTTPS, reach the package route without auth or CSRF middleware, and your allow-list must contain the client id Adobe sends. Locally you need a tunnel. See Troubleshooting.
Everyone signed, but no PDF appeared in storage. Where do I look?
Check, in order: that a queue worker is running (the listener and job are both queued), the failed_jobs table, your log for "Rejected Acrobat Sign webhook" warnings, and the webhook's status in Acrobat Sign. More in Troubleshooting.
Can I use one account-wide webhook instead of one per agreement?
Yes: create it once with WebhookInfo::forAccount('All agreements', route('adobe-sign.webhook')) (an admin task) and drop step 4 of the controller. Adobe recommends per-agreement webhooks because a disabled webhook then affects one agreement, not all of them.
How does this change if users send from their own accounts via OAuth?
In the controller, use AdobeSign::forUser($request->user()) instead of the injected client. In the listener, look up which user owns the agreement (from your own records) and pass that user's key into the job, which calls forUser($key). The webhook route itself needs no change. See OAuth.
Can I store the audit report as a separate file?
Yes. Call combinedDocument($id) without attachAuditReport for the signed PDF and auditTrail($id) for the report, and write each stream to its own path. The core's downloads page lists the other formats.