← Back to blog

Quickstart Guide: Adding E-Signing to Your PHP or Laravel Application

April 15, 2026
Quickstart Guide: Adding E-Signing to Your PHP or Laravel Application

If you need to integrate signature functionality into a PHP or Laravel application, routing your users to a third-party signing portal is the slow option. It breaks the user journey, hands control to another platform, and forces signers to navigate somewhere unfamiliar. A direct API integration keeps everything inside your product.

This quickstart guide walks you through integrating the Inkless e-signature API into both plain PHP and Laravel applications. You will learn how to send signature requests, handle webhook callbacks, download completed documents and audit logs, and check envelope status - all with accurate, working code based on the real Inkless API documentation.

Why Integrate Inkless E-Signatures in PHP or Laravel?

Most e-signature integrations involve complex OAuth flows, verbose SDKs, and subscription fees regardless of usage. The Inkless API is deliberately straightforward: a single API key, standard JSON request bodies, and POST endpoints that do exactly what they say.

Pricing is pay-as-you-go - you pay per document sent, not per user or per month. For PHP applications handling HR contracts, onboarding forms, or client agreements with variable volumes, that model scales cleanly. Inkless stores documents in the UK, provides court-ready audit trails for every signing event, and is designed to comply with UK eIDAS and UK GDPR. See the Inkless features page for full security and compliance details.

How the Inkless API Works

Before writing any code, it helps to understand the three key concepts the API is built around:

  • Document templates: Signature layouts are created once in your Inkless dashboard and referenced by their template ID in API calls. You can optionally replace the document content at send time using a base64-encoded file.

  • Envelopes: A single call to POST /api/send_link creates the envelope, assigns recipients, and dispatches signing invitations in one step. The response returns an envelope_token and a document_token used for all follow-up calls.

  • Tokens: All subsequent API calls (status checks, downloads, resending) use the envelope_token or document_token returned by the initial send call. Store these securely in your database immediately.

 

The base URL for all API calls is:

https://inkless.co.uk/api

 

All endpoints use POST

Unlike REST APIs that use GET for reads, every Inkless endpoint - including status checks and downloads - uses HTTP POST. Parameters are always passed as a JSON request body, never as URL query strings.

 

Prerequisites and Account Setup

Before you begin, you will need:

  • PHP 7.4 or later (PHP 8.1+ recommended)

  • Composer installed globally

  • Laravel 9+ if following the Laravel examples (optional for plain PHP)

  • An Inkless account - sign up free and retrieve your API key from the developer settings in your dashboard. Your key will be in the format ink_live_key.secret

  • At least one document template and one email template configured in your Inkless dashboard. Note their integer IDs - you will need them in every API call.

 

Install Guzzle as your HTTP client:

composer require guzzlehttp/guzzle

 

Add your credentials to your .env file:

# .env

INKLESS_API_KEY=ink_live_key.your_secret_here

INKLESS_BASE_URL=https://inkless.co.uk/api

INKLESS_WEBHOOK_SECRET=your_webhook_secret_here

 

# IDs from your Inkless dashboard

INKLESS_EMAIL_TEMPLATE_ID=1

INKLESS_DOC_TEMPLATE_ID=42

 

For Laravel, register these in config/services.php:

// config/services.php

'inkless' => [

    'api_key'           => env('INKLESS_API_KEY'),

    'base_url'          => env('INKLESS_BASE_URL',

                               'https://inkless.co.uk/api'),

    'webhook_secret'    => env('INKLESS_WEBHOOK_SECRET'),

    'email_template_id' => env('INKLESS_EMAIL_TEMPLATE_ID'),

    'doc_template_id'   => env('INKLESS_DOC_TEMPLATE_ID'),

],

 

Security reminder

Add .env to your .gitignore before your first commit. API keys in the format ink_live_key.secret are sensitive credentials. Rotate your key from the Inkless dashboard immediately if it is ever exposed in version control or application logs.

 

Send a Signature Request

A single POST to /api/send_link creates the envelope and dispatches invitation emails to all recipients. There is no separate "create then send" step - everything happens in one call.

Recipient fields

 

Field

Type

Required

Description

recipient_number

integer

Yes

Unique identifier within this envelope (1, 2, 3...)

name

string

Yes

Full name of the recipient

email

string

Yes

Email address where the invitation is sent

