Adobe Sign PHP SDKconceptlz/adobe-sign GitHub

Guides

Downloading signed files

The signed PDF is usually the reason the whole integration exists. This page shows how to fetch it (with or without the audit report), the individual documents and the data signers typed in; how to move those bytes to disk or cloud storage without filling memory; what is actually inside the signed file; and when it is ready to download.

At a glance

You wantMethodReturns
The signed document, all files mergedcombinedDocument($id)PDF stream
The same, with the audit report appendedcombinedDocument($id, attachAuditReport: true)PDF stream
The audit report aloneauditTrail($id)PDF stream
The list of documents in the agreementdocuments($id)AgreementDocuments
One documentdocument($id, $documentId)stream
A short-lived link instead of bytescombinedDocumentUrl($id)DocumentUrl
What signers typed into fieldsformData($id)CSV stream

Binary endpoints return a PSR-7 Psr\Http\Message\StreamInterface, never a string, so you decide where the bytes go. Working with streams shows the options.

When to download

Download once the agreement's status is SIGNED (or APPROVED, ACCEPTED, FORM_FILLED, … for agreements without signers). Before that, the file is a work in progress. There are two ways to know the moment has come:

  1. A webhook (recommended)

    Subscribe to AGREEMENT_WORKFLOW_COMPLETED (or AGREEMENT_ALL). When the notification arrives, queue a job; the job confirms the status with get() and downloads. See Webhooks.

  2. Polling

    Fine for scripts and small volumes: check get($id)->isSigned() every few minutes and download when it turns true.

$agreement = $sign->agreements()->get($agreementId);

if ($agreement->isSigned()) {
    $pdf = $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true);
    file_put_contents("/var/app/agreements/$agreementId.pdf", $pdf->detach());
}

AGREEMENT_NOT_EXPOSED while Adobe is processing

Acrobat Sign does a lot of work asynchronously: converting uploads to PDF after creation, and sealing and assembling the final file after the last signature. During those windows some calls answer with the error code AGREEMENT_NOT_EXPOSED. It is temporary. Catch ApiException (every API error extends it), check $errorCode, and retry with a growing delay:

Retry while the agreement is being processed
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Exception\ApiException;
use Psr\Http\Message\StreamInterface;

function signedPdf(AdobeSign $sign, string $agreementId, int $attempts = 5): StreamInterface
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true);
        } catch (ApiException $e) {
            if ($e->errorCode !== 'AGREEMENT_NOT_EXPOSED' || $attempt >= $attempts) {
                throw $e;
            }
            sleep(2 * $attempt);   // 2, 4, 6, 8 seconds
        }
    }
}

In a queue, prefer releasing the job back onto the queue with a delay over sleeping in the worker. Other codes you may meet on downloads are AGREEMENT_IN_DRAFT_STATE (nothing to download yet), DOCUMENT_NOT_AVAILABLE, and INVALID_AGREEMENT_ID or AGREEMENT_DELETED (a NotFoundException).

The signed PDF

agreements()->combinedDocument(string $agreementId, ?bool $attachAuditReport = null, ?bool $attachSupportingDocuments = null, ?string $versionId = null, ?string $participantId = null, ?bool $includeMergedFieldData = null): StreamInterfaceGET /agreements/{agreementId}/combinedDocument

Every document in the agreement merged into one PDF, in the order the files were added. Options left as null are not sent, so Adobe's defaults apply.

OptionEffect
attachAuditReportAppend the audit report as extra pages. One self-contained file for your archive.
attachSupportingDocumentsInclude files signers uploaded in "attachment" fields.
versionIdA specific version of the agreement (versions change when documents are modified); the latest when omitted.
participantIdThe document as that participant sees it, e.g. when some content is private to a participant.
includeMergedFieldDataInclude data merged into form fields when the agreement was created.
// The archive copy: signed documents + audit report in one file
$pdf = $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true);

// Everything, including files signers attached
$pdf = $sign->agreements()->combinedDocument(
    $agreementId,
    attachAuditReport: true,
    attachSupportingDocuments: true,
);

The audit report

agreements()->auditTrail(string $agreementId): StreamInterfaceGET /agreements/{agreementId}/auditTrail

The audit report on its own: a PDF listing every event with time, email address, IP address and identity checks (for example "authenticated with Email OTP by verifying one-time code sent via email"). It is your main evidence for an SES.

Whether to store it separately or appended to the signed PDF is a matter of taste. Appending (attachAuditReport: true) keeps one file per agreement; storing it separately keeps the signed document byte-for-byte as the parties saw it. Many teams store both.

