GAP School Module 05 — Customer Portal Lesson 5.4

The document exchange problem is simple to describe and annoying to solve well. Customers need to receive and submit documents — title paperwork, financing agreements, inspection reports, purchase agreements. Before the portal, this happened over email attachments: no version control, no access logging, no single source of truth, and documents going to personal email addresses where they lived forever and couldn’t be revoked.


The situation

A typical transaction at the Anchor dealership involved 4–6 documents: purchase agreement, title, bill of sale, financing paperwork, inspection report, consignment agreement. These were emailed as PDF attachments, returned signed via reply, saved to a shared drive folder with inconsistent naming, and occasionally lost. When a customer called asking for a copy of their purchase agreement from 18 months ago, finding it required searching multiple places.


What I did

Document storage

Documents are stored in WordPress media in a private directory not publicly accessible. Each document is associated with a specific owned_unit record and tagged with the document type. MIME type is validated from the file’s actual bytes, not the extension:

Document attach — MIME validation + private meta + conversation log
function [client]_attach_document_to_transaction( string $file_path, string $original_filename, int $lead_id, int $unit_id, string $doc_type ): int|false { // Validate file type from bytes — not extension $allowed_types = [ 'application/pdf', 'image/jpeg', 'image/png' ]; $mime_type = mime_content_type( $file_path ); if ( ! in_array( $mime_type, $allowed_types, true ) ) { return false; } // Validate file size — 20MB max if ( filesize( $file_path ) > 20 * MB_IN_BYTES ) { return false; } $attachment_id = media_handle_sideload( [ 'name' => sanitize_file_name( $original_filename ), 'tmp_name' => $file_path, ], 0 ); if ( is_wp_error( $attachment_id ) ) return false; // Mark as private — not accessible via direct URL update_post_meta( $attachment_id, '[client]_doc_private', '1' ); update_post_meta( $attachment_id, '[client]_doc_type', sanitize_key( $doc_type ) ); update_post_meta( $attachment_id, '[client]_doc_lead_id', $lead_id ); update_post_meta( $attachment_id, '[client]_doc_unit_id', $unit_id ); [client]_add_conversation( $lead_id, 'system', "Document attached: {$doc_type} — {$original_filename}", 'Document upload' ); return $attachment_id; }

Signed download URLs

Documents are not served from their direct media URL. The portal generates a signed HMAC token valid for a limited time window, verifiable server-side without a database lookup:

Signed token — HMAC-SHA256 with TTL + hash_equals for constant-time verification
function [client]_generate_document_token( int $attachment_id, int $ttl_seconds = 3600 ): string { $expires = time() + $ttl_seconds; $secret = wp_salt( 'auth' ); $sig = hash_hmac( 'sha256', $attachment_id . ':' . $expires, $secret ); return base64_encode( json_encode( [ 'id' => $attachment_id, 'exp' => $expires, 'sig' => $sig ] ) ); } function [client]_verify_and_serve_document( string $token ): void { $data = json_decode( base64_decode( $token ), true ); $attachment_id = (int) ( $data['id'] ?? 0 ); $expires = (int) ( $data['exp'] ?? 0 ); $sig = $data['sig'] ?? ''; if ( ! $attachment_id || ! $expires || ! $sig ) { wp_die( 'Invalid token.', 400 ); } if ( time() > $expires ) { wp_die( 'Link expired. Please request a new download link from the portal.', 410 ); } $secret = wp_salt( 'auth' ); $expected = hash_hmac( 'sha256', $attachment_id . ':' . $expires, $secret ); // hash_equals() is constant-time — === is vulnerable to timing attacks if ( ! hash_equals( $expected, $sig ) ) { wp_die( 'Invalid token.', 403 ); } // Verify portal user owns this document $doc_lead_id = (int) get_post_meta( $attachment_id, '[client]_doc_lead_id', true ); $user_lead = (int) get_user_meta( get_current_user_id(), '[client]_portal_lead_id', true ); if ( $doc_lead_id !== $user_lead ) { wp_die( 'Access denied.', 403 ); } // Serve the file $file_path = get_attached_file( $attachment_id ); $mime_type = get_post_mime_type( $attachment_id ); header( 'Content-Type: ' . $mime_type ); header( 'Content-Disposition: attachment; filename="' . basename( $file_path ) . '"' ); header( 'Content-Length: ' . filesize( $file_path ) ); header( 'Cache-Control: private, no-cache' ); readfile( $file_path ); exit; }

Customer-side upload

Customers can upload documents from the portal — signed paperwork, additional photos, any document staff requested. The upload handler validates file type, size, and confirms the user is uploading to a transaction that belongs to them:

Customer upload handler — nonce + ownership check + staff notification
add_action( 'wp_ajax_[client]_portal_upload', '[client]_handle_portal_document_upload' ); function [client]_handle_portal_document_upload() { if ( ! wp_verify_nonce( $_POST['_wpnonce'], '[client]_portal_upload' ) ) { wp_send_json_error( 'Security check failed.' ); return; } $unit_id = (int) ( $_POST['unit_id'] ?? 0 ); $doc_type = sanitize_key( $_POST['doc_type'] ?? 'customer_upload' ); // Verify ownership $lead_id = (int) get_user_meta( get_current_user_id(), '[client]_portal_lead_id', true ); if ( ! $lead_id || ! $unit_id ) { wp_send_json_error( 'Invalid request.' ); return; } if ( ! isset( $_FILES['document'] ) || $_FILES['document']['error'] !== UPLOAD_ERR_OK ) { wp_send_json_error( 'Upload failed.' ); return; } $attachment_id = [client]_attach_document_to_transaction( $_FILES['document']['tmp_name'], $_FILES['document']['name'], $lead_id, $unit_id, $doc_type ); if ( ! $attachment_id ) { wp_send_json_error( 'Invalid file type or size.' ); return; } // Notify staff of customer upload wp_mail( SALES_EMAIL, '[Portal] Customer document uploaded: ' . get_the_title( $unit_id ), "Lead #{$lead_id} uploaded a document ({$doc_type}) for unit " . get_the_title( $unit_id ), [ 'Content-Type: text/html; charset=UTF-8' ] ); wp_send_json_success( [ 'attachment_id' => $attachment_id ] ); }

Why it matters

Signed time-limited URLs solve the revocation problem. If a document is sent via email, it exists in the recipient’s inbox indefinitely and cannot be revoked. A portal download link that expires in 1 hour can be regenerated when needed and is invalid outside that window.

The ownership check in verify_and_serve_document() is critical. Without it, anyone who obtains a valid token (forwarded link, shared URL) can download any document — a customer’s purchase agreement, another consignor’s contract. The lead ID comparison ensures the serving user is authorized for that specific document.


The Anchor build

The document exchange replaced the email attachment workflow for all new transactions starting in month one after portal launch. Average document turnaround dropped from 3.2 days (email attachment, sign, scan, email back) to same-day for customers who used the portal. Staff spent about 4 fewer hours per week on document-related email back-and-forth.

The one-hour token TTL was reduced from 24 hours after a staff member forwarded a download link to a personal email to check something on their phone. That link was technically valid for 24 hours in an uncontrolled context. One hour is the right default — customers can regenerate instantly if they need the file again.


Do this, not that

  • Never serve documents from their direct media URL. WordPress media URLs are publicly accessible by default if someone knows the path. Private documents must be served through an authenticated, signed request.
  • Validate file type server-side using mime_content_type(), not file extension. Extensions are trivially faked. MIME type from the file’s actual bytes is the correct validation.
  • Use hash_equals() for signature comparison, not ===. hash_equals() is constant-time. === is vulnerable to timing attacks on the signature comparison.
  • Set a short token TTL (1 hour) and make regeneration easy. Long-lived tokens are indistinguishable from permanent access. Short TTL + easy regeneration gives you revocability without friction.
  • Log every upload and download to the activity timeline. Document access should be auditable. When a dispute arises about whether a customer received a document, the activity log is the answer.
When you’re ready to build

The lessons are yours. When you want it built, we’re here.

Every lesson stays free — no account, no paywall, no email gate, ever. But if you’d rather have this system standing on your business than wire all 48 lessons yourself, leave your email. We’ll send you a direct line to a build — and you’ll be first to hear when we add new tools to the curriculum.

None of this gates a single lesson. The curriculum was free before you got here and it stays that way.

We’ll use your email to send you a fast-track to a GAP build and occasional notes on how GAP builds digital sales departments. Lessons stay 100% free — no email required to read any of them. We never share or sell your information. Unsubscribe any time. Privacy policy at gapindustriesllc.com/privacy.html.

Done learning how it’s built? We’ll build it.

You came here to understand the system, and now you do. If you’d rather have it standing on your business than spend the next three months wiring it yourself, GAP Concierge is the same architecture from these lessons — a white-label AI agent that knows your catalog and captures your leads — set up for you, from $97/mo.

See GAP Concierge →