July 18, 2026 · Tutorial
Add E-Signatures to PHP & Laravel Applications via API (2026)
Integrating digital signatures into PHP or Laravel shouldn't require a 20-package Composer SDK, RSA private key assertions, or bloated envelope templates. Learn how to dispatch contracts and verify HMAC webhooks using native cURL, Guzzle, or Laravel's HTTP client.
Founder, Signbee
TL;DR
You don't need complex vendor SDKs to execute digital signatures in PHP or Laravel. With Signbee, you pass raw Markdown content to a single HTTP REST endpoint using cURL, Guzzle, or Laravel's Http::withToken() wrapper. This guide covers request validation, standalone PHP execution, Laravel API controller architecture, timing-safe HMAC-SHA256 webhook validation using hash_equals(), and Eloquent ORM status management.
Why PHP Applications Need Simple E-Signature APIs
PHP powers millions of web platforms, CRM systems, and enterprise backends built on frameworks like Laravel, Symfony, and WordPress. However, legacy e-signature providers make PHP integration surprisingly painful. Official SDKs like DocuSign's docusign/esign-client pull in dozens of Composer dependencies, require complex OAuth 2.0 JWT token generation with RSA private keys, and demand multi-step envelope construction before sending a single document.
Modern PHP engineering demands an API-first approach. By leveraging a lightweight digital signature API like Signbee, you can send contracts, NDAs, and onboarding agreements directly from your PHP code using standard HTTP primitives. You supply your contract text formatted in Markdown, and the API automatically renders a compliant PDF, distributes signing invitations via email, captures signatures, and returns real-time updates via secure webhooks.
Prerequisites
Section 1: PHP Developer Overview — Native cURL vs Guzzle vs Laravel Http
PHP provides several clean ways to dispatch HTTP requests. Depending on your environment, you can choose between native cURL extensions, Guzzle HTTP, or Laravel's HTTP facade.
1. Native PHP cURL (Zero External Dependencies)
If you are operating in a legacy codebase, a custom microservice, or a minimal PHP script, native cURL requires no third-party libraries. You set headers for authorization and content type, JSON-encode your payload, and execute the request:
$apiKey = getenv('SIGNBEE_API_KEY');
$ch = curl_init('https://signb.ee/api/v1/send');
$payload = json_encode([
'title' => 'Consulting Service Agreement',
'recipient_name' => 'Jane Doe',
'recipient_email' => 'jane@example.com',
'markdown' => "# Consulting Agreement\n\nThis agreement is made between...",
]);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);2. Guzzle HTTP Client (Composer Standard)
Most modern PHP applications use Guzzle (guzzlehttp/guzzle). Guzzle simplifies body serialization, header management, and exception handling:
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
$client = new Client();
try {
$response = $client->post('https://signb.ee/api/v1/send', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('SIGNBEE_API_KEY'),
'Accept' => 'application/json',
],
'json' => [
'title' => 'Standard NDA',
'recipient_name' => 'Alex Smith',
'recipient_email' => 'alex@example.com',
'markdown' => '# Non-Disclosure Agreement...',
],
]);
$data = json_decode($response->getBody()->getContents(), true);
} catch (GuzzleException $e) {
// Handle API errors gracefully
}3. Laravel Http Facade (Laravel 8+)
Laravel developers can use the Illuminate\Support\Facades\Http facade, which wraps Guzzle in an elegant, fluent syntax with built-in helper methods:
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.signbee.key'))
->acceptJson()
->post('https://signb.ee/api/v1/send', [
'title' => 'Master Services Agreement',
'recipient_name' => 'Sarah Connor',
'recipient_email' => 'sarah@cyberdyne.com',
'markdown' => '# Master Services Agreement...',
]);
if ($response->successful()) {
$documentId = $response->json('document_id');
$signingUrl = $response->json('signing_url');
}Section 2: Complete Working Standalone PHP Script
Here is a complete, production-ready PHP script that dispatches a document for signature and handles response status codes, JSON decoding, and error logging. Save this file as send_contract.php:
<?php
declare(strict_types=1);
$apiKey = getenv('SIGNBEE_API_KEY');
if (!$apiKey) {
fwrite(STDERR, "Error: SIGNBEE_API_KEY environment variable is missing.\n");
exit(1);
}
$apiEndpoint = 'https://signb.ee/api/v1/send';
$contractPayload = [
'title' => 'Software Development Retainer Agreement 2026',
'recipient_name' => 'Marcus Vance',
'recipient_email' => 'marcus.vance@enterprise-client.com',
'callback_url' => 'https://yourdomain.com/api/webhooks/signbee',
'metadata' => [
'client_id' => 'cli_884920',
'project_code' => 'PRJ-2026-B',
],
'markdown' => <<<MARKDOWN
# Software Development Retainer Agreement
**Date:** July 18, 2026
**Provider:** Acme Solutions LLC
**Client:** Marcus Vance
---
### 1. Scope of Services
Provider agrees to deliver senior backend engineering and API design services for **Project Beta**.
### 2. Monthly Fee & Term
- **Monthly Retainer:** $8,500 USD
- **Billing Cycle:** Billed on the 1st of each calendar month
- **Initial Term:** 6 Months
### 3. Acceptance & Digital Signature
By affixing their signature below, both parties acknowledge and accept the terms of this retainer agreement.
MARKDOWN,
];
$ch = curl_init($apiEndpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($contractPayload, JSON_THROW_ON_ERROR),
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$rawResponse = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlError) {
fwrite(STDERR, "cURL Request Failed: " . $curlError . "\n");
exit(1);
}
$responseData = json_decode($rawResponse, true);
if ($httpCode >= 200 && $httpCode < 300) {
echo "Success! Document dispatched for signature.\n";
echo "Document ID: " . ($responseData['document_id'] ?? 'N/A') . "\n";
echo "Signing URL: " . ($responseData['signing_url'] ?? 'N/A') . "\n";
echo "Status: " . ($responseData['status'] ?? 'sent') . "\n";
} else {
fwrite(STDERR, "API Error [HTTP {$httpCode}]: " . json_encode($responseData) . "\n");
exit(1);
}Section 3: Laravel Controller & Route Setup (`POST /api/contracts/send`)
Now let's integrate document sending directly into a modern Laravel application. We will define an API route, a validated Request, and a dedicated ContractController.
1. Define the Route (`routes/api.php`)
Register the endpoint in your routes/api.php file:
use App\Http\Controllers\ContractController;
use Illuminate\Support\Facades\Route;
Route::post('/contracts/send', [ContractController::class, 'send']);2. Configure Signbee Credentials (`config/services.php`)
Add Signbee service settings to config/services.php:
'signbee' => [
'key' => env('SIGNBEE_API_KEY'),
'webhook_secret' => env('SIGNBEE_WEBHOOK_SECRET'),
],3. Build ContractController (`app/Http/Controllers/ContractController.php`)
The controller validates incoming request parameters, creates a local draft record in PostgreSQL/MySQL using Eloquent, and calls the Signbee API using Http::withToken():
<?php
namespace App\Http\Controllers;
use App\Models\Contract;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ContractController extends Controller
{
public function send(Request $request): JsonResponse
{
$validated = $request->validate([
'recipient_name' => 'required|string|max:255',
'recipient_email' => 'required|email|max:255',
'contract_title' => 'required|string|max:255',
'markdown_body' => 'required|string',
]);
// 1. Create a local pending record in the database
$contract = Contract::create([
'recipient_name' => $validated['recipient_name'],
'recipient_email' => $validated['recipient_email'],
'title' => $validated['contract_title'],
'status' => Contract::STATUS_DRAFT,
]);
// 2. Dispatch request to Signbee API
$response = Http::withToken(config('services.signbee.key'))
->acceptJson()
->post('https://signb.ee/api/v1/send', [
'title' => $validated['contract_title'],
'recipient_name' => $validated['recipient_name'],
'recipient_email' => $validated['recipient_email'],
'markdown' => $validated['markdown_body'],
'callback_url' => route('webhooks.signbee'),
'metadata' => [
'contract_id' => $contract->id,
],
]);
if ($response->failed()) {
Log::error('Signbee API Error', [
'status' => $response->status(),
'body' => $response->body(),
]);
$contract->update(['status' => Contract::STATUS_FAILED]);
return response()->json([
'error' => 'Failed to dispatch document for signature.',
'details' => $response->json(),
], $response->status());
}
$responseData = $response->json();
// 3. Update contract record with Signbee Document ID
$contract->update([
'signbee_document_id' => $responseData['document_id'],
'signing_url' => $responseData['signing_url'] ?? null,
'status' => Contract::STATUS_SENT,
]);
return response()->json([
'message' => 'Contract sent for signature successfully.',
'contract_id' => $contract->id,
'signbee_document_id' => $contract->signbee_document_id,
'signing_url' => $contract->signing_url,
'status' => $contract->status,
], 201);
}
}Section 4: Webhook Route in Laravel (`POST /api/webhooks/signbee`) & HMAC Verification
To receive real-time notifications when documents are viewed, signed, or completed, you need an open webhook route. For deeper concepts on payload formats and retry logic across languages, read our dedicated guide to E-Signature API Webhook Events.
1. Define Webhook Route (`routes/api.php`)
use App\Http\Controllers\SignbeeWebhookController;
Route::post('/webhooks/signbee', [SignbeeWebhookController::class, 'handle'])
->name('webhooks.signbee');Note: Remember to exclude `/api/webhooks/signbee` from CSRF protection in Laravel's `bootstrap/app.php` or `Middleware/VerifyCsrfToken.php` if applicable.
2. Preventing Timing Attacks with `hash_equals()` in PHP
Signbee computes an HMAC-SHA256 hash of the raw HTTP request body using your secret webhook key and includes it in the X-Signbee-Signature header.
When validating signatures in PHP, you must never use standard equality operators like == or ===. Standard string comparison stops as soon as it encounters the first non-matching byte. Malicious actors can analyze response duration down to micro-seconds to deduce valid signatures character by character.
PHP provides hash_equals(), a timing-safe string comparison function that executes in constant time regardless of where string mismatches occur:
<?php
namespace App\Http\Controllers;
use App\Models\Contract;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SignbeeWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$signatureHeader = $request->header('X-Signbee-Signature');
$webhookSecret = config('services.signbee.webhook_secret');
if (!$signatureHeader || !$webhookSecret) {
Log::warning('Signbee Webhook missing signature header or server secret.');
return response()->json(['error' => 'Unauthorized header missing'], 401);
}
// 1. Get raw unparsed HTTP body string
$rawPayload = $request->getContent();
// 2. Compute expected HMAC-SHA256
$expectedSignature = hash_hmac('sha256', $rawPayload, $webhookSecret);
// 3. Timing-safe comparison to prevent side-channel timing attacks
if (!hash_equals($expectedSignature, $signatureHeader)) {
Log::warning('Signbee Webhook signature mismatch', [
'received' => $signatureHeader,
'computed' => $expectedSignature,
]);
return response()->json(['error' => 'Invalid signature'], 401);
}
// 4. Parse payload event data
$payload = $request->json()->all();
$eventType = $payload['event'] ?? null;
$docId = $payload['document_id'] ?? null;
Log::info("Signbee Webhook Received: {$eventType} for Doc ID {$docId}");
$contract = Contract::where('signbee_document_id', $docId)->first();
if (!$contract) {
Log::warning("Signbee Webhook: No contract found for Document ID {$docId}");
return response()->json(['message' => 'Document not found locally, ignored'], 200);
}
// 5. Handle event types
switch ($eventType) {
case 'document.sent':
$contract->update(['status' => Contract::STATUS_SENT]);
break;
case 'document.viewed':
$contract->update(['status' => Contract::STATUS_VIEWED]);
break;
case 'document.signed':
$contract->update([
'status' => Contract::STATUS_SIGNED,
'signed_at' => now(),
]);
break;
case 'document.completed':
$contract->update([
'status' => Contract::STATUS_COMPLETED,
'signed_pdf_url' => $payload['signed_pdf_url'] ?? null,
'completed_at' => now(),
]);
break;
case 'document.declined':
$contract->update([
'status' => Contract::STATUS_DECLINED,
'declined_at' => now(),
]);
break;
}
return response()->json(['status' => 'success'], 200);
}
}Section 5: Storing Signed Metadata in MySQL/PostgreSQL with Eloquent
To track documents throughout their lifecycle, let's create a database migration and Eloquent model.
1. Database Migration (`database/migrations/..._create_contracts_table.php`)
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('contracts', function (Blueprint $table) {
$table->id();
$table->string('signbee_document_id')->nullable()->unique()->index();
$table->string('recipient_name');
$table->string('recipient_email')->index();
$table->string('title');
$table->string('status')->default('draft')->index();
$table->string('signing_url')->nullable();
$table->string('signed_pdf_url')->nullable();
$table->timestamp('signed_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('declined_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contracts');
}
};2. Eloquent Model (`app/Models/Contract.php`)
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Contract extends Model
{
use HasFactory;
public const STATUS_DRAFT = 'draft';
public const STATUS_SENT = 'sent';
public const STATUS_VIEWED = 'viewed';
public const STATUS_SIGNED = 'signed';
public const STATUS_COMPLETED = 'completed';
public const STATUS_DECLINED = 'declined';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'signbee_document_id',
'recipient_name',
'recipient_email',
'title',
'status',
'signing_url',
'signed_pdf_url',
'signed_at',
'completed_at',
'declined_at',
];
protected $casts = [
'signed_at' => 'datetime',
'completed_at' => 'datetime',
'declined_at' => 'datetime',
];
public function isSigned(): bool
{
return in_array($this->status, [self::STATUS_SIGNED, self::STATUS_COMPLETED], true);
}
}Comparing Signbee with Official DocuSign PHP SDK
When evaluating e-signature integrations for PHP projects, developer friction and dependency bloat are critical factors. Here is how Signbee compares directly to the official DocuSign PHP SDK:
| Feature / Metric | DocuSign PHP SDK | Signbee API |
|---|---|---|
| Composer Dependencies | 20+ transitive packages | 0 (Native cURL / Laravel Http) |
| Authentication Scheme | OAuth 2.0 JWT Assertion + RSA Keypair | Single Secret Bearer Token |
| Template Configuration | DocuSign Portal + Absolute X/Y Coordinates | Dynamic Markdown to PDF |
| Lines of Code to Send | 80+ lines across multiple objects | 15 lines of PHP |
| Webhook Verification | Complex XML/JSON Connect signatures | Timing-safe HMAC hash_equals() |
Frequently Asked Questions
How do I add digital signatures to a PHP application without an SDK?
You can add digital signatures to any PHP application without installing bloated vendor SDKs by making direct HTTP POST requests to the Signbee REST API endpoint. Whether you choose native cURL extensions, Guzzle HTTP (guzzlehttp/guzzle), or Laravel's Illuminate\Support\Facades\Http facade, you simply attach your API key as a Bearer token in the Authorization header and transmit a JSON payload containing the recipient details, document title, and contract body formatted in Markdown. Signbee compiles the Markdown into a legal PDF, emails the signing link to the recipient, manages signature collection, and returns a document ID alongside a direct signing URL. This approach avoids heavy dependencies and complex OAuth token exchanges.
How do I verify e-signature webhooks securely in Laravel?
To verify e-signature webhooks securely in Laravel, inspect the raw HTTP request body alongside the X-Signbee-Signature header delivered to your POST endpoint. Compute the expected signature by generating an HMAC-SHA256 hash of the unparsed raw request content using your secret webhook key. Next, pass the computed hash and incoming header string to PHP's native hash_equals() function. Using hash_equals() is critical because it performs a timing-safe string comparison, taking a constant duration regardless of matching character position. This prevents timing attack vulnerabilities where malicious attackers deduce valid signatures by measuring response micro-seconds. Once verified, parse the JSON payload, update your Eloquent database models, and immediately return a 200 OK status code.
How does Signbee compare to the official DocuSign PHP SDK in Laravel?
The official DocuSign PHP SDK (docusign/esign-client) introduces over 20 transitive Composer dependencies, requires complex RSA keypair configuration, and forces developers to write 80+ lines of boilerplate code to handle OAuth 2.0 JWT assertion grants, account ID lookups, envelope definition objects, and template mapping. In contrast, Signbee eliminates SDK overhead entirely by exposing a clean, single-endpoint REST architecture. Integrating Signbee into a Laravel application requires zero extra Composer packages—you simply use Laravel's built-in Http::withToken() client in under 15 lines of code. Additionally, Signbee accepts raw Markdown instead of requiring manual coordinate positioning or template uploaded PDFs, cutting development setup time from days to minutes.
Related Tutorials & SDK Guides
Building multi-language microservices or implementing webhook notification pipelines? Check out our step-by-step guides for other popular languages and backend frameworks: