Guides
Webhooks
Acrobat Sign tells your app when people view, sign, decline or complete an agreement by calling a webhook. The package gives you a ready-made endpoint that satisfies Adobe's rules and turns each delivery into a Laravel event, so all you write is a queued listener.
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, the webhook isn't created. - Every notification needs the echo too, within 5 seconds. Without it, Adobe retries with growing intervals for 72 hours and then disables the webhook. Notifications during the disabled period are lost.
- Retries mean duplicates. A slow answer can count as a failure even if you processed it, so the same notification may 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 package's WebhookController handles all four. Your code receives a typed, de-duplicated
event after Adobe already has its answer.
The route
| Name | Methods | URI | Middleware |
|---|---|---|---|
adobe-sign.webhook | GET, POST | /adobe-sign/webhook (webhooks.path) | none (webhooks.middleware, default []) |
It is registered only when webhooks.enabled is true (the default). Build its
public URL with route('adobe-sign.webhook') when you register webhooks.
CSRF, sessions and auth
The route sits outside the web middleware group on purpose. Adobe can't send a
CSRF token, a session cookie or a login, so VerifyCsrfToken, sessions and
auth would turn every delivery into a 419 or a redirect. Because it isn't in that group, you
don't need a CSRF exception for it. The package's test suite asserts that web never ends up
on this route.
Keep it that way if you customise webhooks.middleware: rate limiting or an IP allow-list is
fine, anything that needs a session or user is not. If your app adds middleware to every route
globally (for example a "require login" middleware appended in bootstrap/app.php), exclude
this path there.
What happens on each call
| Situation | Response | Event fired? | Logged |
|---|---|---|---|
| Header missing or client id not allowed | 403, no echo | No | Warning "Rejected Acrobat Sign webhook" with reason and IP |
Verification GET | 200 with echo | No | No |
POST body not a JSON object with an event | 400 | No | Error "Unreadable Acrobat Sign webhook" |
POST seen before (same notification id) | 200 with echo | No | No |
POST, new notification | 200 with echo | Yes, WebhookReceived | No |
| No allow-list configured at all | 500 (MisconfiguredException) | No | Your exception handler |
The echo goes in both places Adobe accepts: the X-AdobeSign-ClientId response header and a
JSON body {"xAdobeSignClientId": "…"}. A 400 is deliberate: Adobe retries it, so a body that
was garbled in transit isn't silently lost.
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:
# One or more ids, comma-separated
ADOBE_SIGN_WEBHOOK_CLIENT_IDS=CBJCHBCAABAAprodapp,CBJCHBCAABAAstagingapp
When the list is empty, it falls back to ADOBE_SIGN_CLIENT_ID, which is right for apps that
create webhooks through their own OAuth application. If neither is set, the route can't decide who to
trust and throws MisconfiguredException.
If you are not sure which id Adobe sends (for example when webhooks are created with an Integration
Key), log it once from a temporary route middleware, then put it in .env and remove the
logging:
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
final class LogAdobeSignClientId
{
public function handle(Request $request, Closure $next)
{
Log::info('Acrobat Sign webhook client id', ['id' => $request->header('X-AdobeSign-ClientId')]);
return $next($request);
}
}
// config/adobe-sign.php
'webhooks' => [
// ...
'middleware' => [\App\Http\Middleware\LogAdobeSignClientId::class],
],
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 listeners should confirm anything important through the API rather than trusting the payload.
Duplicate deliveries
Every notification has an id (webhookNotificationId in the payload). Before dispatching,
the controller calls the cache's atomic add() with the key
adobe_sign.webhook.{notificationId}:
- Store:
cache_store(ADOBE_SIGN_CACHE_STORE), or your default store when empty. - Lifetime:
webhooks.dedupe_ttl, 259,200 seconds (72 hours), matching Adobe's retry window. - First wins:
add()only succeeds if the key is absent, so even two simultaneous deliveries of the same notification fire the event once, as long as both servers share the store. - No id, no dedupe: a payload without a notification id is always dispatched.
Use a shared cache store on multiple servers
With the file or array store, each server remembers its own notification ids, so a retry that lands on another server fires the event again. Point ADOBE_SIGN_CACHE_STORE at Redis, Memcached, the database or DynamoDB in production. Clearing the cache (php artisan cache:clear) also forgets the ids.
Dedupe is about deliveries, not business logic. Listeners should still be idempotent: a queued job can be retried, and Adobe can legitimately send several different notifications that lead to the same action.
The WebhookReceived event
Conceptlz\AdobeSign\Laravel\Events\WebhookReceived has one property, $event: a
core Conceptlz\AdobeSign\Webhook\WebhookEvent. Its class tells you what kind of resource
the notification is about:
| Class | For events starting with | Extra helpers |
|---|---|---|
AgreementEvent | AGREEMENT_ | agreement(), agreementId(), isWorkflowCompleted() |
MegaSignEvent | MEGASIGN_ (Send in Bulk) | none |
WidgetEvent | WIDGET_ (web forms) | none |
LibraryDocumentEvent | LIBRARY_DOCUMENT_ | none |
UnknownEvent | anything Adobe adds later | none |
All of them share these read-only properties and helpers:
| Member | Type | Meaning |
|---|---|---|
$event | string | Event name, e.g. AGREEMENT_WORKFLOW_COMPLETED; compare with EventType constants |
$subEvent | ?string | Finer detail some events carry |
$webhookId, $webhookName | ?string | Which webhook delivered it |
$notificationId | ?string | Unique per notification; the dedupe key |
$eventDate | ?string | When it happened, ISO 8601 |
$eventResourceType | ?string | e.g. agreement |
$participantRole, $actionType | ?string | Role and action of the participant involved, when present |
$participantUserId, $participantUserEmail | ?string | The participant the event is about |
$actingUserId, $actingUserEmail, $actingUserIpAddress | ?string | Who actually did it (may differ, e.g. a delegate) |
$initiatingUserId, $initiatingUserEmail | ?string | Who started the action, when different |
$applicableUsers | array | Users the notification applies to |
$raw | array | The full decoded payload |
resource(), resourceId(), resourceName(), resourceStatus() | methods | The agreement (or web form, …) object in the payload, and its id, name and status after the event |
is(string ...$events) | method | Whether the event is one of the given names |
The core's webhooks page lists every EventType constant.
Writing queued listeners
Listeners run inside the webhook request unless they implement ShouldQueue. Anything that
calls Adobe, touches storage or sends email must be queued, or Adobe's 5-second clock runs out. A
listener that keeps your own records in step with Acrobat Sign:
use App\Jobs\DownloadSignedAgreement;
use App\Models\Contract;
use App\Notifications\ContractDeclined;
use Conceptlz\AdobeSign\Laravel\Events\WebhookReceived;
use Conceptlz\AdobeSign\Webhook\AgreementEvent;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Contracts\Queue\ShouldQueue;
final class SyncContractStatus implements ShouldQueue
{
public string $queue = 'webhooks'; // keep webhook work off your default queue
public int $tries = 3;
public function handle(WebhookReceived $received): void
{
$event = $received->event;
if (! $event instanceof AgreementEvent || $event->agreementId() === null) {
return;
}
$contract = Contract::where('adobe_agreement_id', $event->agreementId())->first();
if ($contract === null) {
return; // not one of ours (e.g. sent from the Acrobat Sign UI)
}
match (true) {
$event->is(EventType::AGREEMENT_ACTION_COMPLETED) =>
$contract->signatures()->create([
'email' => $event->participantUserEmail,
'signed_at' => $event->eventDate,
]),
$event->isWorkflowCompleted() =>
DownloadSignedAgreement::dispatch($event->agreementId()),
$event->is(EventType::AGREEMENT_REJECTED) =>
$contract->owner->notify(new ContractDeclined($contract, $event->actingUserEmail)),
$event->is(EventType::AGREEMENT_RECALLED, EventType::AGREEMENT_EXPIRED) =>
$contract->update(['status' => 'closed']),
default => null,
};
}
}
- Discovery. Laravel finds listeners in
app/Listenersby the type-hint onhandle(). Otherwise register them withEvent::listen(WebhookReceived::class, SyncContractStatus::class). - Several listeners are fine. One can sync status, another can post to Slack; each is queued independently.
- Confirm before irreversible actions. The event is a hint. Before releasing goods or marking a contract legally complete, re-fetch the agreement (
agreements()->get($id)->isSigned()), as the download job does. - Payload size. The event, including
$raw, is serialised onto the queue. If you ask Adobe to include signed documents in the payload, a single message can exceed the message size limit of queues such as Amazon SQS. Download the PDF through the API instead.
Registering webhooks with Adobe
The route only receives. Adobe has to be told to call it, through the core's
webhooks()->create() and the WebhookInfo builder.
One webhook per agreement (recommended)
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Webhook\EventType;
AdobeSign::webhooks()->create(
WebhookInfo::forAgreement("Agreement $agreementId", route('adobe-sign.webhook'), $agreementId)
->events(EventType::AGREEMENT_WORKFLOW_COMPLETED, EventType::AGREEMENT_ACTION_COMPLETED, EventType::AGREEMENT_REJECTED)
->agreementPayload(participants: true), // include participant info in the payload
);
Adobe recommends this scope: if deliveries keep failing and Adobe disables a webhook, only that agreement
is affected. Subscribe to the events you handle, or EventType::AGREEMENT_ALL for everything.
One webhook for the whole account
An account admin can register one webhook that covers every agreement, for example from a one-off Artisan command:
use Conceptlz\AdobeSign\Builder\WebhookInfo;
use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign;
use Conceptlz\AdobeSign\Webhook\EventType;
use Illuminate\Support\Facades\Artisan;
Artisan::command('adobe-sign:register-webhook', function () {
$id = AdobeSign::webhooks()->create(
WebhookInfo::forAccount('All agreements', route('adobe-sign.webhook'))
->events(EventType::AGREEMENT_ALL)
->notifyOnProblems('ops@example.com'), // Adobe emails when deliveries fail
);
$this->info("Webhook $id registered.");
})->purpose('Register the account-wide Acrobat Sign webhook');
Run it after deploying, once APP_URL is the public HTTPS address. There are also forGroup(), forUser() and forResource() variants.
Listing, re-enabling and deleting
foreach (AdobeSign::webhooks()->all(showInActiveWebhooks: true) as $webhook) {
// generated UserWebhook models, e.g. $webhook->getId(), $webhook->getName(), $webhook->getStatus()
}
AdobeSign::webhooks()->updateState($webhookId, 'ACTIVE'); // re-enable one Adobe disabled
AdobeSign::webhooks()->delete($webhookId);
Re-enabling doesn't replay what was missed while it was disabled. After an outage, reconcile by checking the status of agreements your records still show as open.
HTTPS and the URL
Adobe only calls public HTTPS URLs, and WebhookInfo throws
InvalidArgumentException ("Acrobat Sign only delivers webhooks to HTTPS URLs") for anything
else, before sending. route('adobe-sign.webhook') builds the URL from the current request
or APP_URL. Behind a load balancer that terminates TLS, configure
trusted proxies so Laravel
knows the original request was HTTPS; in queued jobs and commands, where there is no request, set
APP_URL to the https address.
Local development with a tunnel
Adobe can't reach localhost, so local webhook testing needs a tunnel that gives your
machine a temporary public HTTPS address.
-
Serve the app and open a tunnel
php artisan serve --port=8000 ngrok http 8000 # or: cloudflared tunnel --url http://localhost:8000 -
Point APP_URL at the tunnel
APP_URL=https://warm-otter-42.example-tunnel.dev ADOBE_SIGN_WEBHOOK_CLIENT_IDS=CBJCHBCAABAAdevappRun
php artisan config:clearif config is cached, soroute()picks up the new address. -
Run a queue worker
php artisan queue:workOr set
QUEUE_CONNECTION=syncwhile debugging, so listeners run inline (they then count against Adobe's 5 seconds). -
Send an agreement and sign it
Webhooks registered now point at the tunnel. Your tunnel tool's inspector shows each delivery and your app's answer, which is the fastest way to debug a 403 or 400.
Tunnels expose your machine
A tunnel makes your local app reachable from the internet while it runs. Use a developer Acrobat Sign account, stop the tunnel when you're done, and delete webhooks that point at old tunnel addresses; Adobe would otherwise keep retrying them and then disable them.
Simulating a delivery without Adobe
To exercise your listener without signing anything, post a payload to your local route yourself:
curl -i http://localhost:8000/adobe-sign/webhook \
-H "X-AdobeSign-ClientId: CBJCHBCAABAAdevapp" \
-H "Content-Type: application/json" \
-d '{"event":"AGREEMENT_WORKFLOW_COMPLETED","webhookNotificationId":"local-1","agreement":{"id":"CBJCHBCAABAAexample","status":"SIGNED"}}'
Change webhookNotificationId for each try, or the second post is dropped as a duplicate. For automated tests, see Testing webhooks.
Deploys and maintenance mode
During php artisan down the route answers 503 like the rest of your app, and Adobe retries
later. For a short deploy that is harmless. If you expect longer downtime, let webhooks through so they
are queued for when workers come back:
->withMiddleware(function (Middleware $middleware) {
$middleware->preventRequestsDuringMaintenance(except: ['adobe-sign/webhook']);
})
Common questions
Do I need to exclude the webhook from CSRF protection?
No. The route isn't in the web group, so CSRF middleware never runs for it. Only if you add web to webhooks.middleware (don't) or apply CSRF globally would you need an exception.
Adobe gets a 403 from my webhook. Why?
The client id in X-AdobeSign-ClientId isn't in ADOBE_SIGN_WEBHOOK_CLIENT_IDS (or ADOBE_SIGN_CLIENT_ID when that is empty). Your log has a "Rejected Acrobat Sign webhook" warning. Log the incoming id as shown in the allow-list section and add it.
Can a listener run synchronously?
Only if it is very fast and never calls Adobe or other remote services. The whole request, including synchronous listeners, must finish within 5 seconds, or Adobe treats the delivery as failed and retries. Queue everything else.
Can I use a different URL path?
Yes. Set webhooks.path in your published config, e.g. hooks/acrobat-sign. Webhooks already registered at Adobe keep the old URL, so re-register or update them.
Can I handle webhooks in my own controller instead?
Yes. Set webhooks.enabled to false and use AdobeSign::webhookHandler(): verifyClientId(), parse() and acknowledgementHeaders() do the protocol work. You then own dedupe and the 5-second budget.
Our server was down for a day. Did we lose events?
Not yet: Adobe retries for 72 hours, so deliveries arrive once you're back. After 72 hours the webhook is disabled and later events are not delivered. Re-enable it with webhooks()->updateState($id, 'ACTIVE') and reconcile open agreements through the API.