Aadhaar eSign API in PHP with Laravel
Laravel's Http client sits on top of Guzzle and handles multipart uploads, Bearer tokens and JSON in a few lines. This guide builds a small SignYu service class, sends a document from a controller, and receives webhooks on an API route with the raw body.
Updated 2026-10-01
What you need
- Laravel 10 or 11 (PHP 8.1 or newer).
- A SignYu API key (starts with sk_live_). API access costs ₹999/month with a 3-day free trial; create the key under Developers in the dashboard.
- Signature credits for your signers, from ₹15 per signature (₹15 per signature on packs of 10 or more). Sending uses one credit per signer.
- A webhook endpoint added under Developers, and its signing secret.
Step 1: Configure credentials
Add the values to .env and expose them through config/services.php, so config caching works in production.
// .env
// SIGNYU_API_KEY=sk_live_your_api_key
// SIGNYU_WEBHOOK_SECRET=your_endpoint_secret
'signyu' => [
'key' => env('SIGNYU_API_KEY'),
'webhook_secret' => env('SIGNYU_WEBHOOK_SECRET'),
],Step 2: A service class
attach() switches the request to multipart. Pass the content type as the fourth argument, otherwise Guzzle guesses it from the file name, which fails for files without a .pdf extension.
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class SignYu
{
private function client(): PendingRequest
{
return Http::baseUrl('https://signyu.com/api/v1')
->withToken(config('services.signyu.key'))
->acceptJson()
->timeout(60);
}
public function sendForSignature(string $pdfPath, string $name, array $signers): array
{
$doc = $this->client()
->attach('file', file_get_contents($pdfPath), basename($pdfPath), ['Content-Type' => 'application/pdf'])
->post('/documents', ['name' => $name])
->throw()
->json();
$this->client()
->post("/documents/{$doc['documentId']}/signers", ['signers' => $signers])
->throw();
return $this->client()
->post("/documents/{$doc['documentId']}/send")
->throw()
->json();
}
public function get(string $documentId): array
{
return $this->client()->get("/documents/{$documentId}")->throw()->json();
}
}Step 3: Send from a controller and handle errors
throw() raises RequestException. Inspect the response status and the error field to decide what the user sees.
use App\Services\SignYu;
use Illuminate\Http\Client\RequestException;
public function send(Agreement $agreement, SignYu $signyu)
{
try {
$sent = $signyu->sendForSignature(
storage_path("app/agreements/{$agreement->id}.pdf"),
"Rental agreement #{$agreement->id}",
[
['name' => $agreement->tenant_name, 'phone' => $agreement->tenant_phone, 'email' => $agreement->tenant_email],
['name' => $agreement->owner_name, 'phone' => $agreement->owner_phone, 'email' => $agreement->owner_email],
],
);
} catch (RequestException $e) {
$error = $e->response->json('error');
return match ($e->response->status()) {
402 => back()->withErrors('Not enough eSign credits. Please top up.'),
409 => back()->with('status', 'This agreement was already sent.'),
401, 403 => abort(500, 'SignYu API key or subscription problem: ' . $error),
default => throw $e,
};
}
$agreement->update(['signyu_document_id' => $sent['documentId'], 'esign_status' => $sent['status']]);
return back()->with('status', 'Sent for Aadhaar eSign.');
}Step 4: Store the signed PDF
Download from downloadUrl with a fresh Http call that has no token, and save it to a private disk.
public function handle(SignYu $signyu): void
{
$doc = $signyu->get($this->documentId);
if ($doc['status'] !== 'COMPLETED') {
return;
}
$pdf = Http::timeout(60)->get($doc['downloadUrl'])->throw()->body();
Storage::disk('local')->put("signed/{$this->documentId}.pdf", $pdf);
}Verify webhooks
Register the route in routes/api.php so the web CSRF middleware does not run (in Laravel 11, run php artisan install:api first). $request->getContent() returns the raw body; hash_hmac and hash_equals do the rest. Dispatch a queued job and return 200 straight away.
<?php
// routes/api.php
// Route::post('/webhooks/signyu', SignYuWebhookController::class);
namespace App\Http\Controllers;
use App\Jobs\ArchiveSignedAgreement;
use App\Models\Agreement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SignYuWebhookController extends Controller
{
public function __invoke(Request $request)
{
$raw = $request->getContent();
$expected = 'sha256=' . hash_hmac('sha256', $raw, config('services.signyu.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('X-SignSetu-Signature'))) {
abort(400, 'invalid signature');
}
$event = json_decode($raw, true);
$key = "signyu:{$event['documentId']}:{$event['event']}:" . ($event['signer']['signerId'] ?? '');
if (Cache::add($key, true, now()->addDay())) {
Agreement::where('signyu_document_id', $event['documentId'])
->update(['esign_status' => $event['status']]);
if ($event['event'] === 'document.completed') {
ArchiveSignedAgreement::dispatch($event['documentId']);
}
}
return response()->noContent();
}
}Common mistakes
- Putting the webhook in routes/web.php triggers CSRF protection and a 419 response. Use routes/api.php or exclude the path from CSRF.
- Using $request->all() or $request->json() to rebuild the body changes it; always hash $request->getContent().
- Passing the arguments to hash_equals in the wrong order still works, but passing null (missing header) throws a TypeError; cast the header to string.
- Reusing the client() with withToken for the downloadUrl sends your API key to the storage host and the download fails.
- Webhooks are retried when your endpoint fails or times out after 10 seconds, so the same event can arrive more than once. Key your processing on documentId plus event plus signerId and ignore repeats.
Frequently asked questions
Can I use plain Guzzle without Laravel?
Yes. Use a multipart option with name, contents, filename and headers set to Content-Type application/pdf, plus an Authorization: Bearer header. The Laravel Http client is a thin wrapper around the same thing.
Does this work on shared hosting?
Yes, as long as outbound HTTPS is allowed and your webhook URL is reachable over HTTPS. Without a queue worker, use the sync queue driver and keep the webhook handler light.
Which PHP version do I need?
The examples use match expressions and named arguments, so PHP 8.0 or newer; Laravel 10 itself needs PHP 8.1.
How do I test the webhook locally with Laravel Sail or Valet?
Valet's valet share or a tunnel like ngrok gives you a public HTTPS URL. Add it under Developers and send a real document to yourself.
API reference
Get your API key
Start a 3-day free trial of API access and send your first document today.