Skip to content

Signaturit E-Signature

Integrates Signaturit for digitally signing supplier contracts. Every contract is fully executed by both parties: the supplier signs first, then a Volare admin countersigns. A single Signaturit signature request carries two recipients in sequential signing mode, and the final PDF embeds both signatures’ certificates.

  • Sending a supplier contract for digital signature from the Filament admin panel
  • Processing signature lifecycle events (partial signature, completion, decline, cancel) via webhooks
  • Manually syncing a contract’s signature state from Signaturit when the inbound webhook never arrived (see Manual Sync)
  • Downloading original or fully countersigned contract PDFs
SupplierContractService (orchestrator)
├── ContractPdfGenerator → DomPDF → local storage
│ └── Two anchors in PDF: __SIGN_HERE__ (supplier) + __SIGN_HERE_VOLARE__ (Volare)
├── SignaturitService → Signaturit REST API v3
│ └── Single request, recipients[0]=supplier, recipients[1]=Volare, signing_mode=sequential
├── handleSignaturitWebhook() → routes inbound events:
│ ├── document_signed → handlePartialSignature() (log only, no DB writes)
│ ├── document_completed → handleDocumentCompleted() ─┐
│ ├── document_declined → signaturit_status=declined │
│ └── document_canceled → signaturit_status=canceled │
├── syncSignatureStatus() → admin-triggered fallback: │
│ GET /signatures/{id}.json → reconcileFromSignaturitResponse()
│ ├── all docs completed → onSyncCompleted() ─────────┤
│ ├── any declined → onSyncTerminalStatus() │
│ ├── any canceled → onSyncTerminalStatus() │
│ ├── any expired → onSyncTerminalStatus() │
│ └── otherwise → returns pending (no DB writes)
└── finalizeSignedContract() ◄───── shared finalizer ◄───┘
(download signed PDF, set signed_at/signed_by, transition to Signed)
SignaturitWebhookController
└── POST /api/webhooks/signaturit → SupplierContractService
ContractPdfController (authenticated web routes)
├── GET /contracts/{contract}/pdf
└── GET /contracts/{contract}/signed-pdf

Both the webhook and manual-sync paths converge on finalizeSignedContract() and produce byte-identical end state.

Source files:

  • backend/app/Services/Signaturit/SignaturitService.php — API client (multi-recipient)
  • backend/app/Services/Signaturit/ContractPdfGenerator.php — PDF generation
  • backend/app/Services/Signaturit/SignaturitApiException.php — Error type
  • backend/app/Services/SupplierContractService.php — Orchestration (e-signature methods)
  • backend/app/Http/Controllers/Webhook/SignaturitWebhookController.php — Webhook handler
  • backend/app/Http/Controllers/Webhook/ContractPdfController.php — PDF download routes
  • backend/resources/views/pdf/supplier-contract.blade.php — PDF template with both anchors

Required env vars: SIGNATURIT_API_TOKEN, SIGNATURIT_BASE_URL

Optional env vars: SIGNATURIT_WEBHOOK_SECRET, SIGNATURIT_BRANDING_ID

Config file: config/services.php -> signaturit

VariablePurposeDefault
SIGNATURIT_API_TOKENBearer token for Signaturit API
SIGNATURIT_BASE_URLAPI base URL (sandbox or production)https://api.sandbox.signaturit.com/v3
SIGNATURIT_WEBHOOK_SECRETOptional token appended as ?token= query param for webhook validation
SIGNATURIT_BRANDING_IDOptional Signaturit branding template for email customization