routing_order

integer

No

Signing order when enforce_routing_order is true

read_only

boolean

No

Recipient receives a copy but does not sign

send_link_sms

boolean

No

Also deliver the signing link via SMS

 

Plain PHP example

The following sends a document for signature using Guzzle. The document template must already exist in your Inkless dashboard. Optionally, pass replacement_file_base64 to use a dynamically generated PDF instead of the default template file.

<?php

require 'vendor/autoload.php';

 

use GuzzleHttp\Client;

use GuzzleHttp\Exception\RequestException;

 

$client = new Client([

    'base_uri' => $_ENV['INKLESS_BASE_URL'],

    'headers'  => [

        'Authorization' => 'Bearer ' . $_ENV['INKLESS_API_KEY'],

        'Content-Type'  => 'application/json',

        'Accept'        => 'application/json',

    ],

]);

 

// Optionally replace the template file with a dynamic PDF

$customPdf = base64_encode(

    file_get_contents('/path/to/generated-contract.pdf')

);

 

$payload = [

    'email_template_id'    => (int) $_ENV['INKLESS_EMAIL_TEMPLATE_ID'],

    'enforce_routing_order' => true,  // sequential signing

    'client_reference_id'  => 'HR-2025-001',

    'recipients' => [

        [

            'recipient_number' => 1,

            'routing_order'    => 1,

            'name'             => 'Jane Smith',

            'email'            => 'jane.smith@example.com',

        ],

        [

            'recipient_number' => 2,

            'routing_order'    => 2,

            'name'             => 'HR Manager',

            'email'            => 'hr@yourcompany.com',

        ],

    ],

    'documents' => [

        [

            'document_template_id'    =>

                (int) $_ENV['INKLESS_DOC_TEMPLATE_ID'],

            'recipient_numbers'       => [1, 2],

            'replacement_file_base64' => $customPdf,

            'replacement_file_name'   => 'employment-contract.pdf',

        ],

    ],

];

 

try {

    $response = $client->post('/send_link', ['json' => $payload]);

    $data = json_decode($response->getBody(), true);

 

    $envelopeToken = $data['envelope_token'];

    $documentToken = $data['documents'][0]['document_token'];

 

    // Store both tokens - needed for all follow-up API calls

    echo "Envelope created: {$envelopeToken}";

    echo "Balance remaining: {$data['remaining']}";

 

} catch (RequestException $e) {

    $body   = json_decode($e->getResponse()->getBody(), true);

    $status = $e->getResponse()->getStatusCode();

    error_log("Inkless error {$status}: " . $body['error']);

 

    if ($status === 402) {

        error_log('Insufficient Inkless balance - top up required');

    }

    throw $e;

}

 

Test mode

To test your integration without consuming credits or sending real emails, use api@inkless.co.uk as the recipient email address. Inkless will return a mock response so you can verify your code handles the full workflow correctly before going live.

 

Laravel service class

In Laravel, encapsulate all Inkless calls in a service class. This keeps controllers thin, makes credentials easy to manage, and allows you to mock the HTTP client cleanly in tests.

<?php

// app/Services/InklessService.php

 

namespace App\Services;

 

use Illuminate\Support\Facades\Http;

use Illuminate\Http\Client\RequestException;

 

class InklessService

{

    protected string $baseUrl;

    protected string $apiKey;

 

    public function __construct()

    {

        $this->baseUrl = config('services.inkless.base_url');

        $this->apiKey  = config('services.inkless.api_key');

    }

 

    protected function client()

    {

        return Http::withToken($this->apiKey)

                   ->baseUrl($this->baseUrl)

                   ->acceptJson();

    }

 

    /**

     * Send a document for signature.

     * Returns the full API response array.

     */

    public function sendDocument(

        array  $recipients,

        string $replacementFilePath = null

    ): array {

        $document = [

            'document_template_id' =>

                (int) config('services.inkless.doc_template_id'),

            'recipient_numbers'    =>

                array_column($recipients, 'recipient_number'),

        ];

 

        if ($replacementFilePath) {

            $document['replacement_file_base64'] =

                base64_encode(file_get_contents($replacementFilePath));

            $document['replacement_file_name'] =

                basename($replacementFilePath);

        }

 

        return $this->client()

            ->post('/send_link', [

                'email_template_id'     =>

                    (int) config('services.inkless.email_template_id'),

                'enforce_routing_order' => true,

                'recipients'            => $recipients,

                'documents'             => [$document],

            ])

            ->throw()

            ->json();

    }

 

