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.
When to Use
Section titled “When to Use”- 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
Architecture
Section titled “Architecture”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-pdfBoth 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 generationbackend/app/Services/Signaturit/SignaturitApiException.php— Error typebackend/app/Services/SupplierContractService.php— Orchestration (e-signature methods)backend/app/Http/Controllers/Webhook/SignaturitWebhookController.php— Webhook handlerbackend/app/Http/Controllers/Webhook/ContractPdfController.php— PDF download routesbackend/resources/views/pdf/supplier-contract.blade.php— PDF template with both anchors
Configuration
Section titled “Configuration”Required env vars: SIGNATURIT_API_TOKEN, SIGNATURIT_BASE_URL
Optional env vars: SIGNATURIT_WEBHOOK_SECRET, SIGNATURIT_BRANDING_ID
Config file: config/services.php -> signaturit
| Variable | Purpose | Default |
|---|---|---|
SIGNATURIT_API_TOKEN | Bearer token for Signaturit API | — |
SIGNATURIT_BASE_URL | API base URL (sandbox or production) | https://api.sandbox.signaturit.com/v3 |
SIGNATURIT_WEBHOOK_SECRET | Optional token appended as ?token= query param for webhook validation | — |
SIGNATURIT_BRANDING_ID | Optional 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.
Signature Flow
Section titled “Signature Flow”- Admin opens a contract in Filament (Edit Supplier Contract page) and clicks Send for Signature
SupplierContractService::sendForSignature()validates that:- The contract is in
DraftorSentstatus - It has at least one service line item
- The supplier resolves to an email address
VolareEntity::current()->emailis set (otherwise it throwsInvalidArgumentException: “Volare entity has no email configured. Set one at Markets → Volare Entity before sending the contract.”)
- The contract is in
ContractPdfGenerator::generate()renderspdf.supplier-contractBlade 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- PDF is stored locally at
contracts/{id}/contract-{reference}.pdf SignaturitService::createSignatureRequest()uploads the PDF with two recipients andsigning_mode=sequential:recipients[0]— supplier, widget pinned to__SIGN_HERE__recipients[1]— Volare, widget pinned to__SIGN_HERE_VOLARE__
- Contract record is updated with
signaturit_signature_id,signaturit_document_id,signaturit_status, andsignaturit_sent_at - If the contract was in
Draft, it transitions toSent - Sequential signing: the supplier (index 0) is emailed first. Only after the supplier signs does Signaturit email the Volare countersigner (index 1)
- Each time a recipient signs, Signaturit sends
POST /api/webhooks/signaturit.SupplierContractService::handleSignaturitWebhook()routes it:document_signed— one recipient signed; routed tohandlePartialSignature(), which only logs. The contract stays atSent(no DB writes, no status transition, no PDF download)document_completed— every recipient has signed; routed tohandleDocumentCompleted()which delegates to the sharedfinalizeSignedContract()helper. That downloads the fully countersigned PDF, stores it atcontracts/{id}/signed-{reference}.pdf, setssigned_at/signed_by, and transitions the contract toSigneddocument_declined— setssignaturit_statustodeclined, logs the eventdocument_canceled— setssignaturit_statustocanceled
Webhook Security
Section titled “Webhook Security”The webhook endpoint at POST /api/webhooks/signaturit uses optional token-based validation:
- If
SIGNATURIT_WEBHOOK_SECRETis 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_urlwhen the URL containslocalhostto avoid Signaturit delivery errors
Manual Sync (Webhook Bypass)
Section titled “Manual Sync (Webhook Bypass)”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.
Behaviour
Section titled “Behaviour”SupplierContractService::syncSignatureStatus(SupplierContract, SignaturitService): array is the entry point. It:
- Guards. Throws
InvalidArgumentExceptionif the contract has nosignaturit_signature_id, or if it is already finalized (Signed,Active,Expired,Terminated). - Fetches state. Calls
SignaturitService::getSignature($id)—GET /signatures/{id}.json. - Aggregates status from the V3 response (see Response shape).
- Branches:
- All
documents[].status === 'completed'→ callsfinalizeSignedContract()— the same helper used by the webhook. Downloads the signed PDF, setssigned_at, transitionsSent → Signed. Returns['status' => 'completed']. - Any
declined/canceled/expired→ mirrors that terminal state intosignaturit_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.
- All
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.
Signaturit V3 response shape
Section titled “Signaturit V3 response shape”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.
When to use it
Section titled “When to use it”- Production contract is in
Sentand 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/502on the inbound webhook in the logs.
Source: backend/app/Services/SupplierContractService.php (syncSignatureStatus, reconcileFromSignaturitResponse, finalizeSignedContract, onSyncCompleted, onSyncTerminalStatus).
Filament UI Actions
Section titled “Filament UI Actions”The Edit Supplier Contract page (EditSupplierContract.php) provides header actions:
| Action | Visible When | Effect |
|---|---|---|
| Send for Signature | Draft/Sent, no pending signature, supplier has email, Volare entity has email | Generates 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 Email | Pending signature exists (hasPendingSignature()) | Calls POST /signatures/{id}/reminder.json — same signaturit_signature_id, no PDF re-upload; updates signaturit_sent_at |
| Sync from Signaturit | Pending signature exists | Calls 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 Signature | Pending signature exists | Cancels via Signaturit API |
| Download Contract PDF | contract_pdf_path is set | Downloads the generated PDF |
| Download Signed PDF | signed_pdf_path is set | Downloads the fully countersigned PDF (both certificates embedded) |
Source: backend/app/Filament/Resources/Suppliers/SupplierContracts/Pages/EditSupplierContract.php
Volare Entity
Section titled “Volare Entity”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
Contract Status Lifecycle
Section titled “Contract Status Lifecycle”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
Recipient Resolution
Section titled “Recipient Resolution”The signature request always carries two recipients.
Supplier resolution
Section titled “Supplier resolution”Resolved from the supplier in this priority order:
commercial_contact_name/commercial_contact_emailcontact_person/emailname(name only fallback)
If no email is available, sendForSignature() throws InvalidArgumentException (“Supplier must have an email address to receive the signature request.”).
Volare resolution
Section titled “Volare resolution”Resolved from VolareEntity::current():
- Email:
volareEntity->email(required — hard failure if missing) - Name:
trade_name→legal_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.
PDF Template
Section titled “PDF Template”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 column | Right 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.
Local Development
Section titled “Local Development”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:
- 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.
- Replay a webhook payload manually — target
document_completed(notdocument_signed), sincedocument_signedonly logs anddocument_completedis what flips the contract toSigned.
# Replay a document_completed event against your local appcurl -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.
Related
Section titled “Related”- 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