Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

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.

Your app Acrobat Sign Your endpoint Queue worker POST /webhooks GET X-AdobeSign-ClientId 200 + echo id (≤ 5 s) { "id": webhookId } … later, someone signs … POST AGREEMENT_ACTION_COMPLETED queue raw 200 + echo id (≤ 5 s) GET /agreements/{id} (confirm) status, then documents
The endpoint's only job is to verify, queue and acknowledge within five seconds. Everything slow, including confirming the event through the API, happens in the worker. Without the echo, Adobe retries with growing gaps for 72 hours and then disables the webhook.

Subscribing

Conceptlz\AdobeSign\Builder\WebhookInfo builds the subscription, and webhooks()->create() sends it and returns the new webhook's id:

One webhook per agreement
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

BuilderScopeReceives events for
WebhookInfo::forAgreement($name, $url, $agreementId)RESOURCEone agreement
WebhookInfo::forResource($name, $url, $resourceType, $resourceId)RESOURCEone AGREEMENT, WIDGET, MEGASIGN or LIBRARY_DOCUMENT
WebhookInfo::forUser($name, $url)USEReverything of the calling user
WebhookInfo::forGroup($name, $url)GROUPeverything in the user's group
WebhookInfo::forAccount($name, $url)ACCOUNTthe 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):

ArgumentAdds
detailed: truethe agreement's detailed information
participants: trueparticipant sets and their status
documents: trueinformation about the documents
signedDocuments: truethe 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

  1. Every request carries a client id

    Both the verification GET sent when the webhook is created and every notification POST carry an X-AdobeSign-ClientId header: the client id of the application that created the webhook.

  2. 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-ClientId response header or as {"xAdobeSignClientId": "…"} in a JSON body. The handler does both.

  3. 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.

src/Http/AdobeSignWebhookAction.php
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
WebhookResultMeaning
$responseThe PSR-7 response to send back as-is; it carries the echo.
$eventThe parsed WebhookEvent, or null for the verification GET and for rejected requests.
$isVerificationTrue for Adobe's verification GET.
$errorA WebhookException explaining a rejection, or null.
isAccepted()True when $error is null.

In plain PHP

Without PSR-7, use the three building blocks directly:

public/adobe-sign-webhook.php
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:

ClassEventsExtra methods
AgreementEventAGREEMENT_*agreement(), agreementId(), isWorkflowCompleted()
MegaSignEventMEGASIGN_* (bulk send)megaSignId()
WidgetEventWIDGET_* (web forms)widgetId()
LibraryDocumentEventLIBRARY_DOCUMENT_*libraryDocumentId()
UnknownEventanything else Adobe adds laternone; 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).

Worker: route events by type
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() shows INACTIVE for 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:

  • AllowlistWebhookHandler accepts 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:

  1. Verify

    Let WebhookHandler check the client id and parse the body.

  2. Queue the raw payload

    Push $event->raw, a plain array that serialises cleanly, onto your queue.

  3. 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.

Where next