    /**

     * Get current envelope status.

     */

    public function getEnvelopeStatus(string $envelopeToken): array

    {

        return $this->client()

            ->post('/get_envelope_status', [

                'envelope_token' => $envelopeToken,

            ])

            ->throw()

            ->json();

    }

 

    /**

     * Download the signed PDF.

     * Returns the decoded PDF bytes.

     */

    public function downloadSigned(string $documentToken): string

    {

        $response = $this->client()

            ->post('/download_signed', [

                'document_token' => $documentToken,

            ])

            ->throw()

            ->json();

 

        return base64_decode($response['file']['content_base64']);

    }

 

    /**

     * Download the court bundle (signed PDF + audit log as ZIP).

     */

    public function downloadArchive(string $documentToken): string

    {

        $response = $this->client()

            ->post('/download_archive', [

                'document_token' => $documentToken,

            ])

            ->throw()

            ->json();

 

        return base64_decode($response['file']['content_base64']);

    }

 

    /**

     * Resend signing invitations to pending recipients.

     */

    public function resendLinks(string $envelopeToken): array

    {

        return $this->client()

            ->post('/resend_envelope_links', [

                'envelope_token' => $envelopeToken,

            ])

            ->throw()

            ->json();

    }

}

 

Laravel controller

Inject the service into a controller, validate the incoming request, and store the returned tokens for later use:

<?php

// app/Http/Controllers/SigningController.php

 

namespace App\Http\Controllers;

 

use App\Services\InklessService;

use Illuminate\Http\Request;

use Illuminate\Support\Facades\Log;

use App\Models\SigningRequest;

 

class SigningController extends Controller

{

    public function __construct(

        protected InklessService $inkless

    ) {}

 

    public function send(Request $request)

    {

        $data = $request->validate([

            'signer_name'  => 'required|string|max:255',

            'signer_email' => 'required|email',

            'manager_name'  => 'required|string|max:255',

            'manager_email' => 'required|email',

        ]);

 

        $recipients = [

            [

                'recipient_number' => 1,

                'routing_order'    => 1,

                'name'             => $data['signer_name'],

                'email'            => $data['signer_email'],

            ],

            [

                'recipient_number' => 2,

                'routing_order'    => 2,

                'name'             => $data['manager_name'],

                'email'            => $data['manager_email'],

            ],

        ];

 

        try {

            $result = $this->inkless->sendDocument(

                $recipients,

                storage_path('app/contracts/template.pdf')

            );

 

            // Persist tokens - essential for downloads and status checks

            SigningRequest::create([

                'envelope_token' => $result['envelope_token'],

                'document_token' => $result['documents'][0]['document_token'],

                'reference'      => $result['client_reference_id'] ?? null,

            ]);

 

            return response()->json([

                'ok'             => true,

                'envelope_token' => $result['envelope_token'],

                'balance'        => $result['remaining'],

            ]);

 

        } catch (\Exception $e) {

            Log::error('Inkless send failed', [

                'error' => $e->getMessage()

            ]);

            return response()->json(['error' => 'Request failed'], 500);

        }

    }

}

 

Handle Webhook Events

Rather than polling /api/get_envelope_status, register a webhook endpoint so Inkless notifies your server the moment each event occurs. Key events to handle include document_signed (one recipient has signed), document_complete (all signatures collected on a document), and envelope_complete (all documents in the envelope are fully signed).

Inkless signs each webhook request with HMAC-SHA256. The signature is Base64-encoded and sent in the X-Inkless-Signature header. Always verify it before processing the payload.

<?php

// app/Http/Controllers/WebhookController.php

 

namespace App\Http\Controllers;

 

use Illuminate\Http\Request;

use Illuminate\Support\Facades\Log;

 

class WebhookController extends Controller

{

    public function handle(Request $request)

