Adobe Sign for Laravelconceptlz/adobe-sign-laravel GitHub

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:

  1. Verification. When you create a webhook, Adobe immediately sends a GET with an X-AdobeSign-ClientId header. Unless you answer 2xx and echo that value back, the webhook isn't created.
  2. 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.
  3. Retries mean duplicates. A slow answer can count as a failure even if you processed it, so the same notification may arrive again.
  4. 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

NameMethodsURIMiddleware
adobe-sign.webhookGET, 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

Adobe GET / POST Client id allow-listed? Parse body POST only Dedupe cache add() Dispatch WebhookReceived 200 + X-AdobeSign-ClientId echo 403 400 unknown not JSON GET duplicate
A verification GET is answered as soon as the client id checks out. A POST is parsed, de-duplicated and dispatched; duplicates still get a 200 so Adobe stops retrying.
SituationResponseEvent fired?Logged
Header missing or client id not allowed403, no echoNoWarning "Rejected Acrobat Sign webhook" with reason and IP
Verification GET200 with echoNoNo
POST body not a JSON object with an event400NoError "Unreadable Acrobat Sign webhook"
POST seen before (same notification id)200 with echoNoNo
POST, new notification200 with echoYes, WebhookReceivedNo
No allow-list configured at all500 (MisconfiguredException)NoYour 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:

app/Http/Middleware/LogAdobeSignClientId.php (temporary)
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:

ClassFor events starting withExtra helpers
AgreementEventAGREEMENT_agreement(), agreementId(), isWorkflowCompleted()
MegaSignEventMEGASIGN_ (Send in Bulk)none
WidgetEventWIDGET_ (web forms)none
LibraryDocumentEventLIBRARY_DOCUMENT_none
UnknownEventanything Adobe adds laternone

All of them share these read-only properties and helpers:

MemberTypeMeaning
$eventstringEvent name, e.g. AGREEMENT_WORKFLOW_COMPLETED; compare with EventType constants
$subEvent?stringFiner detail some events carry
$webhookId, $webhookName?stringWhich webhook delivered it
$notificationId?stringUnique per notification; the dedupe key
$eventDate?stringWhen it happened, ISO 8601
$eventResourceType?stringe.g. agreement
$participantRole, $actionType?stringRole and action of the participant involved, when present
$participantUserId, $participantUserEmail?stringThe participant the event is about
$actingUserId, $actingUserEmail, $actingUserIpAddress?stringWho actually did it (may differ, e.g. a delegate)
$initiatingUserId, $initiatingUserEmail?stringWho started the action, when different
$applicableUsersarrayUsers the notification applies to
$rawarrayThe full decoded payload
resource(), resourceId(), resourceName(), resourceStatus()methodsThe agreement (or web form, …) object in the payload, and its id, name and status after the event
is(string ...$events)methodWhether 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:

app/Listeners/SyncContractStatus.php
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/Listeners by the type-hint on handle(). Otherwise register them with Event::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:

routes/console.php
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.

  1. Serve the app and open a tunnel
    php artisan serve --port=8000
    ngrok http 8000                                     # or: cloudflared tunnel --url http://localhost:8000
  2. Point APP_URL at the tunnel
    APP_URL=https://warm-otter-42.example-tunnel.dev
    ADOBE_SIGN_WEBHOOK_CLIENT_IDS=CBJCHBCAABAAdevapp

    Run php artisan config:clear if config is cached, so route() picks up the new address.

  3. Run a queue worker
    php artisan queue:work

    Or set QUEUE_CONNECTION=sync while debugging, so listeners run inline (they then count against Adobe's 5 seconds).

  4. 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:

bootstrap/app.php (excerpt)
->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.