Guides
Walkthrough: send & store
Build the complete loop a contract takes through a Symfony app: a form uploads the PDF, a controller sends it in signing order, Adobe calls your webhook when everyone has signed, and a Messenger handler downloads the signed PDF and stores it. Every class is complete and copy-pasteable, and each step explains why it is written that way.
The loop at a glance
The example is a small contracts app: staff upload a PDF, type the client's email, and the contract goes
out for signature, first by the client and then by your CEO. When both have signed, the signed PDF with
Adobe's audit report lands in var/contracts/ and the contract shows as signed.
Before you start
- The bundle installed with an Integration Key that works:
php bin/console adobe-sign:verifyprints your user. See Installation. - Doctrine ORM (
composer require symfony/orm-pack) and Messenger (composer require symfony/messenger). The examples use the Doctrine transport; Redis or AMQP work the same way. - For trying it locally: a public HTTPS URL that reaches your machine, because Adobe calls your webhook from the internet. See local development with a tunnel.
- Your webhook's client id. With an Integration Key it is the client id of an Acrobat Sign API application in the same account; Adobe sends it in
X-AdobeSign-ClientId. If unsure, log the header once with a temporary listener; see the allow-list.
Step 1: Configuration
Four small files. First the bundle: the key, the webhook allow-list, and Messenger dispatch switched on.
adobe_sign:
integration_key: '%env(ADOBE_SIGN_INTEGRATION_KEY)%'
webhooks:
client_ids: ['%env(ADOBE_SIGN_CLIENT_ID)%']
messenger: true
Then the routes. This app only needs the webhook:
adobe_sign_webhook:
resource: '@AdobeSignBundle/config/routes/webhook.php'
Messenger: an async transport, a failure transport, and the bundle's message routed to it.
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # e.g. doctrine://default
retry_strategy:
max_retries: 5
delay: 10000 # 10 s, then 30 s, 90 s, ...
multiplier: 3
failed: 'doctrine://default?queue_name=failed'
routing:
Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage: async
And security: Adobe sends no cookies and can't log in, so the webhook path must not require a user. A
dedicated firewall with security switched off, listed before main, is the clearest way:
security:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
adobe_sign_webhook:
pattern: ^/adobe-sign/webhook$
security: false
main:
lazy: true
# ... your login configuration
Step 2: The Contract entity
The app's own record of each contract. It stores Adobe's agreement id so the webhook can find the contract again, and a status that only moves forward.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ContractRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ContractRepository::class)]
class Contract
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true, nullable: true)]
private ?string $agreementId = null;
#[ORM\Column(length: 32)]
private string $status = 'draft';
#[ORM\Column(length: 255, nullable: true)]
private ?string $signedPdfPath = null;
public function __construct(
#[ORM\Column(length: 255)] private string $title,
#[ORM\Column(length: 255)] private string $clientEmail,
) {
}
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function getClientEmail(): string { return $this->clientEmail; }
public function getAgreementId(): ?string { return $this->agreementId; }
public function getStatus(): string { return $this->status; }
public function markSent(string $agreementId): void
{
$this->agreementId = $agreementId;
$this->status = 'sent';
}
public function markSigned(string $pdfPath): void
{
$this->signedPdfPath = $pdfPath;
$this->status = 'signed';
}
public function markClosed(string $adobeStatus): void
{
$this->status = strtolower($adobeStatus); // "cancelled", "expired"
}
public function isFinal(): bool
{
return in_array($this->status, ['signed', 'cancelled', 'expired'], true);
}
}
php bin/console make:migration && php bin/console doctrine:migrations:migrate
Step 3: The controller
A minimal form posts the PDF and the client's email:
<form method="post" enctype="multipart/form-data" action="{{ path('contract_send') }}">
<input type="text" name="title" required placeholder="Master Services Agreement">
<input type="email" name="client_email" required placeholder="client@example.com">
<input type="file" name="contract" accept="application/pdf" required>
<input type="hidden" name="_token" value="{{ csrf_token('send-contract') }}">
<button>Send for signature</button>
</form>
The controller does three calls to Acrobat Sign and one database write:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Contract;
use Conceptlz\AdobeSign\Builder\{AgreementInfo, FileInfo, ParticipantSetInfo, WebhookInfo};
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Webhook\EventType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ContractController extends AbstractController
{
public function __construct(
private readonly AdobeSignManager $adobeSign,
private readonly EntityManagerInterface $em,
) {
}
#[Route('/contracts/new', name: 'contract_new', methods: ['GET'])]
public function new(): Response
{
return $this->render('contract/new.html.twig');
}
#[Route('/contracts', name: 'contract_send', methods: ['POST'])]
public function send(Request $request): Response
{
if (!$this->isCsrfTokenValid('send-contract', $request->request->getString('_token'))) {
throw $this->createAccessDeniedException();
}
$pdf = $request->files->get('contract');
if (!$pdf instanceof UploadedFile || $pdf->getMimeType() !== 'application/pdf') {
throw new BadRequestHttpException('Upload a PDF.');
}
$contract = new Contract($request->request->getString('title'), $request->request->getString('client_email'));
$client = $this->adobeSign->client();
// 1. Upload the PDF as a transient document (valid for 7 days).
$documentId = $client->transientDocuments()->upload($pdf, $pdf->getClientOriginalName(), 'application/pdf');
// 2. Create the agreement: the client signs first, then the CEO.
$agreementId = $client->agreements()->create(
AgreementInfo::create($contract->getTitle())
->file(FileInfo::transientDocument($documentId))
->participantSet(ParticipantSetInfo::signer($contract->getClientEmail())->order(1))
->participantSet(ParticipantSetInfo::signer('ceo@example.com')->order(2))
->message('Please review and sign.'),
);
// 3. Ask Adobe to call our webhook about this agreement.
$client->webhooks()->create(
WebhookInfo::forAgreement(
"Contract $agreementId",
$this->generateUrl('adobe_sign_webhook', [], UrlGeneratorInterface::ABSOLUTE_URL),
$agreementId,
)->events(
EventType::AGREEMENT_WORKFLOW_COMPLETED,
EventType::AGREEMENT_REJECTED,
EventType::AGREEMENT_RECALLED,
EventType::AGREEMENT_EXPIRED,
),
);
$contract->markSent($agreementId);
$this->em->persist($contract);
$this->em->flush();
$this->addFlash('success', 'Sent for signature.');
return $this->redirectToRoute('contract_new');
}
}
What each part of send() does
- It injects the manager, not the client. With an Integration Key either works; the manager keeps the controller unchanged if you later switch some senders to OAuth (
forUser($this->getUser())). - It names the upload. The
UploadedFile's path is PHP's temporary file; the second argument is the name signers see in their email. - The order is explicit.
order(1)andorder(2)make it a sequence: the CEO is only emailed after the client signs. Two sets with the same order would sign in parallel. - The webhook is per agreement. Adobe recommends it: if deliveries to your URL fail for a long time, Adobe disables the webhook, and that then affects one agreement rather than all. Creating it makes Adobe call your route immediately with a verification
GET, so the route must be reachable at this point. - It subscribes only to final events. This app only acts when signing ends.
EventType::AGREEMENT_ALLwould also deliver views, reminders and each signature, which is fine but more traffic. - It saves after Adobe accepts. If any call throws, nothing is persisted and the exception reaches the user (map it with an exception listener). If the webhook registration fails after the agreement was created, the agreement still exists at Adobe; in production, persist the contract right after step 2 so a retry doesn't send twice.
Step 4: The webhook route (already done)
You wrote no code for this step: the bundle's WebhookController sits behind
/adobe-sign/webhook. On the verification GET it checks the client id and echoes it. On
each notification POST it checks the client id, parses the body, drops it if the
webhookNotificationId was already seen within dedupe_ttl, dispatches a
WebhookReceivedEvent and, because you enabled Messenger, an AdobeSignWebhookMessage.
Then it answers 200 with the echo. The whole thing takes milliseconds, well inside Adobe's 5-second limit.
Webhooks covers every branch.
Step 5: Route the message to the worker
Already done in Step 1's messenger.yaml. It matters more than it looks: without the
routing line, Messenger handles the message synchronously, inside the webhook request,
and the download would run within Adobe's 5-second window. Check the routing with:
php bin/console debug:messenger
Step 6: The Messenger handler
The handler does the slow, fallible part, in a worker, with its own retry budget.
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Contract\ContractStorage;
use App\Repository\ContractRepository;
use Conceptlz\AdobeSign\Exception\RateLimitException;
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
#[AsMessageHandler]
final class StoreSignedAgreement
{
public function __construct(
private readonly AdobeSignManager $adobeSign,
private readonly ContractRepository $contracts,
private readonly ContractStorage $storage,
private readonly EntityManagerInterface $em,
private readonly MessageBusInterface $bus,
) {
}
public function __invoke(AdobeSignWebhookMessage $message): void
{
$event = $message->event();
$agreementId = $event instanceof AgreementEvent ? $event->agreementId() : null;
if ($agreementId === null) {
return; // not about an agreement
}
$contract = $this->contracts->findOneBy(['agreementId' => $agreementId]);
if ($contract === null || $contract->isFinal()) {
return; // not ours, or already handled
}
$client = $this->adobeSign->client();
try {
// Webhook payloads aren't signed: ask the API for the truth before acting.
$agreement = $client->agreements()->get($agreementId);
if ($agreement->isSigned()) {
$pdf = $client->agreements()->combinedDocument($agreementId, attachAuditReport: true);
$contract->markSigned($this->storage->save($agreementId, $pdf));
} elseif (in_array($agreement->status, ['CANCELLED', 'EXPIRED'], true)) {
$contract->markClosed($agreement->status);
} else {
return; // still in progress
}
} catch (RateLimitException $e) {
// Adobe asked for a long pause: come back later instead of blocking the worker.
$this->bus->dispatch($message, [new DelayStamp(($e->retryAfter ?? 60) * 1000)]);
return;
}
$this->em->flush();
}
}
Why the handler 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. Only a real
SIGNEDstatus leads to a download. - It is idempotent. The bundle drops duplicate deliveries, but Messenger can still run a handler twice (a retry after a crash, a replay from the failure transport).
isFinal()turns the second run into a no-op. - 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 that the storage service copies to disk in chunks. - It cooperates with throttling. Short
Retry-Afterwaits are retried inside the SDK (up toretries.max_delay_seconds). A longer one surfaces asRateLimitException, and the handler re-queues the message with aDelayStamprather than burning a Messenger retry. Every other exception goes to Messenger's retry strategy and finally to thefailedtransport. - It injects the manager. The worker is long-running;
client()per message picks up the fresh client after eachkernel.reset. With OAuth there is no signed-in user in a worker: store the sender's key on the contract and callforUser($contract->getSenderKey()).
Step 7: The storage service
<?php
declare(strict_types=1);
namespace App\Contract;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
final class ContractStorage
{
public function __construct(
#[Autowire('%kernel.project_dir%/var/contracts')] private readonly string $directory,
private readonly Filesystem $filesystem = new Filesystem(),
) {
}
/** Copies the stream to disk and returns the path. */
public function save(string $agreementId, StreamInterface $pdf): string
{
$path = sprintf('%s/%s/%s.pdf', $this->directory, date('Y'), $agreementId);
$this->filesystem->mkdir(\dirname($path));
$in = $pdf->detach() ?? throw new \RuntimeException('Download stream is not readable.');
$out = fopen($path.'.part', 'wb') ?: throw new \RuntimeException("Cannot write $path.");
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
// Rename last: a half-written file never looks like a finished contract.
$this->filesystem->rename($path.'.part', $path, true);
return $path;
}
}
On several servers, write to shared storage instead. With Flysystem (league/flysystem-bundle) the
body of save() becomes $this->storage->writeStream($key, $pdf->detach()), and S3,
Azure or GCS work the same way.
Step 8: Try it
- Start the app and a tunnel
Run
symfony server:start, then expose it over HTTPS with a tunnel and open the app through the tunnel's URL, sogenerateUrl(..., ABSOLUTE_URL)produces a URL Adobe can reach. If your tunnel setsX-Forwarded-*headers, configureframework.trusted_proxiesso the URL ishttps://. - Start a worker
php bin/console messenger:consume async -vv-vvprints every message as it is received and handled. - Send a contract
Open
/contracts/new, use addresses you control (they receive real emails), and submit. Adobe calls the webhook once to verify it; theadobe_signlog stays quiet if the client id matched. - Sign as both signers
Sign from the first email, then the second. After the last signature Adobe posts
AGREEMENT_WORKFLOW_COMPLETED; the worker prints the handled message, and the PDF appears invar/contracts/.
Step 9: Test it without Adobe
Two tests cover the loop. The first drives the real webhook route and checks that a message was queued; the
second runs the handler against a mock PSR-18 client. Configure the mock as shown in
Testing, and make the async transport in-memory:// in the test environment.
use Conceptlz\AdobeSign\Symfony\Message\AdobeSignWebhookMessage;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
final class ContractWebhookTest extends WebTestCase
{
public function test_completed_agreement_is_queued_once(): void
{
$client = static::createClient();
$client->disableReboot();
$payload = (string) file_get_contents(__DIR__.'/../Fixtures/workflow_completed.json');
$client->request('POST', '/adobe-sign/webhook', server: [
'HTTP_X_ADOBESIGN_CLIENTID' => 'test-client-id',
'CONTENT_TYPE' => 'application/json',
], content: $payload);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('X-AdobeSign-ClientId', 'test-client-id');
$transport = static::getContainer()->get('messenger.transport.async');
self::assertInstanceOf(InMemoryTransport::class, $transport);
self::assertCount(1, $transport->getSent());
self::assertInstanceOf(AdobeSignWebhookMessage::class, $transport->getSent()[0]->getMessage());
}
}
The handler test queues Adobe's answers and checks the result. The full pattern, including the fixture and the mock setup, is in testing your own handlers.
Hardening for production
- Shared cache. Duplicate detection lives in
cache_pool. With several web servers, point it at Redis or another shared pool, or a retry landing on a second server is processed again. See cache configuration. - Workers under supervision. Run
messenger:consume async --time-limit=3600 --memory-limit=256Munder systemd or Supervisor, andmessenger:stop-workerson deploy. See running workers. - Failure handling. Watch the
failedtransport (messenger:failed:show) and alert on it. After 72 hours of failed deliveries Adobe disables a webhook, so a scheduled command that re-checks contracts still marked "sent" after a few days is a cheap safety net. - Timeouts. Large agreements take time to download. Make sure
framework.http_client'smax_durationfits, and that the worker's time limit is longer. - Send once. Persist the contract with its agreement id before registering the webhook, so a retry of a failed request doesn't create a second agreement. Use
externalId()on the builder to find agreements Adobe created but you didn't record. - Signature level. If the project requires AES or QES, check it in the handler before marking the contract signed:
$this->adobeSign->signatureShortfalls($agreementId). See checking after signing.
Common questions
webhooks()->create() fails while creating the agreement works. Why?
Adobe calls your URL with a GET before creating the webhook. It must be public HTTPS, not behind a firewall that requires login, and the client id Adobe sends must be in your allow-list. Locally you need a tunnel. See Troubleshooting.
Everyone signed, but no PDF appeared. Where do I look?
In order: is a worker consuming async? Is the message routed (debug:messenger)? Anything in messenger:failed:show? Any "Rejected Acrobat Sign webhook" warning in the adobe_sign log? Is the webhook still active in Acrobat Sign? See Troubleshooting.
Can I use one account-wide webhook instead of one per agreement?
Yes: create it once with WebhookInfo::forAccount('All agreements', $url) (an account-admin task) and drop step 3 of the controller. The handler already ignores agreements it doesn't know. Adobe recommends per-agreement webhooks because a disabled webhook then affects one agreement.
How does this change if users send from their own accounts via OAuth?
In the controller use $this->adobeSign->forUser($this->getUser()) and store $this->adobeSign->userKey($this->getUser()) on the contract. In the handler, call forUser() with that stored key. The webhook route doesn't 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 save each stream separately. The core's downloads page lists the other formats.