    {

        $signature = $request->header('X-Inkless-Signature');

        $secret    = config('services.inkless.webhook_secret');

        $rawBody   = $request->getContent();

 

        // Inkless uses Base64-encoded HMAC-SHA256

        $expected = base64_encode(

            hash_hmac('sha256', $rawBody, $secret, true)

        );

 

        // hash_equals prevents timing attacks

        if (! hash_equals($expected, (string) $signature)) {

            Log::warning('Invalid Inkless webhook signature');

            return response('Unauthorised', 401);

        }

 

        $event         = $request->input('event');

        $documentToken = $request->input('document_token');

 

        Log::info('Inkless webhook received', [

            'event'          => $event,

            'document_token' => $documentToken,

            'timestamp'      => $request->input('timestamp'),

        ]);

 

        match ($event) {

            'document_complete' =>

                \App\Jobs\ArchiveSignedDocument

                    ::dispatch($documentToken),

            'envelope_complete' =>

                \App\Jobs\NotifyStakeholders

                    ::dispatch($documentToken),

            'document_signed'   =>

                \App\Jobs\UpdateSigningStatus

                    ::dispatch($documentToken),

            default => null,

        };

 

        // Respond immediately - process asynchronously via jobs

        return response('OK', 200);

    }

}

 

Register the route and exclude it from CSRF middleware:

// routes/api.php

Route::post('/sign/send',

    [SigningController::class, 'send']);

 

Route::post('/webhooks/inkless',

    [WebhookController::class, 'handle']);

 

Webhook verification endpoint

When you register your webhook URL in the Inkless dashboard, Inkless will send a GET request to your endpoint and expect a plain-text response containing your verification_secret value. Add a separate GET route that returns this string at HTTP 200 to complete registration.

 

Download Completed Documents and Audit Logs

Once the document_complete event fires, retrieve the signed PDF and audit trail using the document_token stored from the original send call. Inkless returns files as Base64-encoded strings in the response body.

Inkless provides three download options, each using the document_token:

  • POST /api/download_signed - the completed signed PDF

  • POST /api/download_archive - a court bundle ZIP containing the signed PDF and all supporting documentation

  • POST /api/download_audit_log - the tamper-evident audit log in NDJSON format, recording every signing event with timestamps

 

Here is the queue job that handles the download after a webhook triggers it:

<?php

// app/Jobs/ArchiveSignedDocument.php

 

namespace App\Jobs;

 

use App\Services\InklessService;

use App\Models\SigningRequest;

use Illuminate\Bus\Queueable;

use Illuminate\Contracts\Queue\ShouldQueue;

use Illuminate\Support\Facades\Log;

use Illuminate\Support\Facades\Storage;

 

class ArchiveSignedDocument implements ShouldQueue

{

    use Queueable;

 

    public int $tries = 3;

 

    public function __construct(

        public readonly string $documentToken

    ) {}

 

    public function handle(InklessService $inkless): void

    {

        $record = SigningRequest::where(

            'document_token', $this->documentToken

        )->firstOrFail();

 

        // Download the signed PDF

        $pdfBytes = $inkless->downloadSigned($this->documentToken);

 

        $path = "signed-documents/{$this->documentToken}.pdf";

        Storage::put($path, $pdfBytes);

 

        // Also download the court bundle archive

        $zipBytes = $inkless->downloadArchive($this->documentToken);

        Storage::put(

            "signed-documents/{$this->documentToken}-archive.zip",

            $zipBytes

        );

 

        $record->update([

            'status'     => 'complete',

            'signed_at'  => now(),

            'file_path'  => $path,

        ]);

 

        Log::info('Document archived', [

            'token' => $this->documentToken

        ]);

    }

}

 

Data minimisation and retention

UK GDPR requires that personal data is not retained longer than necessary. When you download and store completed documents, document your legal basis and retention schedule. Delete or anonymise records once the retention period expires. The Inkless audit log captures signer IP addresses and timestamps, so treat it as personal data under your privacy notice.

 

Check Envelope Status

If you need to query the current state of a signing request - for example, to display progress in your application - use POST /api/get_envelope_status with the envelope_token. The response includes completion status per document, per recipient timestamps, and whether each party has viewed, signed, or has a link pending expiry.

// Example Laravel controller method

public function status(Request $request, string $envelopeToken)

{

    try {

        $status = $this->inkless->getEnvelopeStatus($envelopeToken);

 

        return response()->json([

            'envelope' => $status['envelope'],

            'documents' => $status['documents'],

            'recipients' => $status['recipients'],

        ]);

 

    } catch (\Exception $e) {

        return response()->json(['error' => 'Not found'], 404);

    }

}

 

