Send a document for signature with PHP
This walks through a minimal, working PHP example that uploads a PDF and creates a signature request through Esignapi's REST API using cURL — enough to see the full flow working end to end before building it into a larger application.
What you need first
- An Esignapi account with API access (available starting on the Pro plan).
- An API key from your account settings, in the
key_id.key_secret format.
- A PDF file to send, and an email address you control to act as the test signer.
- PHP with the cURL extension enabled, which is standard on most PHP installations.
Step 1: upload the document
<?php
define('API_URL', 'https://api.esignapi.me/v1');
define('API_KEY', getenv('ESIGNAPI_KEY')); // key_id.key_secret
function uploadDocument(string $filePath, string $filename): array {
$content = base64_encode(file_get_contents($filePath));
$ch = curl_init(API_URL . '/upload-document');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . API_KEY,
],
CURLOPT_POSTFIELDS => json_encode([
'filename' => $filename,
'content' => $content,
'mime_type' => 'application/pdf',
]),
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($statusCode >= 400) {
throw new Exception('Upload failed: ' . ($data['error'] ?? $statusCode));
}
return $data;
}
Step 2: create the signature request
function createSignatureRequest(string $documentId, array $signers): array {
$ch = curl_init(API_URL . '/create-signature-request');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . API_KEY,
],
CURLOPT_POSTFIELDS => json_encode([
'document_id' => $documentId,
'name' => 'Service Agreement',
'message' => 'Please review and sign this document.',
'signers' => $signers,
'expires_in_days' => 30,
]),
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($statusCode >= 400) {
throw new Exception('Request creation failed: ' . ($data['error'] ?? $statusCode));
}
return $data;
}
Putting it together
try {
$uploadResult = uploadDocument('./contract.pdf', 'contract.pdf');
$requestResult = createSignatureRequest($uploadResult['document_id'], [
['email' => 'signer@example.com', 'name' => 'Jane Signer'],
]);
echo 'Signature request created: ' . $requestResult['id'] . PHP_EOL;
} catch (Exception $e) {
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
Handling specific error cases
The bare >= 400 check above is enough to catch any failure, but production code benefits from distinguishing why a request failed:
if ($statusCode === 429) {
// Rate limited — back off before retrying
} elseif ($statusCode === 401) {
// API key is invalid or revoked
} elseif ($statusCode >= 400) {
throw new Exception('Request failed: ' . ($data['error'] ?? $statusCode));
}
See Error handling and idempotency in e-signature APIs and Rate limits and best practices for e-signature APIs for a fuller breakdown of handling these cases.
Checking a request's status
function getRequestStatus(string $requestId): array {
$ch = curl_init(API_URL . '/get-signature-request?id=' . urlencode($requestId));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['x-api-key: ' . API_KEY],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
For most integrations, a webhook is a more efficient way to track completion than polling this endpoint repeatedly — see E-signature webhooks explained.
Keeping your API key secure
Reading the key from an environment variable with getenv(), as shown above, keeps it out of your PHP source files. If you're deploying with a framework that has its own configuration/secrets system, use that instead of hardcoding the key directly in a controller or script.
Wrapping the cURL calls in a reusable client
Repeating the same curl_init / curl_setopt_array / curl_exec pattern for every endpoint gets tedious quickly. A small helper function that handles the shared parts — setting headers, encoding the body, checking the status code — cuts down the repetition:
function callEsignapi(string $method, string $endpoint, ?array $body = null): array {
$ch = curl_init(API_URL . $endpoint);
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['x-api-key: ' . API_KEY, 'Content-Type: application/json'],
];
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($body);
}
curl_setopt_array($ch, $options);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($statusCode >= 400) {
throw new Exception('Request failed (' . $statusCode . '): ' . ($data['error'] ?? 'unknown error'));
}
return $data;
}
With this in place, uploadDocument and createSignatureRequest both become a single call to callEsignapi, which keeps the header and error-checking logic in one place instead of duplicated across every function that talks to the API.
Adding fields to the request
The example above creates a signature request with no fields specified, which leaves the signer with nothing positioned to actually complete. A usable request needs a fields array added to the body passed into createSignatureRequest, with each field's page, coordinates, type, and assigned signer. See Placing signature fields in PDFs via API for the exact structure.
Using this inside Laravel
If you're working inside Laravel specifically, its Http facade covers the same functionality more concisely than raw cURL:
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['x-api-key' => config('services.esignapi.key')])
->post('https://api.esignapi.me/v1/create-signature-request', [
'document_id' => $documentId,
'name' => 'Service Agreement',
'signers' => $signers,
]);
if ($response->failed()) {
throw new Exception('Request failed: ' . $response->json('error'));
}
This achieves the same result as the raw cURL version, with Laravel's HTTP client handling the underlying request construction and JSON encoding for you.
Running this from a queued job instead of a request cycle
If creating a signature request is triggered by something that shouldn't block your application's main request/response cycle — a background process after a form submission, for instance — wrapping the call in a queued job (Laravel's Job classes, or an equivalent in whatever framework you're using) is a more resilient pattern than calling the API synchronously inside a web request, since a queue can retry a failed job according to your own retry policy without affecting the user-facing response time.
Checking the response shape before relying on it
Before writing code that assumes a specific field is present in the decoded response, it's worth confirming the actual shape of what comes back from your first few real calls — a quick var_dump($data) or error_log(json_encode($data)) while developing is enough to confirm the response matches what you expect before you build logic around fields like document_id or id that your code then depends on downstream.
Memory considerations for larger documents
Reading an entire file into memory with file_get_contents and base64-encoding it, as shown in uploadDocument, works fine for typical business documents — a multi-page contract is nowhere near large enough to strain PHP's default memory limits. If your use case involves unusually large PDFs, check your PHP configuration's memory_limit setting before assuming this same straightforward approach scales to every file size without adjustment.
A note on PHP's error reporting during development
If you're not seeing the exceptions thrown by the functions above surface clearly, check that your local PHP configuration has error reporting enabled (error_reporting(E_ALL) and display_errors on, during development only — never in production) — an uncaught exception that's silently swallowed by your environment's configuration can look identical to a request that mysteriously "did nothing," which wastes time debugging the wrong layer of the problem.
Why cURL instead of a dedicated HTTP library
PHP's cURL extension is used here because it's available by default on nearly every PHP installation without adding a dependency, which keeps this example runnable as-is. For a larger project, a library like Guzzle offers a more ergonomic API on top of the same underlying functionality — handling JSON encoding, headers, and error responses with less boilerplate than raw cURL calls — and is worth adopting once your integration grows beyond a couple of standalone functions, without changing anything about the endpoints, headers, or request bodies described above.
Keeping configuration out of version control
The example above reads the API key from an environment variable with getenv(), which keeps the actual secret out of your PHP files. In a typical deployment, that environment variable is set through your hosting platform's configuration, a .env file loaded by a library like vlucas/phpdotenv (with the .env file itself excluded from version control), or your framework's own configuration system — any of these are reasonable as long as the key never ends up hardcoded in a file that gets committed to your repository.
Try it yourself
Esignapi's API documentation includes this full example alongside the complete set of request and response fields for every endpoint referenced here.
Setting a request timeout
The raw cURL calls above don't set an explicit timeout, which means a hung connection could block your script indefinitely in an unusual network failure scenario. Adding CURLOPT_TIMEOUT (an overall time limit, in seconds, for the whole request) to the options array in a production integration is a small addition that prevents a single stuck request from hanging your application's request-handling thread or a queued job worker.
Frequently asked questions
Does this example work with a PHP framework like Laravel or Symfony?
Yes — the underlying API calls are the same regardless of framework. In Laravel, for example, you'd typically use its Http client instead of raw cURL, but the headers, endpoints, and request bodies are identical.
Do I need a specific PHP version?
The example uses standard, widely available PHP syntax and the cURL extension, both present in current PHP versions in common use.
How do I send the document to more than one signer?
Add additional associative arrays to the $signers array passed to createSignatureRequest, each with at least an email key.
What if curl_exec returns false?
That indicates a lower-level connection failure (DNS, network, TLS) rather than an API-level error response — check curl_error($ch) for the specific cause before assuming the API itself rejected the request.
Is there an official PHP package I should install instead of using raw cURL?
The documentation provides code examples rather than a dedicated installable package — raw cURL (or any HTTP client your framework already uses) is a standard and fully supported way to call the API.