Add client photo proofing to WordPress: a plugin you can build today

· 5 min read

Most photographers who need client proofing end up on Pixieset, Pic-Time, or a similar platform. The monthly fee is reasonable for a busy studio and the experience is polished. But the client lands on a domain you do not own, your images pass through someone else's servers, and you are locked to whatever workflow the platform decides to ship. For a WordPress site owner who needs one proofing step inside a site they already manage, there is no built-in answer.

The plugin this guide describes creates a Proofing Session post type in your WordPress admin. You add images from your media library, save the post, and get a shareable token URL. The client opens that URL without a WordPress account, sees a gallery with a heart icon on each image, tabs between All, Selected, and Not Selected views, and clicks Submit. You get an email listing the selected images, and the session closes.

It is deliberately narrow. It does not sell prints, support multiple revision rounds, or scale gracefully to hundreds of images on a shared host. If you need any of those things, one of the commercial platforms is the right answer. If you need to add one proofing step to a site you already run, this is the build that handles it.

What one build gives you

  • Proofing Session post type with image list and auto-generated token URL
  • Client gallery with heart toggle, live counter, and All/Selected/Not Selected tabs
  • Token-gated access: no WordPress account needed from the client side
  • Submit action saves selections to post meta and sends an email notification
  • Admin view of submitted selections with image thumbnails after submission