Advanced Features and Best Practices

The Inkless API supports several features beyond the basic send-and-sign flow. See the Inkless developer documentation for the full reference.

  • Multiple documents per envelope: Pass multiple objects in the documents array to bundle several documents into a single signing workflow. Use the combine field to group documents that should be billed as one.

  • Sequential vs parallel signing: Set enforce_routing_order: true and assign different routing_order values for sequential signing. Recipients with the same routing_order value sign in parallel.

  • SMS delivery: Set send_link_sms: true on a recipient to deliver their signing link via SMS as well as email. Use send_otp_sms for one-time password verification.

  • Resending invitations: Call POST /api/resend_envelope_links with the envelope_token to resend invitations to all recipients with pending signatures. The response confirms how many were resent and how many were skipped (already signed).

  • Balance monitoring: Call POST /api/get_balance to check your remaining credit before sending, or monitor the remaining field in every send_link response.

 

On the technical side:

  • Always store envelope_token and document_token in your database immediately after a successful send - they are the only way to retrieve, download, or check the status of an envelope later

  • Handle 402 responses (insufficient funds) explicitly with an alert to whoever manages your Inkless account balance

  • Use Laravel queues (or a job queue in plain PHP) for webhook-triggered downloads so your endpoint returns 200 OK within the required window

  • Validate recipient data before sending - a 422 response includes a detailed errors array identifying which fields failed

 

Inkless vs Other PHP E-Signature APIs

 

Feature

Inkless

BoldSign

DigiSigner

KAiZEN

Pricing model

Pay-as-you-go

Subscription

Subscription

Subscription

Authentication

API key (Bearer)

API key

API key

App token

UK data hosting

Yes

No

No

No

UK eIDAS compliance

Yes

No

Partial

No

Court-ready audit trail

Yes (download)

Yes

Basic

Basic

Sequential signing

Yes (routing_order)

Yes

Yes

Yes

SMS delivery

Yes (built-in)

No

No

No

Webhook events

12+ event types

Yes

Limited

Callback URL only

Balance/billing API

Yes (get_balance)

No

No

No

 

Frequently Asked Questions

What PHP version do I need?

PHP 7.4 or later is required. PHP 8.1+ is recommended - the match expression used in the webhook handler is a PHP 8.0 feature, so on older versions replace it with a switch statement.

Do I need to create document templates before I can use the API?

Yes. Document templates and email templates are configured in your Inkless dashboard and referenced by their integer IDs in every API call. However, you can replace the template's default file at send time by passing replacement_file_base64 and replacement_file_name, which is how you send dynamically generated PDFs.

How do I test without sending real emails or spending credits?

Use api@inkless.co.uk as the recipient email address. Inkless returns a realistic mock response in test mode, so you can verify that your code correctly handles tokens, stores them, and triggers webhook processing - all without consuming any signing credit.

Can multiple people sign the same document?

Yes. Add multiple objects to the recipients array, each with a unique recipient_number. Set enforce_routing_order: true and assign different routing_order values for sequential signing (each person signs only after the previous one completes). Assign the same routing_order to multiple recipients for parallel signing.

What happens if the envelope_token or document_token is lost?

There is no API endpoint to retrieve a token after the fact, so it is essential to store both tokens in your database immediately after a successful POST /api/send_link response. If a token is genuinely lost, contact Inkless support - do not attempt to re-send the document and create a duplicate envelope.

How does billing work?

Inkless charges per document sent, not per envelope or per user. If you send one envelope containing two separate documents, you are billed for two documents. Using the combine flag to merge documents into one counts as a single billable item. The send_link response always shows documents_billed, spent, and remaining so you can track usage in real time.

Conclusion

Integrating e-signature functionality into a PHP or Laravel application with Inkless involves a single API key, one POST call to send a document, a webhook handler to receive completion events, and token-based calls to download the signed PDF and audit log. The API is deliberately lean - there are no OAuth flows, no complex SDK trees to navigate, and no subscription commitments.

The code in this guide is based on the Inkless API documentation. API endpoints, template IDs, and pricing details may be updated over time, so always refer to the developer docs for the latest information.

Sign up for a free Inkless account to retrieve your API key and start testing with the sandbox email address, or contact the Inkless team if you have questions about a specific integration scenario.