Audience: end users, customer support, admins Difficulty: new user
What this covers
How Solabooks generates and validates the one-tap document links that let a customer view an invoice, quote, sales order, or related document without logging in. Plus the two URL patterns, expiry rules, recovery flow, throttle limits, and security model.
URL patterns
Two URL prefixes serve the same controller and the same token format:
| Pattern | Status | Example |
|---|---|---|
https://solavel.com/finance/i/{token} |
Canonical. Use this for new emails. | …/finance/i/eyJ…signed…token |
https://solavel.com/finance/portal/{token} |
Legacy alias. Still works for existing emails. | …/finance/portal/eyJ…signed…token |
Both are wired to App\Http\Controllers\Solabooks\Portal\Secure\PortalController
in routes/web.php. The newer /i/ path is what the link generator
emits today; the older /portal/ path remains for back-compatibility.
Available endpoints under each prefix:
| URL | Method | Purpose |
|---|---|---|
/{token} |
GET | View the document |
/{token}/pdf |
GET | Download the PDF |
/{token}/end |
POST | Customer ends their session |
/{token}/settings |
GET | Customer settings (set/change a portal password) |
/{token}/settings/password |
PUT | Update password |
/{token}/quotes/{quote}/accept |
POST | Accept a quoted price |
/{token}/quotes/{quote}/decline |
POST | Decline a quote |
/{token}/request-access |
POST | Customer asks for a fresh link (recovery) |
/{token}/send-again |
POST | Staff resends a fresh link (recovery) |
How a customer receives a link
- A staff user opens an invoice (or other document) and clicks Send to Customer.
- The system calls
PortalLinkService::createLink(). This generates a raw token and stores only its SHA-256 hash incustomer_portal_links.token_hash. - The token format is
<tenantHint>.<signature>.<secret>— three parts separated by dots:<tenantHint>— encrypted database name; lets the request boot the right tenant before the lookup.<signature>— 16-char HMAC of the tenant hint, computed withapp.key. Asserts the link was issued by this app.<secret>— 64+ char URL-safe random string. The actual entropy.
- An email is sent to the customer's address with the URL.
- The customer clicks. The
portal.tokenmiddleware (ResolvePortalToken) parses the token, verifies the signature, boots the tenant connection, hashes the secret, and looks up the matchingcustomer_portal_linkrow. Mismatch ⇒ 404. Expired or revoked ⇒ "This portal link is no longer active."
PDF download
The /{token}/pdf route streams the document as a PDF using the
organization's configured PDF template (gated on pdf_templates_enabled).
The same token authorises both the HTML view and the PDF.
The PDF inherits the same expiry as the link — once the link is dead, the PDF link is dead. There is no separate signed PDF URL.
Expiry — the 24-hour rule
Even if a custom expiry is requested, the system overrides it to 24 hours from creation. This is intentional for security but caught teams out.
The PortalLinkService::createLink() signature accepts a
CarbonInterface $ignoredExpiresAt argument. The parameter name
documents what happens to it: it is silently overridden inside the
method to now()->addDay(). The actual line of code is
(app/Services/Portal/PortalLinkService.php, around line 52):
$sendExpiryAt = now()->addDay();
What this means in practice:
- A new link issued at 10:00 today expires at 10:00 tomorrow.
- A caller passing
now()->addWeek()thinks they got a 7-day link. They actually got 24 hours. - The
createRenewedLink()method (used by send-again) is explicit about this — its inline comment reads "Every regenerated access link expires 24 hours from send time." - Operations and integrations cannot extend the window without changing the service code.
If you need a longer-lived window for a single customer, use the Customer Portal Account flow instead — it is session-based and persists until the customer is disabled or signs out. See overview.
Throttle limits
| Bucket | Limit | Used by |
|---|---|---|
portal-public |
per-IP, configured in RouteServiceProvider |
GET /{token}, GET /{token}/pdf, POST /{token}/end, GET|PUT /{token}/settings*, accept/decline quote |
portal-recovery |
tighter per-IP | POST /{token}/request-access, POST /{token}/send-again |
quotes-public |
separate from portal | GET|POST /q/{token}/* (the alternative public-quote URL) |
Hitting the throttle returns 429. The message is generic — there is no hint to the requester that they were rate-limited; they are advised to wait and retry.
Request-access / send-again recovery
When the customer opens an expired or revoked link, the page renders with a Request access button.
POST /{token}/request-access— open to anyone with the token (even expired). The handler resolves the customer, finds the active link for the same document if one exists, and emails the customer a fresh URL. If no active link exists, it issues a renewed link viacreateRenewedLink()(which revokes any prior unused links for the same(organization, customer, document)triple).POST /{token}/send-again— staff-facing variant invoked from the document edit page.
Both paths use the portal-recovery throttle bucket so an attacker
cannot spam the customer's inbox.
Security notes
- Token storage. Only the SHA-256 hash is stored. Even with full DB read, the raw token is not recoverable.
- Tenant binding. The token carries an encrypted hint to its tenant DB. A token issued for tenant A cannot resolve a row in tenant B's table even if the hash collides — the wrong DB is connected before the lookup.
- Signature. Each token is HMAC-signed with
app.key. Tampering with any part fails the signature check before the DB is queried. - Cross-org check. Even after a token resolves, the service
verifies that the document's
organization_id, the customer'sorganization_id, and the link'sorganization_idare all the same number. A token issued for one org of a tenant cannot be reused for another org of the same tenant. - Document/customer match. If the document carries a
customer_id, it must match the link'scustomer_id. Re-assigning a document to a different customer invalidates outstanding links. - Revocation. Setting
revoked_atis enough — the link is dead even before its 24-hour clock runs out. Revocations are immediate (no caching).
Failure responses
| Condition | Response |
|---|---|
| Wrong token format | "Invalid portal link format." |
| Bad signature | "Token signature verification failed." |
| Tenant DB cannot boot | "Tenant database is not initialized." |
| Token not found | "Invalid or expired portal link." |
| Link revoked or past 24-hour expiry | "This portal link is no longer active." |
| Document missing | "Portal document was not found." |
| Customer missing | "Customer was not found for this portal link." |
| Org mismatch | "Portal scope validation failed." |
| Customer mismatch on document | "Document/customer mismatch for this portal link." |
All errors render via ValidationException and surface to the customer
as a generic "this link is no longer active" page — the specific cause
is logged but not shown.