What it does not do

  • Selling prints from selected images: no WooCommerce or payment integration
  • Multiple revision rounds: each round requires a new Proofing Session post
  • Galleries of more than roughly 200 images on a shared host: all images load at once with no lazy loading
  • Per-image comments or annotations from the client
  • Client accounts or session history: access is by token URL only

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WP Client Proofing. Plugin Name: WP Client Proofing. Description: Lets photographers and designers share a private gallery with clients for image selection without requiring a WordPress login. Single PHP file. On activation call flush_rewrite_rules. Register a 'proofing_session' post type on init: labels with plural 'Proofing Sessions' and singular 'Proofing Session', public false, show_ui true, show_in_menu true, supports array('title'), menu_icon 'dashicons-format-gallery'. On save_post for proofing_session: verify a nonce. If wcp_token meta is empty, generate bin2hex(random_bytes(16)) and save it. Parse the posted wcp_images_raw field: explode(',', trimmed value), array_map intval, array_filter to remove zeros, array_values, json_encode, save to wcp_images. Save wcp_status: accept 'open' or 'closed', default 'open'. Save wcp_notify_email via sanitize_email. Add a meta box 'Session Settings' to the proofing_session edit screen (side context). Fields: 1) Textarea labeled 'Image IDs (comma-separated)' name wcp_images_raw, value = implode(',', json_decode(existing wcp_images, true) or empty array). 2) If the token exists: read-only text input labeled 'Shareable URL' showing esc_url(home_url('/proof/') . wcp_token). 3) Select labeled 'Status' name wcp_status, options 'open' (Open) and 'closed' (Closed). 4) Email input labeled 'Notify email' name wcp_notify_email. Include a nonce field. Add a second meta box 'Submitted Selections' to the proofing_session edit screen (normal, low priority). Show content only when wcp_status is 'closed'. Display wcp_submitted_at date string, then render each attachment ID from wcp_selections JSON as a thumbnail using wp_get_attachment_image(id, 'thumbnail'). Register a rewrite rule on init: add_rewrite_rule('^proof/([^/]+)/?$', 'index.php?wcp_token=$matches[1]', 'top'). Add 'wcp_token' to the query_vars filter. On template_redirect: if get_query_var('wcp_token') is non-empty, run the handler and exit. In the handler: query for a proofing_session post where meta key wcp_token equals the query var (WP_Query, post_type proofing_session, posts_per_page 1). If not found or wcp_status is not 'open', output a minimal HTML page saying 'This proofing session is not available.' and exit. Otherwise render the proofing page as a full HTML document. Inline CSS: *, *::before, *::after { box-sizing: border-box } body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; background: #f5f5f5 } .container { max-width: 1000px; margin: 0 auto; padding: 1.5rem } h1 { font-size: 1.4rem; margin-bottom: 1rem } .toolbar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem } .tab-btn { background: none; border: none; padding: .4rem .9rem; cursor: pointer; border-bottom: 2px solid transparent; font-size: .95rem } .tab-btn.active { border-bottom-color: #0073aa; font-weight: 600 } #counter { font-size: .95rem; color: #555 } .gallery { display: grid; grid-template-columns: repeat(auto-fill,minmax(180px,1fr)); gap: 1rem } .thumb { position: relative; background: #fff; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1) } .thumb img { width: 100%; display: block } .heart-btn { position: absolute; top: 6px; right: 6px; background: rgba(255,255,255,.85); border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #ccc } .thumb.selected .heart-btn { color: #e0245e } .submit-area { margin-top: 1.5rem } #submit-btn { background: #0073aa; color: #fff; border: none; padding: .75rem 2rem; font-size: 1rem; border-radius: 4px; cursor: pointer } #submit-btn:disabled { opacity: .5; cursor: default } #message { margin-top: 1rem; font-size: 1rem; color: #0073aa } Body: div.container containing h1 with session title; div.toolbar with three tab buttons (data-tab='all' text 'All', data-tab='selected' text 'Selected', data-tab='unselected' text 'Not selected', 'all' active); span#counter; div.gallery; div.submit-area with button#submit-btn 'Submit selection' and div#message. Gallery: decode wcp_images JSON. For each attachment ID get wp_get_attachment_image_url(id, 'medium'); skip if empty. Output div.thumb data-id=id containing img src=url alt='' and button.heart-btn containing the heart character. After the gallery output a script tag with: var wcpPostId = POST_ID_INT; var wcpAjax = 'ADMIN_AJAX_URL'; var wcpNonce = 'WP_CREATE_NONCE_wcp_submit'; (use intval for post ID, esc_js for the nonce and AJAX URL). Inline JS (same or second script tag): on DOMContentLoaded: const KEY='wcp_sel_'+wcpPostId; let selected=JSON.parse(localStorage.getItem(KEY)||'[]'); let currentTab='all'; function sync(){ document.querySelectorAll('.thumb').forEach(t=>{ const sel=selected.includes(t.dataset.id); t.classList.toggle('selected',sel); if(currentTab==='all')t.style.display=''; else if(currentTab==='selected')t.style.display=sel?'':'none'; else t.style.display=!sel?'':'none' }); document.getElementById('counter').textContent=selected.length+' selected' } document.querySelectorAll('.tab-btn').forEach(b=>b.addEventListener('click',()=>{ currentTab=b.dataset.tab; document.querySelectorAll('.tab-btn').forEach(x=>x.classList.toggle('active',x===b)); sync() })); document.querySelectorAll('.heart-btn').forEach(b=>b.addEventListener('click',()=>{ const id=b.closest('.thumb').dataset.id; const i=selected.indexOf(id); if(i>-1)selected.splice(i,1); else selected.push(id); localStorage.setItem(KEY,JSON.stringify(selected)); sync() })); document.getElementById('submit-btn').addEventListener('click',()=>{ const btn=document.getElementById('submit-btn'); if(!selected.length&&!confirm('Submit with no images selected?'))return; btn.disabled=true; const body=new URLSearchParams({action:'wcp_submit',post_id:wcpPostId,selections:selected.join(','),nonce:wcpNonce}); fetch(wcpAjax,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}).then(r=>r.json()).then(d=>{ if(d.success){document.getElementById('message').textContent='Thank you. Your selection has been submitted.';localStorage.removeItem(KEY)}else{btn.disabled=false;document.getElementById('message').textContent='Something went wrong. Please try again.'} }).catch(()=>{btn.disabled=false;document.getElementById('message').textContent='Something went wrong. Please try again.'}) }); sync(); Register wp_ajax_wcp_submit and wp_ajax_nopriv_wcp_submit to the same handler. Handler: check_ajax_referer('wcp_submit','nonce'). $post_id=absint($_POST['post_id']); if get_post_type($post_id)!=='proofing_session' or get_post_meta($post_id,'wcp_status',true)!=='open': wp_send_json_error() and return. $raw=sanitize_text_field($_POST['selections']??''); $ids=array_values(array_filter(array_map('absint',explode(',',$raw)))); update_post_meta($post_id,'wcp_selections',json_encode($ids)); update_post_meta($post_id,'wcp_submitted_at',current_time('mysql')); update_post_meta($post_id,'wcp_status','closed'); $notify=get_post_meta($post_id,'wcp_notify_email',true)?:get_option('admin_email'); $title=get_the_title($post_id); $link=admin_url('post.php?post='.$post_id.'&action=edit'); wp_mail($notify,'Client selection submitted: '.$title,'Selected image IDs: '.implode(', ',$ids)."\n\nView in admin: ".$link); wp_send_json_success(). At the end of the file: register_activation_hook(__FILE__,'flush_rewrite_rules'); add_action('init','wcp_init'); add_action('add_meta_boxes','wcp_add_meta_boxes'); add_action('save_post_proofing_session','wcp_save_meta'); add_action('template_redirect','wcp_handle_proof_url'); add_action('query_vars','wcp_add_query_vars'); add_action('wp_ajax_wcp_submit','wcp_handle_submit'); add_action('wp_ajax_nopriv_wcp_submit','wcp_handle_submit').