In addition to the env vars, the Volare entity (managed at Markets → Volare Entity in Filament) must have an email set. That email becomes the Volare countersigner. Without it, sending a contract for signature fails fast.

  1. Admin opens a contract in Filament (Edit Supplier Contract page) and clicks Send for Signature
  2. SupplierContractService::sendForSignature() validates that:
    • The contract is in Draft or Sent status
    • It has at least one service line item
    • The supplier resolves to an email address
    • VolareEntity::current()->email is set (otherwise it throws InvalidArgumentException: “Volare entity has no email configured. Set one at Markets → Volare Entity before sending the contract.”)
  3. ContractPdfGenerator::generate() renders pdf.supplier-contract Blade template via DomPDF. The agreement section contains two side-by-side signature blocks with text anchors: __SIGN_HERE_VOLARE__ on the left and __SIGN_HERE__ on the right
  4. PDF is stored locally at contracts/{id}/contract-{reference}.pdf
  5. SignaturitService::createSignatureRequest() uploads the PDF with two recipients and signing_mode=sequential:
    • recipients[0] — supplier, widget pinned to __SIGN_HERE__
    • recipients[1] — Volare, widget pinned to __SIGN_HERE_VOLARE__
  6. Contract record is updated with signaturit_signature_id, signaturit_document_id, signaturit_status, and signaturit_sent_at
  7. If the contract was in Draft, it transitions to Sent
  8. Sequential signing: the supplier (index 0) is emailed first. Only after the supplier signs does Signaturit email the Volare countersigner (index 1)
  9. Each time a recipient signs, Signaturit sends POST /api/webhooks/signaturit. SupplierContractService::handleSignaturitWebhook() routes it:
    • document_signed — one recipient signed; routed to handlePartialSignature(), which only logs. The contract stays at Sent (no DB writes, no status transition, no PDF download)
    • document_completed — every recipient has signed; routed to handleDocumentCompleted() which delegates to the shared finalizeSignedContract() helper. That downloads the fully countersigned PDF, stores it at contracts/{id}/signed-{reference}.pdf, sets signed_at/signed_by, and transitions the contract to Signed
    • document_declined — sets signaturit_status to declined, logs the event
    • document_canceled — sets signaturit_status to canceled

The webhook endpoint at POST /api/webhooks/signaturit uses optional token-based validation:

  • If SIGNATURIT_WEBHOOK_SECRET is set, the webhook URL includes ?token={secret} and the controller rejects requests without a matching token
  • If no secret is configured, all requests are accepted (suitable for sandbox/local development)
  • The webhook URL automatically excludes events_url when the URL contains localhost to avoid Signaturit delivery errors

Use this when the inbound webhook never arrived and the contract is stuck in Sent despite both parties having signed — the canonical case is the production AWS WAF blocking Signaturit’s delivery (see callout above).

The “Sync from Signaturit” header action on the contract edit page calls Signaturit’s API directly, inspects the envelope state, and runs the same finalization the webhook would have run. There is no scheduled auto-sync and no artisan backfill command — recovery is admin-triggered only.

SupplierContractService::syncSignatureStatus(SupplierContract, SignaturitService): array is the entry point. It:

  1. Guards. Throws InvalidArgumentException if the contract has no signaturit_signature_id, or if it is already finalized (Signed, Active, Expired, Terminated).
  2. Fetches state. Calls SignaturitService::getSignature($id)GET /signatures/{id}.json.
  3. Aggregates status from the V3 response (see Response shape).
  4. Branches:
    • All documents[].status === 'completed' → calls finalizeSignedContract() — the same helper used by the webhook. Downloads the signed PDF, sets signed_at, transitions Sent → Signed. Returns ['status' => 'completed'].
    • Any declined / canceled / expired → mirrors that terminal state into signaturit_status, logs, returns ['status' => '<that-status>']. Does not transition the contract status itself.
    • Otherwise → returns ['status' => 'pending', 'signaturit_status' => '<aggregated-pending-state>'] with no DB writes.

The shared finalizeSignedContract() helper guarantees the manual-sync path produces byte-identical end state to the webhook path: same files stored, same DB writes, same status transition. Webhook tests still pass unchanged because handleDocumentCompleted() was only refactored to delegate to this helper.

GET /signatures/{id}.json has no top-level status field. The envelope state must be aggregated from the per-recipient documents[].status array (typical values: ready, in_progress, signed, completed, declined, canceled, expired).

{
"id": "abc123...",
"documents": [
{ "id": "doc-supplier", "status": "completed" },
{ "id": "doc-volare", "status": "completed" }
]
}

This was discovered while testing against a real signed contract — earlier code that read a top-level status field would have silently failed. reconcileFromSignaturitResponse() only walks documents[].status. Don’t add a top-level status lookup back.

  • Production contract is in Sent and the supplier confirms they (and Volare) signed days ago.
  • Local development without a public webhook URL (when you don’t want to replay a curl payload manually).
  • After investigating any 403 / 502 on the inbound webhook in the logs.

Source: backend/app/Services/SupplierContractService.php (syncSignatureStatus, reconcileFromSignaturitResponse, finalizeSignedContract, onSyncCompleted, onSyncTerminalStatus).

The Edit Supplier Contract page (EditSupplierContract.php) provides header actions:

