Integrating PandaDoc electronic signature on a website
PandaDoc differs from DocuSign and SignNow in that it's not just a signing service, but a full-featured document management platform: template creation, variables, approval workflows, payment blocks within documents, view analytics. The REST API covers the entire cycle from creation to archiving.
Application registration and authentication
In PandaDoc Developer Dashboard: create an application → get Client ID and Client Secret. Two authentication modes:
- API Key — simple key in header for server integrations without user context
- OAuth 2.0 — for multi-user applications
// Simplest option for your own website
$headers = [
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
'Content-Type' => 'application/json',
];
For OAuth — standard Authorization Code Flow at app.pandadoc.com/oauth2/authorize.
Creating a document from a template
PandaDoc supports templates — more convenient than uploading PDF each time:
class PandaDocService
{
private string $baseUrl = 'https://api.pandadoc.com/public/v1';
public function createFromTemplate(
string $templateId,
array $recipient,
array $tokens
): array {
$response = Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/documents", [
'name' => "Contract — {$recipient['email']}",
'template' => ['id' => $templateId],
'recipients' => [
[
'email' => $recipient['email'],
'first_name' => $recipient['first_name'],
'last_name' => $recipient['last_name'],
'role' => 'client', // role from template
],
],
'tokens' => array_map(fn($k, $v) => ['name' => $k, 'value' => $v],
array_keys($tokens), $tokens),
'metadata' => [
'order_id' => $recipient['order_id'] ?? '',
],
]);
return $response->json();
}
}
Tokens are template variables like [COMPANY_NAME], [CONTRACT_DATE]. When creating a document, they're substituted automatically.
Creating a document from an uploaded PDF
public function createFromPDF(string $pdfPath, array $recipient): array
{
// Step 1: upload file
$uploadResponse = Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
])->attach('file', file_get_contents($pdfPath), 'contract.pdf')
->post("{$this->baseUrl}/documents");
$documentId = $uploadResponse->json('id');
// Step 2: wait for document to be processed (usually a few seconds)
$this->waitForStatus($documentId, 'document.uploaded');
// Step 3: add signature field
Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
'Content-Type' => 'application/json',
])->patch("{$this->baseUrl}/documents/{$documentId}", [
'recipients' => [[
'email' => $recipient['email'],
'role' => 'Signer',
]],
'fields' => [[
'field_id' => 'sig1',
'type' => 'signature',
'role' => 'Signer',
'page' => 0,
'x' => 100,
'y' => 600,
'width' => 200,
'height' => 50,
]],
]);
return ['id' => $documentId];
}
private function waitForStatus(string $documentId, string $status): void
{
$attempts = 0;
do {
sleep(1);
$doc = Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
])->get("{$this->baseUrl}/documents/{$documentId}")->json();
$attempts++;
} while ($doc['status'] !== $status && $attempts < 15);
}
Sending for signature
public function sendDocument(string $documentId, string $message = ''): void
{
Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/documents/{$documentId}/send", [
'message' => $message ?: 'Please review and sign the document.',
'subject' => 'Document for signing',
'silent' => false, // true — don't send email, only link
]);
}
Embedded signing via session link
public function getSessionLink(string $documentId, string $recipientEmail): string
{
$response = Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/documents/{$documentId}/session", [
'recipient' => $recipientEmail,
'lifetime' => 3600, // seconds
]);
return $response->json('id'); // this is session ID, not URL directly
// URL for iframe: https://app.pandadoc.com/s/{session_id}
}
Final URL for iframe: https://app.pandadoc.com/s/{session_id}. PandaDoc sends postMessage when signing is complete.
Webhook
// Register in PandaDoc settings: Settings → API → Webhooks → Add endpoint
public function handlePandaDocWebhook(Request $request): Response
{
// PandaDoc signs via HMAC-SHA256, key from settings
$signature = $request->header('x-pandadoc-signature');
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, config('services.pandadoc.webhook_key'));
if (!hash_equals($expected, $signature)) {
abort(403);
}
foreach ($request->json() as $event) {
if ($event['event'] === 'document_state_changed'
&& $event['data']['status'] === 'document.completed') {
$docId = $event['data']['id'];
DownloadPandaDocJob::dispatch($docId);
}
}
return response()->noContent();
}
Note: PandaDoc may send multiple events in one webhook request — iterate through the array.
Download and storage
public function download(string $documentId, string $savePath): void
{
$response = Http::withHeaders([
'Authorization' => 'API-Key ' . config('services.pandadoc.api_key'),
])->get("{$this->baseUrl}/documents/{$documentId}/download");
file_put_contents($savePath, $response->body());
}
What sets PandaDoc apart
Templates with branding (logo, colors, style), pricing tables directly in document (client sees the amount and signs), approval workflows (document goes through internal sign-off before sending to client), built-in view analytics (when opened, time spent on each page). This makes PandaDoc suitable for commercial proposals and contracts with pricing, not just standard forms.
Timeline
Basic integration (template → creation → sending → webhook → download): 2–3 working days. With embedded signing, CRM tokens, and approval workflow: 4–5 working days.