Build this pluginAbout 55 credits · the free plan includes enough for one

Why WordPress has no built-in answer

The block editor gallery shows images but provides no selection mechanism. Adding a heart toggle requires JavaScript that WordPress does not ship with its gallery block.

Making the gallery accessible without a WordPress login means bypassing the normal authentication flow entirely. That requires a token in the URL and a custom rewrite rule to intercept the request before WordPress tries to serve a login page. A membership plugin can gate pages behind a login form, but the reverse problem, serving a page to an anonymous visitor that is not publicly listed, needs a different approach.

Storing and retrieving what the client selected requires a place to write to that persists between page loads. The plugin uses WordPress post meta on the session post itself. In-progress hearts live in the browser's localStorage until the client submits; submitted selections move to the server at that point.

What the plugin does

The plugin registers a post type called Proofing Session, visible in the WordPress admin but not publicly listed. Each session has a title, a comma-separated list of WordPress media library attachment IDs, a notification email address, and a generated token. When you save the session for the first time, the plugin creates the token and shows you a shareable URL in the settings panel.

When a client opens that URL, WordPress routes the request through a custom rewrite rule, looks up the session by token, and renders a standalone HTML page outside any theme. The page shows a grid of images with a heart button on each one. Clicking the heart marks the image selected and updates the counter at the top. Three tab buttons let the client switch between All, Selected, and Not Selected views.

When the client clicks Submit, the browser sends the list of selected attachment IDs to the server. The plugin saves them to the session's post meta, marks the session closed, and emails you a notification with the IDs and a link to the admin edit screen. Any attempt to open the token URL after that returns a session-not-available page.

  • Proofing Session post type with configurable image list and auto-generated token URL
  • Gallery with heart toggle, live selection counter, and All/Selected/Not Selected tab filters
  • Token-gated access: no WordPress login required from the client side
  • Submit saves selections to post meta and sends email notification
  • Session auto-closes after submission to prevent repeated submits

How token access works

The token is a 32-character hex string generated with random_bytes, which is cryptographically random. Anyone who has the URL can view the session, which is the intended behaviour: the client has no WordPress account, and the URL is the credential. The token is generated once on first save and does not change.

The rewrite rule maps /proof/{token} to an internal WordPress query variable. The plugin intercepts the request at template_redirect, before any theme template runs, queries for the proofing_session post with a matching token, and renders its own HTML. No theme stylesheet, no header or footer, just the gallery page. If the token does not match any open session, the page shows a short closed or not-found message.

Setting it up

Install the plugin, then open Proofing Sessions in the admin sidebar. Create a new session and give it a title that will appear as the page heading for the client. Upload the images to your media library if they are not already there. Find the attachment IDs from the media library URL (?attachment_id=123) or from each file's detail panel. Paste them comma-separated into the Image IDs field.

Set the notification email to wherever you want the submission alert sent. Save the post. The shareable URL appears as a read-only field in the Session Settings panel. Copy it and send it to your client.

To run a second round of selections on the same images, create a new Proofing Session post. There is no built-in link between sessions, but a consistent naming convention in the title is enough to track rounds across projects.

Questions

Can I use images from an external URL rather than the WordPress media library?

No. The plugin expects WordPress attachment IDs. Import external images using the Add from URL option in the media library first, then find their attachment IDs in the URL bar when you open each file.

What if the client closes the tab before submitting?

In-progress selections are saved in browser localStorage each time the client clicks a heart. When they reopen the same link in the same browser, the selections are still there. Clearing browser site data or switching to a different device would reset them.

Can two people review the same session at the same time?