Individual documents

agreements()->documents(string $agreementId, ?string $versionId = null, ?string $participantId = null): AgreementDocumentsGET /agreements/{agreementId}/documents

The documents in the agreement (and any supporting documents), with ids, names, MIME types and page counts.

agreements()->document(string $agreementId, string $documentId): StreamInterfaceGET /agreements/{agreementId}/documents/{documentId}

One document's file, in whatever format Adobe stores it (usually PDF).

$documents = $sign->agreements()->documents($agreementId);

foreach ($documents->getDocuments() ?? [] as $document) {
    $stream = $sign->agreements()->document($agreementId, $document->getId());
    $target = sprintf('/var/app/agreements/%s/%s', $agreementId, basename($document->getName()));

    file_put_contents($target, $stream->detach());
    printf("%s, %d pages, %s\n", $document->getName(), $document->getNumPages(), $document->getMimeType());
}

// Files signers attached during signing
$supporting = $documents->getSupportingDocuments() ?? [];

Use this when the agreement had several files and you need them apart, for example a contract and its price schedule filed in different places. Document names come from the uploads, so sanitise them (as basename() does above) before using them in a path.

A download link instead of bytes

agreements()->combinedDocumentUrl(string $agreementId, ?bool $attachAuditReport = null, ?bool $attachSupportingDocuments = null): DocumentUrlGET /agreements/{agreementId}/combinedDocument/url

A short-lived URL from which anyone holding it can download the combined PDF. Handy for sending a user's browser straight to Adobe, so the file never passes through your server.

$url = $sign->agreements()->combinedDocumentUrl($agreementId, attachAuditReport: true)->getUrl();

header('Location: ' . $url, true, 302);

Generate the link when the user clicks, after checking they may see the agreement. Don't store it or email it: it expires, and until then it works for whoever has it.

Form data

agreements()->formData(string $agreementId, ?string $participantId = null, ?bool $allowInProcessAgreement = null): StreamInterfaceGET /agreements/{agreementId}/formData

A CSV of the values signers entered in form fields, one row per participant. Only the agreement's creator can read it. Pass allowInProcessAgreement: true to read it before everyone has signed, and participantId for one participant's row.

$csv = $sign->agreements()->formData($agreementId)->detach();

$header = fgetcsv($csv);
while (($row = fgetcsv($csv)) !== false) {
    $values = array_combine($header, $row);
    // e.g. $values['Company registration number']
}
fclose($csv);

Column headers come from the field names in the document, so agree on stable names with whoever designs your templates. To see which fields exist without the values, use formFields().

Working with streams

A StreamInterface is a handle on the response body. The most important rule: don't turn it into a string unless you mean to hold the whole file in memory. (string) $stream and $stream->getContents() both do exactly that.

To a file

$pdf = $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true);

// Option 1: detach() hands you the underlying PHP resource; copy it in one call
$in = $pdf->detach();
$out = fopen("/var/app/agreements/$agreementId.pdf", 'wb');
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);

// Option 2: read in chunks through the PSR-7 interface
$out = fopen("/var/app/agreements/$agreementId.pdf", 'wb');
while (!$pdf->eof()) {
    fwrite($out, $pdf->read(65536));
}
fclose($out);

detach() separates the resource from the stream object: afterwards the StreamInterface is unusable and the resource is yours to close. A file_put_contents($path, $pdf->detach()) one-liner works too, because file_put_contents() accepts a stream resource and copies it in chunks.

To cloud storage

Most storage libraries accept a PHP stream resource, which is what detach() gives you:

// Flysystem (and Laravel's Storage facade, which wraps it)
$filesystem->writeStream("agreements/$agreementId.pdf", $pdf->detach());

// AWS SDK for PHP
$s3->putObject([
    'Bucket'      => 'signed-contracts',
    'Key'         => "agreements/$agreementId.pdf",
    'Body'        => $pdf->detach(),
    'ContentType' => 'application/pdf',
]);

To the browser

$pdf = $sign->agreements()->combinedDocument($agreementId);

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="agreement-' . $agreementId . '.pdf"');
fpassthru($pdf->detach());

In a PSR-15 application you can return the stream directly as a response body: $response->withBody($pdf).

Memory, rewinding and integrity

  • Where the bytes live depends on your PSR-18 client. Some stream from the network as you read; many buffer the body into php://temp, which keeps small bodies in memory and spills larger ones to a temporary file. Either way, reading in chunks or via detach() keeps your own memory use flat.
  • A stream is read once. After copying it, it is at the end. If you need the bytes twice (save and hash, say), save first and then work from the saved file, or call rewind() when isSeekable() is true.
  • Record a hash. Store hash_file('sha256', $path) alongside the file, so you can later show the archived copy hasn't changed since you downloaded it.
