Guides
Webhooks
Webhooks let Acrobat Sign tell your application when something happens (an agreement is sent, viewed, signed, declined or completed) instead of you asking over and over. This page covers subscribing, answering Adobe's verification handshake, turning notifications into typed events, and running the whole thing reliably in production.
How it works
A webhook is a subscription: "send these events about this resource to this HTTPS URL". When you
create one, Adobe immediately calls the URL to check it belongs to you. After that, every matching
event arrives as a POST, and each one must be acknowledged within five seconds.
Subscribing
Conceptlz\AdobeSign\Builder\WebhookInfo builds the subscription, and
webhooks()->create() sends it and returns the new webhook's id:
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Webhook\EventType;
$webhookId = $sign->webhooks()->create(
WebhookInfo::forAgreement('Contract sync', 'https://app.example.com/adobe-sign/webhook', $agreementId)
->events(EventType::AGREEMENT_ALL)
->agreementPayload(participants: true),
);
webhooks()->create(WebhookInfo|array $info): stringPOST /webhooks
Accepts the builder, the generated WebhookInfo model or a raw array. Adobe first sends
the verification GET to the URL; if your endpoint doesn't answer it correctly, creation
fails. So deploy the receiver before you subscribe.
Scopes
| Builder | Scope | Receives events for |
|---|---|---|
WebhookInfo::forAgreement($name, $url, $agreementId) | RESOURCE | one agreement |
WebhookInfo::forResource($name, $url, $resourceType, $resourceId) | RESOURCE | one AGREEMENT, WIDGET, MEGASIGN or LIBRARY_DOCUMENT |
WebhookInfo::forUser($name, $url) | USER | everything of the calling user |
WebhookInfo::forGroup($name, $url) | GROUP | everything in the user's group |
WebhookInfo::forAccount($name, $url) | ACCOUNT | the whole account (admin) |
Adobe recommends one resource-scoped webhook per agreement over one account-wide
webhook. Delivery problems are tracked per webhook: with one per agreement, a failure only stalls that
agreement's retry clock, and a webhook that Adobe disables takes one agreement's notifications with it,
not the whole account's. The cost is one extra API call per agreement, made right after
create().
The builder validates locally and throws InvalidArgumentException for a blank name, a URL
that isn't https:// (Adobe only delivers to HTTPS), a blank resource id, or (when the
request is built) a subscription without any event.
Choosing events
events() takes event names as strings; Conceptlz\AdobeSign\Webhook\EventType
has a constant for each one Adobe documents. Calls add up, and duplicates are ignored.
->events(EventType::AGREEMENT_ALL) // everything about agreements
->events( // or just what you act on
EventType::AGREEMENT_ACTION_COMPLETED, // someone signed or approved
EventType::AGREEMENT_WORKFLOW_COMPLETED, // everyone is done: download now
EventType::AGREEMENT_REJECTED, // someone declined
EventType::AGREEMENT_EXPIRED,
EventType::AGREEMENT_RECALLED, // cancelled by the sender
EventType::AGREEMENT_EMAIL_BOUNCED, // fix the address
)
There are _ALL constants for each family (AGREEMENT_ALL,
MEGASIGN_ALL, WIDGET_ALL, LIBRARY_DOCUMENT_ALL). They are
plain strings rather than an enum on purpose: Adobe adds events without a version change, and an
unknown name must still pass through.
What the payload contains
By default an agreement notification carries little more than the event and the agreement's id and
status. agreementPayload() asks for more (Adobe calls these conditional parameters):
| Argument | Adds |
|---|---|
detailed: true | the agreement's detailed information |
participants: true | participant sets and their status |
documents: true | information about the documents |
signedDocuments: true | the signed PDF, base64-encoded, once signing completes |
Bigger payloads are convenient but remember they are unsigned: use them as hints and fetch anything important through the API.
Being told about problems
WebhookInfo::forAccount('Account sync', 'https://app.example.com/adobe-sign/webhook')
->events(EventType::AGREEMENT_ALL)
->notifyOnProblems('ops@example.com', 'integrations@example.com');
Adobe emails these account users when deliveries start failing or the webhook is disabled. Only admins
can set it. For fields the builder doesn't model, with() merges raw WebhookInfo
fields into the request.
Managing subscriptions
// List (generator; pages fetched lazily). Filters: scope, resourceType, showInActiveWebhooks
foreach ($sign->webhooks()->all(scope: 'RESOURCE', showInActiveWebhooks: true) as $webhook) {
echo $webhook->getId(), ' ', $webhook->getName(), ' ', $webhook->getStatus(), "\n";
}
$webhook = $sign->webhooks()->get($webhookId); // getStatus(): ACTIVE or INACTIVE
$sign->webhooks()->updateState($webhookId, 'ACTIVE');
$sign->webhooks()->delete($webhookId); // after get(), If-Match is sent automatically
update() replaces a webhook (fetch with get() first so If-Match
is sent), and updateState() switches it between ACTIVE and
INACTIVE. Delete per-agreement webhooks once the agreement is finished, so they don't pile
up.
Receiving notifications
Conceptlz\AdobeSign\Webhook\WebhookHandler implements Adobe's rules. It needs the client
ids you accept, verifies each request against them, parses notifications and builds the
acknowledgement. It returns the event to you; dispatching is your code's job.
The handshake
- Every request carries a client id
Both the verification
GETsent when the webhook is created and every notificationPOSTcarry anX-AdobeSign-ClientIdheader: the client id of the application that created the webhook. - You echo it back
A delivery counts only if you answer with a 2xx status and return the same id, either in an
X-AdobeSign-ClientIdresponse header or as{"xAdobeSignClientId": "…"}in a JSON body. The handler does both. - Within five seconds
Answer too slowly, or without the echo, and Adobe treats the delivery as failed and retries.
With PSR-7 (Slim, Mezzio, Symfony's PSR bridge, …)
WebhookHandler::handle(ServerRequestInterface $request, ResponseFactoryInterface $responses, StreamFactoryInterface $streams): WebhookResultGET / POST your endpoint
Verifies, parses and builds the response in one call, and never throws for a bad request. A
rejected request comes back with $result->error set and a 4xx response: 405 for a
method other than GET or POST, 403 for a missing or unknown client id
(deliberately without the echo), and 400 for a body that isn't a JSON object with an
event, which makes Adobe retry rather than lose data you couldn't read.
use Conceptlz\AdobeSign\Webhook\WebhookHandler;
$handler = new WebhookHandler([getenv('ADOBE_SIGN_CLIENT_ID')]);
$result = $handler->handle($request, $responseFactory, $streamFactory);
if (!$result->isAccepted()) {
$logger->warning('Rejected Adobe Sign webhook: ' . $result->error->getMessage());
} elseif ($result->event !== null) { // null for the verification GET
$queue->push('adobe-sign-webhook', $result->event->raw);
}
return $result->response; // 200 + echo, or the 4xx
WebhookResult | Meaning |
|---|---|
$response | The PSR-7 response to send back as-is; it carries the echo. |
$event | The parsed WebhookEvent, or null for the verification GET and for rejected requests. |
$isVerification | True for Adobe's verification GET. |
$error | A WebhookException explaining a rejection, or null. |
isAccepted() | True when $error is null. |
In plain PHP
Without PSR-7, use the three building blocks directly:
use Conceptlz\AdobeSign\Exception\WebhookException;
use Conceptlz\AdobeSign\Webhook\WebhookHandler;
$handler = new WebhookHandler([getenv('ADOBE_SIGN_CLIENT_ID')]);
try {
$clientId = $handler->verifyClientId($_SERVER['HTTP_X_ADOBESIGN_CLIENTID'] ?? null);
$event = $_SERVER['REQUEST_METHOD'] === 'POST'
? $handler->parse((string) file_get_contents('php://input'))
: null;
} catch (WebhookException $e) {
http_response_code(isset($clientId) ? 400 : 403);
exit;
}
foreach ($handler->acknowledgementHeaders($clientId) as $name => $value) {
header("$name: $value"); // X-AdobeSign-ClientId and Content-Type: application/json
}
echo $handler->acknowledgementBody($clientId); // {"xAdobeSignClientId":"..."}
if ($event !== null) {
$queue->push('adobe-sign-webhook', $event->raw);
}
verifyClientId() compares in constant time and throws WebhookException for a
missing or unknown id; parse() throws it for a body that isn't a JSON object with an
event. The constants WebhookHandler::CLIENT_ID_HEADER and
WebhookHandler::CLIENT_ID_BODY_KEY hold the header and body key names. A complete version
is in examples/webhook-receiver.php; the Laravel bridge ships a ready-made route.
Which client id to allow
The constructor takes a list, and refuses an empty one (InvalidArgumentException),
because the client id is the only authenticity check Adobe's webhooks offer. For an OAuth integration it
is your application's client id from the Acrobat Sign API application settings. If several applications
create webhooks to the same URL, list them all. If you're not sure what Adobe sends, have the endpoint
log the X-AdobeSign-ClientId header of a rejected request (it answers 403), confirm it is
your application, and add it.
Typed events
The handler picks a class from the event name, falling back to whichever resource key the payload has:
| Class | Events | Extra methods |
|---|---|---|
AgreementEvent | AGREEMENT_* | agreement(), agreementId(), isWorkflowCompleted() |
MegaSignEvent | MEGASIGN_* (bulk send) | megaSignId() |
WidgetEvent | WIDGET_* (web forms) | widgetId() |
LibraryDocumentEvent | LIBRARY_DOCUMENT_* | libraryDocumentId() |
UnknownEvent | anything else Adobe adds later | none; use $raw |
All of them extend WebhookEvent, with readonly properties for the common fields:
$event, $subEvent, $webhookId, $webhookName,
$notificationId, $eventDate, $eventResourceType,
$participantRole, $actionType (e.g. ESIGNED),
$participantUserId, $participantUserEmail, $actingUserId,
$actingUserEmail, $actingUserIpAddress, $initiatingUserId,
$initiatingUserEmail, $applicableUsers and $raw. Methods:
resource(), resourceId(), resourceName(),
resourceStatus() and is(string ...$events).
use Conceptlz\AdobeSign\Webhook\{AgreementEvent, EventType, MegaSignEvent, UnknownEvent, WebhookEvent};
$event = WebhookEvent::fromArray($rawPayloadFromQueue); // rebuild the typed event
match (true) {
$event instanceof AgreementEvent && $event->isWorkflowCompleted()
=> $jobs->dispatch(new ArchiveSignedAgreement($event->agreementId())),
$event instanceof AgreementEvent && $event->is(EventType::AGREEMENT_REJECTED, EventType::AGREEMENT_EXPIRED)
=> $jobs->dispatch(new NotifySalesTeam($event->agreementId(), $event->event)),
$event instanceof AgreementEvent && $event->is(EventType::AGREEMENT_ACTION_COMPLETED)
=> $log->info("{$event->participantUserEmail} acted ({$event->actionType}) on {$event->agreementId()}"),
$event instanceof MegaSignEvent
=> $jobs->dispatch(new RefreshBulkSend($event->megaSignId())),
$event instanceof UnknownEvent
=> $log->notice('Unhandled Adobe Sign event ' . $event->event),
default => null,
};
The resource inside a notification (agreement(), resource()) uses a
webhook-specific shape that differs from the REST API's: for example, participant sets sit under
participantSetsInfo.participantSets. That is why it stays an array, and another reason to
re-fetch through the API for anything that matters.
Retries, and when Adobe gives up
If a delivery fails (no answer within five seconds, a non-2xx status, or no echo), Adobe retries with
growing intervals for 72 hours. If the endpoint is still failing after that, Adobe
disables the webhook, emails the addresses from notifyOnProblems(), and
stops sending.
webhooks()->get($id)->getStatus()showsINACTIVEfor a disabled webhook.webhooks()->updateState($id, 'ACTIVE')resumes delivery, but notifications from while it was disabled are lost.-
So keep a slow reconciliation job (nightly, say) that lists open agreements with
agreements()->all()and checks their status. It catches anything a webhook missed, for whatever reason.
Deduplicating deliveries
Retries mean the same notification can arrive more than once, for example when your endpoint
acknowledged it but the response got lost. Every notification has a unique
$notificationId (Adobe's webhookNotificationId). Record it, and skip ids you
have already seen:
// Table: adobe_sign_notifications(notification_id VARCHAR PRIMARY KEY, received_at TIMESTAMP)
$insert = $pdo->prepare(
'INSERT INTO adobe_sign_notifications (notification_id, received_at) VALUES (?, NOW())
ON CONFLICT (notification_id) DO NOTHING',
);
$insert->execute([$event->notificationId]);
if ($insert->rowCount() === 0) {
return; // already handled
}
Do this in the worker, not the endpoint, and make the work itself idempotent too ("download if not already archived"), so that a duplicate slipping through does no harm.
Payloads are not signed
Adobe doesn't sign webhook payloads, so a request that reaches your endpoint with an allowed client id could in principle still be forged by someone who knows that id. Defend in layers:
- Allowlist
WebhookHandleraccepts only the client ids you list. This is Adobe's documented check. - Re-fetchTreat the payload as a hint ("agreement X changed") and read the truth through the API with your own credentials before acting:
get()for the status,combinedDocument()for the file. - Unguessable URLOptional: put a long random token in the webhook URL path and reject requests without it. It's your URL, so this costs nothing.
- Mutual TLSAcrobat Sign can also present a client certificate at account level, if you need that assurance.
// In the worker: never trust the payload's status, ask Adobe
$agreement = $sign->agreements()->get($event->agreementId());
if ($agreement->isSigned()) {
$pdf = $sign->agreements()->combinedDocument($agreement->id, attachAuditReport: true);
$storage->writeStream("agreements/{$agreement->id}.pdf", $pdf->detach());
}
Queueing the work
Five seconds is not long. Downloading a PDF, calling your CRM or sending emails from inside the endpoint will eventually take longer, and then Adobe retries, and you process the same event twice. Keep the endpoint to three steps:
- Verify
Let
WebhookHandlercheck the client id and parse the body. - Queue the raw payload
Push
$event->raw, a plain array that serialises cleanly, onto your queue. - Acknowledge
Return the handler's response straight away.
In the worker, rebuild the typed event with WebhookEvent::fromArray($raw), deduplicate,
re-fetch, then do the real work. If the worker fails, your queue retries it; Adobe has already been
told the delivery succeeded.
Local development
Adobe only delivers to public HTTPS URLs, so localhost won't do. Put a tunnel in front of
your local server:
# Terminal 1: the example receiver from this package
ADOBE_SIGN_CLIENT_ID=your-client-id php -S localhost:8081 examples/webhook-receiver.php
# Terminal 2: a public HTTPS URL that forwards to it (ngrok, cloudflared, ...)
cloudflared tunnel --url http://localhost:8081
Then subscribe with the tunnel's URL, for example
WebhookInfo::forAgreement('Dev', 'https://<your-tunnel>/', $agreementId). Remember that
the tunnel exposes your machine to the internet while it runs; stop it when you're done, and use a
test agreement on a developer account.
For automated tests you don't need Adobe at all: build events from saved payloads with
WebhookEvent::fromArray() or $handler->parse($json), and drive
handle() with a PSR-7 request that carries an X-AdobeSign-ClientId header. See
Testing your integration.
Common questions
Creating the webhook fails. Why?
Adobe calls your URL with a verification GET before it creates the webhook. The
endpoint must be reachable over HTTPS, accept that client id, answer 2xx and echo
X-AdobeSign-ClientId. Deploy the receiver first, and check its logs for a 403 (wrong
client id in the allowlist).
Why do I receive the same notification twice?
Adobe retries any delivery it considers failed, and a delivery can succeed on your side while the
acknowledgement is lost. Deduplicate on $event->notificationId, as shown in
Deduplicating deliveries.
Notifications stopped arriving. What happened?
Probably the webhook was disabled after 72 hours of failed deliveries. Check
webhooks()->get($id)->getStatus(), fix the endpoint, re-activate with
updateState($id, 'ACTIVE'), and run your reconciliation job: notifications from the
disabled period are not replayed.
One webhook for the account, or one per agreement?
Adobe recommends one per agreement: failures and disabling then affect one agreement only. An account-wide webhook is simpler to set up and suits low volumes or monitoring, but a long outage on your side can switch off notifications for everything at once.
Can I trust the status in the payload?
Use it to decide what to look at, not what to do. Payloads aren't signed, so confirm with
agreements()->get() before releasing goods, archiving a contract or emailing a
customer.
Do notifications arrive in order?
Don't rely on it. Retries and parallel deliveries can reorder them. Use $event->eventDate
when order matters, and prefer logic that reads the current state from the API rather than
replaying events one by one.