Each browser keeps its own localStorage, so two people on different devices will not see each other's in-progress picks. Whoever clicks Submit first closes the session. If two people need to proof independently, create two sessions with the same image IDs and send each person their own link.

Can I see which images the client is picking before they submit?

Not in this build. In-progress selections live in the client's browser until Submit is clicked. After submission the picks are saved to post meta and visible in the Submitted Selections panel on the session edit screen. Ask Steem to add a live admin view that polls the session if you need to watch the process as it happens.

The client says the link shows 'session not available'. What is wrong?

Either the token URL was copied incorrectly, the session post was deleted, or the client has already submitted and the session is now closed. Check the Proofing Sessions list in the admin. If the status shows closed, create a new session with the same image IDs for a fresh round.

The prompt

Loads into the composer so you can edit it first. Nothing is built, and nothing is charged, until you send it.

Build a WordPress plugin called WP Client Proofing. Plugin Name: WP Client Proofing. Description: Lets photographers and designers share a private gallery with clients for image selection without requiring a WordPress login. Single PHP file. On activation call flush_rewrite_rules. Register a 'proofing_session' post type on init: labels with plural 'Proofing Sessions' and singular 'Proofing Session', public false, show_ui true, show_in_menu true, supports array('title'), menu_icon 'dashicons-format-gallery'. On save_post for proofing_session: verify a nonce. If wcp_token meta is empty, generate bin2hex(random_bytes(16)) and save it. Parse the posted wcp_images_raw field: explode(',', trimmed value), array_map intval, array_filter to remove zeros, array_values, json_encode, save to wcp_images. Save wcp_status: accept 'open' or 'closed', default 'open'. Save wcp_notify_email via sanitize_email. Add a meta box 'Session Settings' to the proofing_session edit screen (side context). Fields: 1) Textarea labeled 'Image IDs (comma-separated)' name wcp_images_raw, value = implode(',', json_decode(existing wcp_images, true) or empty array). 2) If the token exists: read-only text input labeled 'Shareable URL' showing esc_url(home_url('/proof/') . wcp_token). 3) Select labeled 'Status' name wcp_status, options 'open' (Open) and 'closed' (Closed). 4) Email input labeled 'Notify email' name wcp_notify_email. Include a nonce field. Add a second meta box 'Submitted Selections' to the proofing_session edit screen (normal, low priority). Show content only when wcp_status is 'closed'. Display wcp_submitted_at date string, then render each attachment ID from wcp_selections JSON as a thumbnail using wp_get_attachment_image(id, 'thumbnail'). Register a rewrite rule on init: add_rewrite_rule('^proof/([^/]+)/?$', 'index.php?wcp_token=$matches[1]', 'top'). Add 'wcp_token' to the query_vars filter. On template_redirect: if get_query_var('wcp_token') is non-empty, run the handler and exit. In the handler: query for a proofing_session post where meta key wcp_token equals the query var (WP_Query, post_type proofing_session, posts_per_page 1). If not found or wcp_status is not 'open', output a minimal HTML page saying 'This proofing session is not available.' and exit. Otherwise render the proofing page as a full HTML document. Inline CSS: *, *::before, *::after { box-sizing: border-box } body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; background: #f5f5f5 } .container { max-width: 1000px; margin: 0 auto; padding: 1.5rem } h1 { font-size: 1.4rem; margin-bottom: 1rem } .toolbar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem } .tab-btn { background: none; border: none; padding: .4rem .9rem; cursor: pointer; border-bottom: 2px solid transparent; font-size: .95rem } .tab-btn.active { border-bottom-color: #0073aa; font-weight: 600 } #counter { font-size: .95rem; color: #555 } .gallery { display: grid; grid-template-columns: repeat(auto-fill,minmax(180px,1fr)); gap: 1rem } .thumb { position: relative; background: #fff; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1) } .thumb img { width: 100%; display: block } .heart-btn { position: absolute; top: 6px; right: 6px; background: rgba(255,255,255,.85); border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #ccc } .thumb.selected .heart-btn { color: #e0245e } .submit-area { margin-top: 1.5rem } #submit-btn { background: #0073aa; color: #fff; border: none; padding: .75rem 2rem; font-size: 1rem; border-radius: 4px; cursor: pointer } #submit-btn:disabled { opacity: .5; cursor: default } #message { margin-top: 1rem; font-size: 1rem; color: #0073aa } Body: div.container containing h1 with session title; div.toolbar with three tab buttons (data-tab='all' text 'All', data-tab='selected' text 'Selected', data-tab='unselected' text 'Not selected', 'all' active); span#counter; div.gallery; div.submit-area with button#submit-btn 'Submit selection' and div#message. Gallery: decode wcp_images JSON. For each attachment ID get wp_get_attachment_image_url(id, 'medium'); skip if empty. Output div.thumb data-id=id containing img src=url alt='' and button.heart-btn containing the heart character. After the gallery output a script tag with: var wcpPostId = POST_ID_INT; var wcpAjax = 'ADMIN_AJAX_URL'; var wcpNonce = 'WP_CREATE_NONCE_wcp_submit'; (use intval for post ID, esc_js for the nonce and AJAX URL). Inline JS (same or second script tag): on DOMContentLoaded: const KEY='wcp_sel_'+wcpPostId; let selected=JSON.parse(localStorage.getItem(KEY)||'[]'); let currentTab='all'; function sync(){ document.querySelectorAll('.thumb').forEach(t=>{ const sel=selected.includes(t.dataset.id); t.classList.toggle('selected',sel); if(currentTab==='all')t.style.display=''; else if(currentTab==='selected')t.style.display=sel?'':'none'; else t.style.display=!sel?'':'none' }); document.getElementById('counter').textContent=selected.length+' selected' } document.querySelectorAll('.tab-btn').forEach(b=>b.addEventListener('click',()=>{ currentTab=b.dataset.tab; document.querySelectorAll('.tab-btn').forEach(x=>x.classList.toggle('active',x===b)); sync() })); document.querySelectorAll('.heart-btn').forEach(b=>b.addEventListener('click',()=>{ const id=b.closest('.thumb').dataset.id; const i=selected.indexOf(id); if(i>-1)selected.splice(i,1); else selected.push(id); localStorage.setItem(KEY,JSON.stringify(selected)); sync() })); document.getElementById('submit-btn').addEventListener('click',()=>{ const btn=document.getElementById('submit-btn'); if(!selected.length&&!confirm('Submit with no images selected?'))return; btn.disabled=true; const body=new URLSearchParams({action:'wcp_submit',post_id:wcpPostId,selections:selected.join(','),nonce:wcpNonce}); fetch(wcpAjax,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}).then(r=>r.json()).then(d=>{ if(d.success){document.getElementById('message').textContent='Thank you. Your selection has been submitted.';localStorage.removeItem(KEY)}else{btn.disabled=false;document.getElementById('message').textContent='Something went wrong. Please try again.'} }).catch(()=>{btn.disabled=false;document.getElementById('message').textContent='Something went wrong. Please try again.'}) }); sync(); Register wp_ajax_wcp_submit and wp_ajax_nopriv_wcp_submit to the same handler. Handler: check_ajax_referer('wcp_submit','nonce'). $post_id=absint($_POST['post_id']); if get_post_type($post_id)!=='proofing_session' or get_post_meta($post_id,'wcp_status',true)!=='open': wp_send_json_error() and return. $raw=sanitize_text_field($_POST['selections']??''); $ids=array_values(array_filter(array_map('absint',explode(',',$raw)))); update_post_meta($post_id,'wcp_selections',json_encode($ids)); update_post_meta($post_id,'wcp_submitted_at',current_time('mysql')); update_post_meta($post_id,'wcp_status','closed'); $notify=get_post_meta($post_id,'wcp_notify_email',true)?:get_option('admin_email'); $title=get_the_title($post_id); $link=admin_url('post.php?post='.$post_id.'&action=edit'); wp_mail($notify,'Client selection submitted: '.$title,'Selected image IDs: '.implode(', ',$ids)."\n\nView in admin: ".$link); wp_send_json_success(). At the end of the file: register_activation_hook(__FILE__,'flush_rewrite_rules'); add_action('init','wcp_init'); add_action('add_meta_boxes','wcp_add_meta_boxes'); add_action('save_post_proofing_session','wcp_save_meta'); add_action('template_redirect','wcp_handle_proof_url'); add_action('query_vars','wcp_add_query_vars'); add_action('wp_ajax_wcp_submit','wcp_handle_submit'); add_action('wp_ajax_nopriv_wcp_submit','wcp_handle_submit').

Build this pluginAbout 55 credits · the free plan includes enough for one

Read next

Other plugins you can build this way

Each loads into the composer, ready to edit.