Guides
Webhooks
Acrobat Sign tells your app when people view, sign, decline or complete an agreement by calling a webhook. The bundle gives you a ready-made endpoint that satisfies Adobe's rules and turns each delivery into a Symfony event (and, if you like, a Messenger message), so all you write is a listener or a handler.
Adobe's rules, and why the route exists
Acrobat Sign webhooks come with a contract that is easy to break by accident:
- Verification. When you create a webhook, Adobe immediately sends a
GETwith anX-AdobeSign-ClientIdheader. Unless you answer 2xx and echo that value back (in the same header, or asxAdobeSignClientIdin a JSON body), the webhook isn't created. - Every notification needs the echo too, within 5 seconds. Without it, Adobe retries with growing intervals for up to 72 hours and then disables the webhook. Notifications for a disabled webhook are not delivered.
- Retries mean duplicates. A slow answer can count as a failure even though you processed it, so the same notification can arrive again.
- No signature. Payloads aren't signed. The documented check is that the client id belongs to your application; anything important should be confirmed through the API.
The bundle's WebhookController handles all four. Your code receives a typed, de-duplicated event,
and Adobe gets its answer in milliseconds.
The route
| Name | Methods | Path | Controller |
|---|---|---|---|
adobe_sign_webhook | GET, POST | /adobe-sign/webhook | Conceptlz\AdobeSign\Symfony\Controller\WebhookController (invokable) |
adobe_sign_webhook:
resource: '@AdobeSignBundle/config/routes/webhook.php'
# prefix: /integrations # optional; the URL you register at Adobe changes with it
The route is declared stateless: Adobe sends no cookies, so nothing should start a session for it. In debug mode Symfony throws if something does, which surfaces misconfigured firewalls early. It also has no CSRF protection (Adobe can't send a token) and needs no authentication.
Firewalls and access_control
Adobe can't log in. If a firewall or access_control rule requires a user for the webhook path,
Adobe gets a 302 to your login page or a 401, the verification fails, and existing webhooks are eventually
disabled. The clearest setup is a firewall of its own, listed before main:
security:
firewalls:
adobe_sign_webhook:
pattern: ^/adobe-sign/webhook$
security: false # no authentication, no session, no access_control
main:
lazy: true
# ...
access_control:
- { path: ^/adobe-sign/oauth, roles: ROLE_USER } # the OAuth routes, not the webhook
- { path: ^/admin, roles: ROLE_ADMIN }
If you prefer to keep everything under main, add a public rule above any catch-all:
- { path: ^/adobe-sign/webhook, roles: PUBLIC_ACCESS }. Then make sure main is
lazy, so it doesn't load the session for this stateless route.
What happens on each call
| Situation | Response | Logged on adobe_sign | Dispatched |
|---|---|---|---|
| Header missing or id not allowed | 403, empty body, no echo | warning "Rejected Acrobat Sign webhook" with reason and IP | nothing |
| No allow-list configured at all | 500 (MisconfiguredException) | by your error handler | nothing |
Verification GET | 200, echo in header and JSON body | no | nothing |
POST body not a JSON object with an event | 400 | error "Unreadable Acrobat Sign webhook" | nothing; Adobe retries |
POST, notification id seen within dedupe_ttl | 200 with echo | no | nothing |
POST, new notification (or no id) | 200 with echo | no | WebhookReceivedEvent, then AdobeSignWebhookMessage if enabled |
The client-id allow-list
Each delivery carries the client id of the API application whose request created the webhook. The route
accepts only ids in webhooks.client_ids, compared in constant time:
adobe_sign:
webhooks:
client_ids:
- '%env(ADOBE_SIGN_CLIENT_ID)%'
- 'CBJCHBCAABAAstagingapp' # e.g. a second application during a migration
When the list is empty it falls back to oauth.client_id, which is right for apps that create webhooks
through their own OAuth application. If neither is set, the route can't decide whom to trust and answers 500
with MisconfiguredException. An explicit list replaces the fallback, so include the OAuth
client id in it if you need both.
The warning for a rejected request logs the reason and IP, not the id. If you don't know which id Adobe sends (for example for webhooks created with an Integration Key), log it once with a temporary listener, put it in the configuration, and remove the listener:
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
#[AsEventListener(priority: 0)]
final class LogAdobeSignClientId
{
public function __construct(private readonly LoggerInterface $logger) {}
public function __invoke(RequestEvent $event): void
{
if ($event->getRequest()->attributes->get('_route') === 'adobe_sign_webhook') {
$this->logger->info('Acrobat Sign webhook client id', [
'id' => $event->getRequest()->headers->get('X-AdobeSign-ClientId'),
]);
}
}
}
Priority 0 runs after Symfony's router listener (priority 32), so _route is already set. The client id isn't a
secret in the way a key is, but it is the only authenticity check Adobe offers. That is why your handlers
should confirm anything important through the API rather than trusting the payload.
Duplicate deliveries
Every notification has a webhookNotificationId. Before dispatching, the controller looks up
adobe_sign.webhook. plus the SHA-256 of that id in cache_pool. If the item exists, the
delivery is a retry: it is acknowledged with 200 (so Adobe stops) and nothing is dispatched. Otherwise the item
is saved for webhooks.dedupe_ttl seconds (default 259 200, Adobe's 72-hour retry window) and the
notification is dispatched. Notifications without an id always pass.
- Share the pool between servers. With
cache.appon each machine's disk, a retry that lands on another server isn't recognised. Use Redis, Memcached or a database pool forcache_pool. - Clearing the pool is safe but forgetful. It also clears the data-centre lookup (rediscovered on the next call) and lets duplicates through for a while.
- Deduplication is best effort. Two deliveries of the same notification arriving at the same instant on two servers can both pass the lookup. Keep your handlers idempotent anyway.
If a listener or the bus throws
A failed delivery is retried, not lost
The notification id is recorded before WebhookReceivedEvent and the Messenger message are dispatched, and released again if either throws: a synchronous listener fails, or the bus can't accept the message (no handler and no routing, a transport that is down). The request then answers 500 and Adobe's retry is processed as a fresh delivery. Two consequences: a listener that partly succeeded runs again on the retry, so keep listeners idempotent; and a failure that never clears makes Adobe give up after 72 hours, so fix it promptly (see Messenger for the "no handler" case). A scheduled re-check of open agreements covers long outages.
The WebhookReceivedEvent
Conceptlz\AdobeSign\Symfony\Event\WebhookReceivedEvent has one readonly property,
$event, holding the core's typed WebhookEvent. Its class tells you the resource:
| Class | For events starting with | Id helper |
|---|---|---|
Conceptlz\AdobeSign\Webhook\AgreementEvent | AGREEMENT_ | agreementId(), plus isWorkflowCompleted() and agreement() |
MegaSignEvent | MEGASIGN_ | megaSignId() |
WidgetEvent | WIDGET_ | widgetId() |
LibraryDocumentEvent | LIBRARY_DOCUMENT_ | libraryDocumentId() |
UnknownEvent | anything else | resourceId() |
Every WebhookEvent has readonly properties for the common fields ($event,
$subEvent, $notificationId, $eventDate, $participantUserEmail,
$actingUserEmail, $actingUserIpAddress, $webhookId, …), the full decoded
payload in $raw, and the helpers is(string ...$events), resource(),
resourceId(), resourceName() and resourceStatus(). Event names are constants on
Conceptlz\AdobeSign\Webhook\EventType.
Writing listeners
use App\Repository\ContractRepository;
use Conceptlz\AdobeSign\Symfony\Event\WebhookReceivedEvent;
use Conceptlz\AdobeSign\Webhook\{AgreementEvent, EventType};
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
final class AgreementActivityListener
{
public function __construct(
private readonly ContractRepository $contracts,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(WebhookReceivedEvent $received): void
{
$event = $received->event;
if (!$event instanceof AgreementEvent
|| !$event->is(EventType::AGREEMENT_DOCUMENTS_VIEWED, EventType::AGREEMENT_ACTION_COMPLETED)) {
return;
}
try {
// Quick, local work only: one indexed lookup, one write.
$contract = $this->contracts->findOneBy(['agreementId' => $event->agreementId()]);
$contract?->recordActivity($event->event, $event->participantUserEmail, $event->eventDate);
$this->em->flush();
} catch (\Throwable $e) {
// Throwing would answer 500 and make Adobe retry the whole notification; for
// non-essential bookkeeping, logging is the better choice.
$this->logger->error('Could not record Acrobat Sign activity', ['exception' => $e]);
}
}
}
Listeners run synchronously, in the webhook request, in priority order like any Symfony listener. Several listeners can handle the same event. For anything that calls Adobe or touches files, use a Messenger handler instead; that is what Async with Messenger is for.
The 5-second budget
Everything between Adobe's request and your response counts: kernel boot, the firewall, the cache lookup, your listeners, dispatching the message. The bundle's own part takes milliseconds. Budget your listeners accordingly:
- Fine in a listener: a database update, a cache write, dispatching your own message.
- Not fine: downloading the signed PDF, calling Adobe's API, sending email synchronously, calling a slow third party. A cold cache after deploy can make the first requests slower than you expect.
Registering webhooks with Adobe
The route only receives. You tell Adobe about it with the core's WebhookInfo builder and
webhooks()->create(). Adobe verifies the URL during that call, so the route must already be
reachable from the internet over HTTPS.
One webhook per agreement (recommended)
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Webhook\EventType;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
$url = $urlGenerator->generate('adobe_sign_webhook', [], UrlGeneratorInterface::ABSOLUTE_URL);
$client->webhooks()->create(
WebhookInfo::forAgreement("Agreement $agreementId", $url, $agreementId)
->events(EventType::AGREEMENT_ALL)
->agreementPayload(detailed: true, participants: true),
);
Created right after the agreement, it delivers only that agreement's events. If deliveries fail for long
enough that Adobe disables it, only that agreement is affected. agreementPayload() controls how much
detail the notification carries; without it, Adobe sends its default payload for the event.
One webhook for the whole account
$client->webhooks()->create(
WebhookInfo::forAccount('All agreements', $url)
->events(EventType::AGREEMENT_WORKFLOW_COMPLETED, EventType::AGREEMENT_REJECTED)
->notifyOnProblems('ops@example.com'),
);
Created once (from a console command or a migration), by an account administrator. Simpler to manage, but a
disabled account webhook silences every agreement. forGroup() and forUser() sit in between.
notifyOnProblems() asks Adobe to email those users when deliveries start failing.
Listing, re-enabling and deleting
foreach ($client->webhooks()->all(showInActiveWebhooks: true) as $webhook) {
// $webhook->getId(), getName(), getStatus(), getScope(), ...
}
$client->webhooks()->updateState($webhookId, 'ACTIVE'); // re-enable after an outage
$client->webhooks()->delete($webhookId);
HTTPS and the generated URL
- HTTPS only. The builder throws
InvalidArgumentException("Acrobat Sign only delivers webhooks to HTTPS URLs.") before any request if the URL starts withhttp://. - Behind a proxy or load balancer, configure
framework.trusted_proxiesandtrusted_headerssogenerate(..., ABSOLUTE_URL)seeshttpsand the public host. - From the console or a worker, there is no request; the router uses
framework.router.default_uri(for examplehttps://app.example.com). Set it, or the URL becomeshttp://localhost/adobe-sign/webhook.
Local development with a tunnel
Adobe must reach your machine from the internet. Run a tunnel (any HTTPS tunnelling tool works) that forwards a public HTTPS address to your local server, then:
- Make Symfony generate the public URL
Open the app through the tunnel's address, or set
framework.router.default_urito it inconfig/packages/dev/. Trust the tunnel's forwarding headers withframework.trusted_proxiesif it terminates TLS. - Allow your client id
Put the id in
webhooks.client_ids(oroauth.client_id) in.env.local. - Register a webhook pointing at the tunnel
Create an agreement and its webhook as usual. Watch the
adobe_signlog and your web server log for the verificationGET. - Clean up
Tunnel addresses change. Delete webhooks that point at old tunnels, or Adobe keeps retrying them and eventually emails your problem contacts.
Simulating a delivery without Adobe
# Verification
curl -i https://localhost:8000/adobe-sign/webhook -H 'X-AdobeSign-ClientId: CBJCHBCAABAAyourclientid'
# A notification (give each test a new webhookNotificationId, or it is dropped as a duplicate)
curl -i https://localhost:8000/adobe-sign/webhook \
-H 'X-AdobeSign-ClientId: CBJCHBCAABAAyourclientid' -H 'Content-Type: application/json' \
-d '{"event":"AGREEMENT_WORKFLOW_COMPLETED","webhookNotificationId":"local-1","agreement":{"id":"CBJCHBCAABAA...","status":"SIGNED"}}'
Expect HTTP/1.1 200 with X-AdobeSign-ClientId in the response and
{"xAdobeSignClientId":"..."} as the body. Automated versions of these checks are in
Testing webhooks.
Deploys and downtime
Adobe retries failed deliveries for up to 72 hours, so a few minutes of downtime during a deploy loses nothing:
notifications arrive again when you're back. What does lose notifications is a webhook that Adobe disabled
after a long outage, or a deploy that leaves the route behind a login. After an incident, list webhooks with
showInActiveWebhooks: true and re-enable the ones you need.
Common questions
Can I change the webhook path?
Yes: add prefix: to the route import, or skip the import and define your own route pointing at Conceptlz\AdobeSign\Symfony\Controller\WebhookController. Webhooks already registered at Adobe keep the old URL; create new ones.
Can I verify that a payload really comes from Adobe?
Not cryptographically: Adobe doesn't sign webhook payloads. The client-id check is the documented control. Treat the payload as a hint, confirm important facts through the API, and, if you want a second layer, restrict the path at your proxy to the IP ranges Adobe documents for Acrobat Sign.
Two Symfony apps share one Acrobat Sign account. Do they see each other's webhooks?
Only if their webhooks overlap. Per-agreement webhooks point at the app that created them. With account-wide webhooks, each app receives everything and should ignore agreements it doesn't know, which the walkthrough's handler does.
Do notifications arrive in order?
Not guaranteed, especially with retries. Don't build state machines on arrival order; read the current status through the API when it matters, and compare $eventDate if you store a history.
Should I ask Adobe to include the signed PDF in the payload?
Usually not. agreementPayload(signedDocuments: true) puts the base64 PDF in the notification, which makes requests large and slow inside the 5-second window, and the bundle would carry it into your Messenger message. Download it in a handler instead.