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 want | Method | Returns |
|---|---|---|
| The signed document, all files merged | combinedDocument($id) | PDF stream |
| The same, with the audit report appended | combinedDocument($id, attachAuditReport: true) | PDF stream |
| The audit report alone | auditTrail($id) | PDF stream |
| The list of documents in the agreement | documents($id) | AgreementDocuments |
| One document | document($id, $documentId) | stream |
| A short-lived link instead of bytes | combinedDocumentUrl($id) | DocumentUrl |
| What signers typed into fields | formData($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:
-
A webhook (recommended)
Subscribe to
AGREEMENT_WORKFLOW_COMPLETED(orAGREEMENT_ALL). When the notification arrives, queue a job; the job confirms the status withget()and downloads. See Webhooks. -
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:
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.
| Option | Effect |
|---|---|
attachAuditReport | Append the audit report as extra pages. One self-contained file for your archive. |
attachSupportingDocuments | Include files signers uploaded in "attachment" fields. |
versionId | A specific version of the agreement (versions change when documents are modified); the latest when omitted. |
participantId | The document as that participant sees it, e.g. when some content is private to a participant. |
includeMergedFieldData | Include 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 viadetach()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()whenisSeekable()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:
| File | Signatures inside | What 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.