ActionVisible WhenEffect
Send for SignatureDraft/Sent, no pending signature, supplier has email, Volare entity has emailGenerates PDF with both anchors, sends a single sequential request to Signaturit with supplier + Volare recipients. If the Volare entity has no email, surfaces a Filament warning notification: “Volare entity has no email configured. Set one at Markets → Volare Entity before sending the contract.”
Resend EmailPending signature exists (hasPendingSignature())Calls POST /signatures/{id}/reminder.json — same signaturit_signature_id, no PDF re-upload; updates signaturit_sent_at
Sync from SignaturitPending signature existsCalls GET /signatures/{id}.json, aggregates documents[].status, runs finalizeSignedContract() if every recipient completed — otherwise mirrors a terminal state or reports the contract is still in progress. See Manual Sync. After a successful sync, the existing Download Signed PDF action becomes visible automatically because signed_pdf_path is now populated
Cancel SignaturePending signature existsCancels via Signaturit API
Download Contract PDFcontract_pdf_path is setDownloads the generated PDF
Download Signed PDFsigned_pdf_path is setDownloads the fully countersigned PDF (both certificates embedded)

Source: backend/app/Filament/Resources/Suppliers/SupplierContracts/Pages/EditSupplierContract.php

Contract PDFs include the company’s legal information from the VolareEntity model (single-row table volare_entities). It is also the source for the Volare countersigner in the e-signature flow. Managed via a dedicated Filament page at Markets > Volare Entity (admin-only).

Fields: legal_name, trade_name, tax_id, address, email, phone, website.

VolareEntity::current() returns the first (and only) row.

Source: backend/app/Models/VolareEntity.php, backend/app/Filament/Pages/VolareEntityPage.php

Defined in SupplierContractStatus enum. The e-signature flow touches these transitions:

Draft -> Sent (on sendForSignature)
Sent -> Signed (on document_completed webhook -- both parties have signed)

A document_signed event (only one party has signed) does not advance the status. The contract stays at Sent until document_completed fires.

Full lifecycle: Draft -> Sent -> Negotiating -> Signed -> Active -> Expired/Terminated

Source: backend/app/Enums/SupplierContractStatus.php

The signature request always carries two recipients.

Resolved from the supplier in this priority order:

  1. commercial_contact_name / commercial_contact_email
  2. contact_person / email
  3. name (name only fallback)

If no email is available, sendForSignature() throws InvalidArgumentException (“Supplier must have an email address to receive the signature request.”).

Resolved from VolareEntity::current():

  • Email: volareEntity->email (required — hard failure if missing)
  • Name: trade_namelegal_name'Volare' (fallback chain)

If VolareEntity::current() is null or its email is empty, sendForSignature() throws InvalidArgumentException: “Volare entity has no email configured. Set one at Markets → Volare Entity before sending the contract.” The Filament action catches this and surfaces it as a warning notification.

The Blade template at backend/resources/views/pdf/supplier-contract.blade.php renders an “Agreement” section near the end with two side-by-side signature blocks inside a .signatures-table:

Left columnRight column
Title: Volare, anchor: __SIGN_HERE_VOLARE__Title: Supplier, anchor: __SIGN_HERE__

Each block is intentionally minimal — a small .signature-title label and a .sign-anchor containing only the anchor text (no name, title, or date lines). The anchors are hidden from view via the .sign-anchor { color: #ccc; font-size: 9px; } style but remain machine-readable so Signaturit can pin each recipient’s widget to the correct slot via word_anchor.

The @verbatim ... @endverbatim directive is used in Blade to keep the underscores raw.

The webhook URL automatically excludes events_url when it contains localhost, so Signaturit will not call your machine. Two options to exercise the finalization path locally:

  1. Click “Sync from Signaturit” on the contract edit page — hits the real Signaturit API and runs the same finalizer the webhook would have. This is also the path used in production when WAF blocks the inbound webhook.
  2. Replay a webhook payload manually — target document_completed (not document_signed), since document_signed only logs and document_completed is what flips the contract to Signed.
Terminal window
# Replay a document_completed event against your local app
curl -X POST 'http://localhost/api/webhooks/signaturit?token=YOUR_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"type": "document_completed",
"signature": { "id": "SIGNATURIT_SIGNATURE_ID" },
"document": { "id": "SIGNATURIT_DOCUMENT_ID", "email": "supplier@example.com" }
}'

Replace SIGNATURIT_SIGNATURE_ID / SIGNATURIT_DOCUMENT_ID with values from the contract row (signaturit_signature_id / signaturit_document_id). Drop the ?token= query if SIGNATURIT_WEBHOOK_SECRET is unset. Sending type: document_signed instead is also useful to confirm partial events are logged and do not finalize the contract.

  • Suppliers Service — Supplier data model and contracts
  • Source: backend/app/Services/Signaturit/SignaturitService.php
  • Source: backend/app/Services/SupplierContractService.php
  • Blade template: backend/resources/views/pdf/supplier-contract.blade.php