$path = "/var/app/agreements/$agreementId.pdf";
file_put_contents($path, $sign->agreements()->combinedDocument($agreementId, attachAuditReport: true)->detach());

$archive->record($agreementId, $path, hash_file('sha256', $path), filesize($path));

What is inside the signed PDF

Open a signed agreement in Acrobat or another PDF reader with a signature panel and you'll see one or more cryptographic signatures. What you find depends on the signature level each person signed at:

FileSignatures insideWhat they prove
Signed PDF, everyone signed with SES Adobe's certification seal Acrobat Sign produced this document and it hasn't been altered since. The visible signatures are images; who made them is evidenced by the audit report.
Signed PDF, one or more AES or QES signers Each digital signer's own certificate as an embedded PDF signature, plus Adobe's seal The named certificate holder signed exactly these bytes. The certificate chain shows the issuer (the trust service provider) and whether it is qualified.
Audit report (auditTrail()) Only Adobe's seal The event log is authentic. It never contains the signers' certificates.

So the file to archive for an AES or QES is the signed PDF from combinedDocument() or document(); the audit report alone is not enough to show the advanced signature. In the live test of an AES through Intesi Group Cloud Signature, the signed PDF carried the signer's certificate issued by "Intesi Group Advanced Cloud Signature CA Test", while the audit report carried only Adobe's seal.

Validating the signatures

For an independent check, open the PDF in Acrobat's signature panel or upload it to trusted-list-aware validation software such as the EU's DSS demonstration service. That is the authoritative way to show that a signature is qualified; signatureEvidence() reads Adobe's records instead, which is faster and fine for workflow decisions.

What to keep

A sensible archive record per agreement, so you can answer questions years later without calling Adobe:

  • Signed PDFFrom combinedDocument(); with the audit report attached, or stored next to it.
  • Audit reportFrom auditTrail() if you didn't attach it.
  • IdentifiersThe agreement id and your own externalId.
  • IntegrityA SHA-256 hash and the file size, recorded at download time.
  • Evidence snapshotThe per-signer result of signatureEvidence(): level reached, certificate issuer, identity check.
  • Form dataThe CSV from formData(), or the values you parsed out of it.

Keep your own copy even though Adobe stores agreements too: account retention policies can remove documents, and your archive shouldn't depend on an API key still working in ten years.

Signed documents in webhook payloads

A webhook can carry the signed PDF itself: WebhookInfo::...->agreementPayload(signedDocuments: true) asks Adobe to include it, base64-encoded, once signing completes. It saves a request, but webhook payloads are not signed, so anyone who can reach your endpoint could post a fake "signed document". Downloading through the API with your credentials is the trustworthy route; see Webhooks.

Downloads from other resources

Library documents, web forms and bulk sends have their own combinedDocument() and, where Adobe offers them, auditTrail(), formData() and (for bulk sends) errorReport(). They work the same way and return streams too. See Library, web forms, users, bulk.

Common questions

Why does Acrobat show only Adobe as the signer of my PDF?

Because everyone signed with an SES. Adobe certifies the finished document with its own seal; the signers' typed or drawn signatures are visual, and the audit report records who made them. Only AES and QES signers add a certificate of their own to the PDF.

Why does my download fail with AGREEMENT_NOT_EXPOSED?

Adobe is still processing the agreement, typically right after it was created or right after the last signature. Wait a few seconds and retry; see the retry example.

Can I download the document before everyone has signed?

Generally yes: combinedDocument() returns the document as it currently stands, with the signatures collected so far. Don't archive that as the final copy; wait for SIGNED. Form data needs allowInProcessAgreement: true for an agreement still in progress.

My worker runs out of memory when downloading large agreements. What's wrong?

Somewhere the stream is being turned into a string: (string) $stream, getContents(), or a library that buffers the whole body. Use detach() with stream_copy_to_stream(), Flysystem's writeStream() or a chunked read loop.

Can I store the link from combinedDocumentUrl() and use it later?

No. It is short-lived and works for anyone who holds it. Generate it when a user asks for the file, and store the PDF itself if you need it later.

Should I attach the audit report to the signed PDF?

For an archive copy, attaching it is convenient: one file holds both the agreement and its evidence. If you also share the document with the other party or feed it to another system, keep a copy without the report as well.

Where next