DEV Community

Zakir Hossen
Zakir Hossen

Posted on

Building an In-App E-Signature Flow Without DocuSign (Laravel + React + Canvas)

A customer of mine was printing offer letters, signing them with a pen, scanning them on a flatbed, and emailing them back. In 2026. Inside an app I built for him.

So last week I shipped a proper offer-letter workflow with an in-browser e-signature surface — no DocuSign, no third-party embed, no bolt-on fees. Just a <canvas>, a signed payload, and a stored PDF.

Here's what I learned. The stack is Laravel 12 + Inertia v2 + React 19 + Mantine + Tailwind v4, but most of this is transferable.

The problem in one paragraph

An offer letter flow has four moving parts: (1) a templated document with variables (name, salary, start date), (2) a public URL a candidate can open without logging in, (3) a signing surface that works on phone and desktop, and (4) a signed PDF that gets archived and is legally defensible. None of these are hard individually. The interesting part is where they collide.

1. The public signing URL — stop being clever

First instinct: JWTs. Signed URLs. Short expiry. A whole dance.

What I actually shipped: a random 64-char token on the offers table, looked up on a route-model-binding where('token', $token)->firstOrFail(). That's it.

// routes/web.php
Route::get('/offer/{token}', [CandidateOfferController::class, 'show']);
Route::post('/offer/{token}/accept', [CandidateOfferController::class, 'accept']);
Enter fullscreen mode Exit fullscreen mode

The token is the secret. No login, no account creation, no email confirmation round-trip. The candidate clicks the link and sees the letter. That's the entire auth model for the candidate side.

If you're thinking "but what about security" — the token has ~380 bits of entropy, the route is HTTPS only, and every action is idempotent and logged. DocuSign's own "anyone with the link can sign" flow has the same threat model. Don't over-engineer this.

2. The signature canvas — dark mode will bite you

I used react-signature-canvas, which wraps the excellent signature_pad library. The naive integration:

<SignatureCanvas
  penColor="var(--mantine-color-text)"
  canvasProps={{ className: 'w-full h-48 border rounded' }}
/>
Enter fullscreen mode Exit fullscreen mode

This silently fails in dark mode.

The <canvas> 2D context cannot resolve CSS custom properties. It sees var(--mantine-color-text) as a literal string, fails to parse it, and falls back to... nothing visible. The user draws, the strokes are captured, but they're rendered in a color the canvas can't compute. You get a "signature captured!" toast and a completely blank canvas.

The fix is to resolve the color at runtime:

import { useComputedColorScheme } from '@mantine/core';

const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
const penColor = colorScheme === 'dark' ? '#f1f3f5' : '#1a1a1a';
const canvasBg = colorScheme === 'dark' ? '#1a1b1e' : '#ffffff';

<SignatureCanvas
  penColor={penColor}
  backgroundColor={canvasBg}
  canvasProps={{ className: 'w-full h-48 border rounded' }}
/>
Enter fullscreen mode Exit fullscreen mode

Canvas doesn't know about CSS. Always pass hex. This cost me an embarrassing amount of debugging time.

3. Type-to-sign — the 40% of users who hate drawing

Half your users are on a laptop with a trackpad. Drawing a signature with a trackpad looks like a seismograph readout. You need a second input mode: type your name, render it in a cursive font.

const TYPED_FONT = '"Dancing Script", "Brush Script MT", "Lucida Handwriting", cursive';

function renderTypedSignature(canvas: HTMLCanvasElement, text: string) {
  const ctx = canvas.getContext('2d')!;
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  let fontSize = 64;
  ctx.font = `${fontSize}px ${TYPED_FONT}`;

  // Auto-fit: shrink until it fits
  while (ctx.measureText(text).width > canvas.width - 40 && fontSize > 24) {
    fontSize -= 2;
    ctx.font = `${fontSize}px ${TYPED_FONT}`;
  }

  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillStyle = penColor;
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
Enter fullscreen mode Exit fullscreen mode

Two gotchas here:

1. Load the font explicitly. Don't assume Dancing Script is on the user's system. Load it via <Head> in Inertia:

<Head>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link
    href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@600&display=swap"
    rel="stylesheet"
  />
</Head>
Enter fullscreen mode Exit fullscreen mode

2. Export the same format as the drawn version. Call canvas.toDataURL('image/png') in both modes. Your backend then has a single format to handle. The user picks between Draw and Type via a <SegmentedControl>, and the output is identical from the server's perspective.

4. The signed payload — keep it boring

OfferSignature::create([
    'offer_id'       => $offer->id,
    'signer_name'    => $request->input('signer_name'),
    'signer_email'   => $request->input('signer_email'),
    'signature_data' => $request->input('signature_data'), // PNG dataURL
    'ip_address'     => $request->ip(),
    'user_agent'     => $request->userAgent(),
    'signed_at'      => now(),
]);

$offer->update([
    'status'      => OfferStatus::Accepted,
    'accepted_at' => now(),
]);
Enter fullscreen mode Exit fullscreen mode

IP + user agent + timestamp is the legal audit trail. You don't need a blockchain. You don't need KBA. For employment offers, this is the standard the US ESIGN Act and EU eIDAS Simple Electronic Signature actually require. Ask a lawyer for your jurisdiction before you believe me, but the legal bar is much lower than the SaaS marketing suggests.

5. The signed PDF — generate it, store it, forget it

Once the candidate signs, kick off a job that:

  1. Renders the letter HTML to PDF (I use spatie/browsershot).
  2. Embeds the signature image at the bottom with the signer's name, typed timestamp, and IP.
  3. Stores it in object storage (Cloudflare R2 in my case — free egress is the whole game).
  4. Makes it downloadable from both the employer dashboard and the candidate's public URL.
class GenerateOfferPdfService
{
    public function generateAndStore(Offer $offer): string
    {
        $html = $this->renderHtml($offer);
        $pdf  = Browsershot::html($html)->format('A4')->pdf();

        $path = "offers/{$offer->id}/signed.pdf";
        Storage::disk('r2')->put($path, $pdf);

        return $path;
    }
}
Enter fullscreen mode Exit fullscreen mode

6. The pipeline trigger — where the magic actually is

Here's the bit that separates "e-signature tool" from "offer-letter workflow." When the candidate signs, I also fire a state transition on the job application:

public function execute(Offer $offer, array $signatureData): void
{
    // ... create signature, update status ...

    $application = $offer->jobApplication;
    if ($application) {
        $hiredStage = $application->job->stages()
            ->where('slug', 'hired')
            ->first();

        if ($hiredStage) {
            $application->update(['status_id' => $hiredStage->id]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The candidate slides from "Offer Sent" to "Hired" in the Kanban board the moment they sign. The employer doesn't touch anything. This one line of code is the reason "offer letter as a feature of the ATS" is 10x better than "offer letter as a standalone SaaS."

The lesson

The interesting part of building this wasn't any single piece. It was that the seams between pieces are where products actually differentiate. A standalone e-signature tool can't move a candidate to "Hired" because it doesn't know what a pipeline is. A standalone ATS can't reliably track a signed offer because it doesn't own the signing surface. Owning both means you get to delete five manual steps from the workflow, and nobody else can.

If you're building in a mature SaaS market and you can't find a differentiator — look at the integrations your users are duct-taping together, and build one of them natively. The seams are where the leverage lives.


I'm Zakir, solo founder of JuggleHire. We shipped this feature yesterday and launched it on Product Hunt today. If you want to see the flow in action or roast my code, the app is at jugglehire.com.

Non-technical write-up on the "why" is on my blog.

Top comments (0)