Back to Tips & Tricks

JetFormBuilder Multiple Signatures: One PDF From Three Signers

BoostMyCroco TeamPublished on
Three signature strokes from separate pens meeting on one document sheet, showing a multi-signer JetFormBuilder PDF flow

To collect JetFormBuilder multiple signatures, use two forms and one snippet. A request form stores a document image and three signer emails, then sends each signer a private link. A signing form saves each signature to its own slot. When the last signer submits, Generate PDF builds one PDF with the image and all three signatures, and the result is also viewable at an unguessable URL.

What you are building (and what you need)

  1. A logged-in staff user uploads the document image and enters three signer emails.
  2. The code creates a private signing request with one slot per signer and emails each signer a personal link.
  3. Each signer opens /sign/?req=…&signer=…&token=…, sees the document and draws a signature.
  4. The code checks the token, claims that signer's slot and checks whether every slot is filled.
  5. On the last signature, Generate PDF runs once, the PDF is emailed, and everyone gets a /signed/?key=… link.

You need JetFormBuilder 3.2.0 or later for the hooks this code uses, plus two PRO addons: Signature (the Signature Field block) and PDF Attachment (the Generate PDF action). Each addon may require a newer core version, so check its requirement in WordPress Dashboard > JetFormBuilder > Addons, where you also install it: find it under All Available Addons, click Install Addon, then Activate Addon. JetEngine is optional.

Why not nonces, a separate table or a cron job

  • Nonces expire, are tied to the user session, and are the same for every logged-out visitor for the same action. Use a random token per signer and store only its SHA-256 hash.
  • A custom table is overkill: a private post with meta fields holds three slots and shows in the admin.
  • Cron polling is unnecessary: the signing form already runs code at the moment that matters, so check for a full set on every submission. Keep WP-Cron for optional reminders or expiry.

Step 1: The signing request record

The snippet registers a non-public post type bmc_sign_request (Signing requests). To use JetEngine instead, create it in JetEngine > Post Types with the same slug, keep it non-public, and delete the register_post_type() block. The code writes all meta itself:

  • Per request: _doc_image_id, _requester_email, _status (pending or complete), _result_key_hash and _pdf_lock.
  • Per signer (n = 1 to 3): _signer_n_email, _signer_n_token_hash, _signer_n_signature, _signer_n_signature_path, _signer_n_signed_at and _signer_n_ip.

Step 2: The request form

  1. Add a Media field named doc_image, limited to images and one file, that saves to the Media Library and returns the attachment ID (check the labels in your version). Our drag and drop file upload guide covers the other Media field settings.
  2. Add three required email fields: signer_1_email, signer_2_email and signer_3_email.
  3. Add the Post Submit Action Call Hook with the hook name bmc_create_sign_request.
  4. Put the form on a page only logged-in staff can reach. The code also rejects users without the edit_others_posts capability (Editors and Administrators by default), because a form that emails any address is a spam relay.

If any signing email fails, the code deletes the request, leaves the uploaded image in the Media Library and shows an error. Links already sent then show as invalid, so resubmit the form.

Step 3: The signing page and signing form

Create a page with the slug sign and add the shortcode [bmc_sign_document]. With a valid link it shows the document, otherwise a neutral invalid-or-already-signed message. Exclude /sign/ and /signed/ from your caching plugin and your CDN: the snippet sends no-cache headers, but a page or edge cache can serve a stored copy before PHP runs. Below the shortcode, add a signing form with:

  1. Hidden fields req, signer and token, each with the value source URL Query Variable and the matching variable name.
  2. A required Signature Field named signature: IMAGE FORMAT PNG (some email clients block SVG), STORAGE TYPE File in a hashed folder without WP attachment, Delete created file on form error on, and FIELD VISIBILITY For all, since signers are usually logged out.
  3. Hidden fields with empty defaults: doc_image_url, signature_1 to signature_3, signed_at_1 to signed_at_3, signer_email_1 to signer_email_3, requester_email, and all_signed with the default no. The code resets them on the server, so browser-posted values are never used.

A custom success message popup is a clear way to confirm each signature.

Step 4: Generate the PDF only when all have signed

Add these Post Submit Actions to the signing form, in this order:

  1. Call Hook bmc_record_signature, first, because it fills the hidden fields the next actions read.
  2. Save Form Record. Without it, the PDF is not uploaded to the Media Library or linked on the Form Records page.
  3. Generate PDF. Click Add new template, add an Image block, click [/] in its toolbar and pick doc_image_url. Add three more Image blocks bound to signature_1 to signature_3, plus text with macros such as %signer_email_1% and %signed_at_1%. In the action, choose the template in SELECT PDF TEMPLATE, set FILE NAME, enable Save generated PDF, and fill in ERROR CREATING FILE and ERROR CREATING ATTACHMENT. Set the action's condition to all_signed equals yes. Use only one Generate PDF action on this form, because the code watches every action of that type.
  4. Click Attach to the Send Email action and confirm. Send to the requester_email field, pick the PDF in ATTACHMENTS and add the same condition.
  5. Call Hook bmc_commit_signature, last, with no condition. It uses up the link and sends the results emails, so it only runs if everything above succeeded.

The test checks three version-dependent details: the Generate PDF action ID, whether action conditions see values set with update_request(), and whether Image blocks render hidden-field URLs (the official signature-to-PDF guide only binds them to the Signature field). If Generate PDF does not start on the final signature, the signature is rolled back, its link stays valid and the admin is emailed. If the PDF addon throws an error when building fails, the submission is rolled back too; a failure that does not throw cannot be detected, so the request would complete without a PDF.

Step 5: A unique results URL

Create a page with the slug signed and add [bmc_signing_result]. On the last signature, the code stores the hash of a random 40-character key and emails /signed/?key=… to the requester and all signers. The page shows the document, signatures, emails and signing times for completed requests only. The PDF goes out by email and stays under Form Records.

Step 6: Add the snippet

Add this to your child theme's functions.php file or a code snippets plugin. Leave BMC_SIGN_UPLOAD_SUBDIR empty for now; the test fills it in. If you rename a form field, change it in the code too.

Code

/**
 * BoostMyCroco: multi-signer documents with JetFormBuilder.
 * Needs JetFormBuilder 3.2.0+ for the hooks used here, plus the Signature and PDF Attachment
 * PRO addons (check each addon's own requirement in JetFormBuilder > Addons).
 * Pages: /sign/ with [bmc_sign_document] + signing form, /signed/ with [bmc_signing_result].
 * Signing form actions, in order: Call Hook "bmc_record_signature", Save Form Record,
 * Generate PDF + Send Email (condition: all_signed equals yes), Call Hook "bmc_commit_signature".
 * Use only one Generate PDF action on the signing form.
 * The outer guard prevents "cannot redeclare" errors if the snippet is added twice.
 */
if ( ! defined( 'BMC_SIGN_SIGNERS' ) ) {

	define( 'BMC_SIGN_SIGNERS', 3 );

	// ID of the Generate PDF action. Confirm it with the logger from "Test it" before going live.
	define( 'BMC_SIGN_PDF_ACTION', 'generate_pdf' );

	// Fixed folder, relative to uploads, where the Signature addon saves its files.
	// Leave it empty for the first test signature: the code logs the real path and rejects it.
	define( 'BMC_SIGN_UPLOAD_SUBDIR', '' );

	// 1. Private "Signing requests" post type.
	// Delete this block if you created the type in JetEngine > Post Types with the same slug.
	add_action( 'init', function () {
		register_post_type( 'bmc_sign_request', array(
			'label'               => 'Signing requests',
			'public'              => false,
			'publicly_queryable'  => false,
			'exclude_from_search' => true,
			'show_ui'             => true,
			'show_in_menu'        => true,
			'show_in_rest'        => false,
			'supports'            => array( 'title', 'custom-fields' ),
			'menu_icon'           => 'dashicons-edit',
		) );
	} );

	// 2. Helpers.
	function bmc_sign_fail( $message ) {
		throw new \Jet_Form_Builder\Exceptions\Action_Exception( $message );
	}

	// jet_fb_context() exists since 3.1.0, the before-do-action hook used below since 3.2.0.
	function bmc_sign_require_jfb() {
		if ( ! function_exists( 'jet_fb_context' )
			|| ( defined( 'JET_FORM_BUILDER_VERSION' ) && version_compare( JET_FORM_BUILDER_VERSION, '3.2.0', '<' ) ) ) {
			bmc_sign_fail( 'Please update JetFormBuilder to 3.2.0 or later.' );
		}
	}

	// Shares data between the hooks of one submission (same PHP request).
	function bmc_sign_state( $set = null ) {
		static $state = array();
		if ( is_array( $set ) ) {
			$state = $set;
		}
		return $state;
	}

	// Accepts attachment IDs only (no URL lookup), so the code never touches an unrelated library item.
	function bmc_sign_media_id( $value ) {
		if ( is_array( $value ) ) {
			$value = isset( $value['id'] ) ? $value['id'] : reset( $value );
		}
		return is_numeric( $value ) ? absint( $value ) : 0;
	}

	function bmc_sign_signature_url( $value ) {
		if ( is_array( $value ) ) {
			$value = isset( $value['url'] ) ? $value['url'] : reset( $value );
		}
		if ( is_numeric( $value ) ) {
			return (string) wp_get_attachment_url( absint( $value ) );
		}
		if ( is_string( $value ) && preg_match( '#^https?://#i', $value ) ) {
			return esc_url_raw( $value );
		}
		return ''; // Data URL strings are not supported: use a file storage type.
	}

	// Copy the signature into uploads/bmc-signatures/ so it survives if JetFormBuilder deletes the original.
	// Offloaded or CDN-rewritten upload URLs are not supported.
	function bmc_sign_store_copy( $req_id, $n, $url ) {
		$uploads = wp_upload_dir();
		$base    = preg_replace( '#^https?:#i', '', trailingslashit( $uploads['baseurl'] ) );
		$path    = preg_replace( '#^https?:#i', '', strtok( $url, '?' ) );
		if ( 0 !== strpos( $path, $base ) ) {
			return false; // Not a file in the local uploads folder.
		}
		$relative = ltrim( rawurldecode( substr( $path, strlen( $base ) ) ), '/' );
		if ( '' === BMC_SIGN_UPLOAD_SUBDIR ) {
			error_log( 'BMC signing: the signature file is uploads/' . $relative . ' - set BMC_SIGN_UPLOAD_SUBDIR to its fixed folder.' );
			return false;
		}
		$basedir = realpath( $uploads['basedir'] );
		$allowed = realpath( trailingslashit( $uploads['basedir'] ) . trim( BMC_SIGN_UPLOAD_SUBDIR, '/\\' ) );
		$source  = realpath( trailingslashit( $uploads['basedir'] ) . $relative );
		if ( ! $basedir || ! $allowed || ! $source || ! is_file( $source ) ) {
			return false;
		}
		// The file must sit inside the Signature addon's folder, and that folder inside uploads.
		// The trailing separators block sibling folders such as uploads-old.
		$allowed = rtrim( $allowed, '/\\' ) . DIRECTORY_SEPARATOR;
		if ( 0 !== strpos( $allowed, rtrim( $basedir, '/\\' ) . DIRECTORY_SEPARATOR ) || 0 !== strpos( $source, $allowed ) ) {
			return false;
		}
		// The real signature file is written during this submission, so older files are rejected
		// (2 seconds of slack for file system clocks).
		$start = isset( $_SERVER['REQUEST_TIME'] ) ? (int) $_SERVER['REQUEST_TIME'] : time();
		clearstatcache( true, $source );
		if ( (int) filemtime( $source ) < $start - 2 ) {
			return false;
		}
		$ext = strtolower( pathinfo( $source, PATHINFO_EXTENSION ) );
		if ( ! in_array( $ext, array( 'png', 'jpg', 'jpeg' ), true ) ) {
			return false;
		}
		$dir = trailingslashit( $uploads['basedir'] ) . 'bmc-signatures';
		if ( ! wp_mkdir_p( $dir ) ) {
			return false;
		}
		if ( ! file_exists( $dir . '/index.html' ) ) {
			file_put_contents( $dir . '/index.html', '' ); // Blocks directory listing.
		}
		$name = sprintf( '%d-%d-%s.%s', $req_id, $n, wp_generate_password( 24, false, false ), $ext );
		if ( ! copy( $source, $dir . '/' . $name ) ) {
			return false;
		}
		return array(
			'url'  => trailingslashit( $uploads['baseurl'] ) . 'bmc-signatures/' . $name,
			'path' => $dir . '/' . $name,
		);
	}

	function bmc_sign_token_ok( $req_id, $n, $token ) {
		$req_id = absint( $req_id );
		$n      = absint( $n );
		if ( ! $req_id || $n < 1 || $n > BMC_SIGN_SIGNERS || ! is_string( $token ) || strlen( $token ) < 20 ) {
			return false;
		}
		if ( 'bmc_sign_request' !== get_post_type( $req_id ) ) {
			return false;
		}
		$stored = get_post_meta( $req_id, "_signer_{$n}_token_hash", true );
		if ( ! is_string( $stored ) || '' === $stored ) {
			return false; // Unknown slot or already signed.
		}
		return hash_equals( $stored, hash( 'sha256', $token ) );
	}

	function bmc_sign_ip() {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	function bmc_sign_time( $mysql ) {
		return $mysql ? mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $mysql ) : '';
	}

	function bmc_sign_subject( $text ) {
		return sprintf( '[%s] %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $text );
	}

	// Runs at shutdown: frees the slot this submission claimed, and its lock, if a later action failed.
	function bmc_sign_rollback() {
		$s = bmc_sign_state();
		if ( empty( $s['req'] ) ) {
			return;
		}
		$req_id = (int) $s['req'];
		$n      = (int) $s['signer'];
		wp_cache_delete( $req_id, 'post_meta' );

		// If another submission holds the PDF lock, it is building the final PDF with this slot: keep it.
		$held    = (string) get_post_meta( $req_id, '_pdf_lock', true );
		$foreign = '' !== $held && $held !== $s['lock'];

		if ( empty( $s['committed'] ) && ! $foreign && 'complete' !== get_post_meta( $req_id, '_status', true )
			&& get_post_meta( $req_id, "_signer_{$n}_signature_path", true ) === $s['path'] ) {
			if ( file_exists( $s['path'] ) ) {
				wp_delete_file( $s['path'] );
			}
			foreach ( array( 'signature', 'signature_path', 'signed_at', 'ip' ) as $field ) {
				delete_post_meta( $req_id, "_signer_{$n}_{$field}" );
			}
		}
		if ( '' !== $s['lock'] && empty( $s['done'] ) ) {
			delete_post_meta( $req_id, '_pdf_lock', $s['lock'] ); // Only removes our own lock.
		}
	}

	// 3. Request form: Call Hook "bmc_create_sign_request".
	add_action( 'jet-form-builder/custom-action/bmc_create_sign_request', function ( $request, $action_handler ) {
		bmc_sign_require_jfb();

		// Only trusted staff (Editors and Administrators by default) may trigger emails,
		// otherwise the form is a spam relay.
		if ( ! is_user_logged_in() || ! current_user_can( 'edit_others_posts' ) ) {
			bmc_sign_fail( 'You are not allowed to send signing requests.' );
		}

		$image_id = bmc_sign_media_id( isset( $request['doc_image'] ) ? $request['doc_image'] : 0 );
		if ( ! $image_id || ! wp_attachment_is_image( $image_id ) ) {
			bmc_sign_fail( 'Please upload the document as an image.' );
		}

		$emails = array();
		for ( $n = 1; $n <= BMC_SIGN_SIGNERS; $n++ ) {
			$email = sanitize_email( (string) ( isset( $request[ "signer_{$n}_email" ] ) ? $request[ "signer_{$n}_email" ] : '' ) );
			if ( ! is_email( $email ) ) {
				bmc_sign_fail( sprintf( 'Signer %d needs a valid email address.', $n ) );
			}
			$emails[ $n ] = $email;
		}
		if ( count( array_unique( array_map( 'strtolower', $emails ) ) ) !== BMC_SIGN_SIGNERS ) {
			bmc_sign_fail( 'Each signer needs a different email address.' );
		}

		$user   = wp_get_current_user();
		$req_id = wp_insert_post( array(
			'post_type'   => 'bmc_sign_request',
			'post_status' => 'private',
			'post_title'  => sprintf( 'Signing request by %s (%s)', $user->user_email, current_time( 'mysql' ) ),
			'post_author' => $user->ID,
		), true );
		if ( is_wp_error( $req_id ) ) {
			bmc_sign_fail( 'Could not create the signing request.' );
		}

		update_post_meta( $req_id, '_doc_image_id', $image_id );
		update_post_meta( $req_id, '_requester_email', $user->user_email );
		update_post_meta( $req_id, '_status', 'pending' );

		// Save every slot first, then send the emails.
		$links = array();
		foreach ( $emails as $n => $email ) {
			$token = wp_generate_password( 32, false, false );
			update_post_meta( $req_id, "_signer_{$n}_email", $email );
			update_post_meta( $req_id, "_signer_{$n}_token_hash", hash( 'sha256', $token ) ); // Never store the raw token.
			$links[ $n ] = add_query_arg( array( 'req' => $req_id, 'signer' => $n, 'token' => $token ), home_url( '/sign/' ) );
		}

		foreach ( $links as $n => $link ) {
			$body = "Hello,\n\nYou have been asked to sign a document. Open your private link:\n\n" . esc_url_raw( $link ) . "\n\nThis link is personal. Please do not forward it.\n";
			if ( ! wp_mail( $emails[ $n ], bmc_sign_subject( 'Please sign a document' ), $body ) ) {
				// Raw tokens exist only in the emails, so start over. Links already sent now show as invalid.
				// The uploaded image stays in the Media Library on purpose.
				wp_delete_post( $req_id, true );
				bmc_sign_fail( 'A signing email could not be sent, so the request was cancelled. Check your site email delivery and submit again.' );
			}
		}
	}, 10, 2 );

	// 4a. Signing form, FIRST action: Call Hook "bmc_record_signature".
	add_action( 'jet-form-builder/custom-action/bmc_record_signature', function ( $request, $action_handler ) {
		bmc_sign_require_jfb();
		$ctx = jet_fb_context();

		// Never trust what the browser posts for these fields: reset them all first.
		$reset = array( 'all_signed' => 'no', 'requester_email' => '', 'doc_image_url' => '' );
		for ( $i = 1; $i <= BMC_SIGN_SIGNERS; $i++ ) {
			$reset[ "signature_{$i}" ]    = '';
			$reset[ "signed_at_{$i}" ]    = '';
			$reset[ "signer_email_{$i}" ] = '';
		}
		foreach ( $reset as $field => $value ) {
			$ctx->update_request( $value, $field ); // Value first, field name second.
		}

		$req_id = absint( isset( $request['req'] ) ? $request['req'] : 0 );
		$n      = absint( isset( $request['signer'] ) ? $request['signer'] : 0 );
		$token  = sanitize_text_field( (string) ( isset( $request['token'] ) ? $request['token'] : '' ) );

		// Re-check the token on the server. The hidden fields only carry it.
		if ( ! bmc_sign_token_ok( $req_id, $n, $token ) || 'complete' === get_post_meta( $req_id, '_status', true ) ) {
			bmc_sign_fail( 'This signing link is invalid or has already been used.' );
		}

		// Keep the raw token out of Form Records (Save Form Record runs after this hook).
		$ctx->update_request( '', 'token' );

		$signature = bmc_sign_signature_url( isset( $request['signature'] ) ? $request['signature'] : '' );
		if ( '' === $signature ) {
			bmc_sign_fail( 'Please draw your signature before submitting.' );
		}
		$copy = bmc_sign_store_copy( $req_id, $n, $signature );
		if ( ! $copy ) {
			bmc_sign_fail( 'Your signature could not be saved. Please try again.' );
		}

		// Claim the slot: add_post_meta() with $unique = true refuses if it is already taken,
		// so a second submission with the same link (double click, resubmit) stops here.
		if ( ! add_post_meta( $req_id, "_signer_{$n}_signature", esc_url_raw( $copy['url'] ), true ) ) {
			wp_delete_file( $copy['path'] );
			bmc_sign_fail( 'This signing link is invalid or has already been used.' );
		}
		// bmc_sign_rollback() frees the slot again if a later action fails (it matches on this path).
		update_post_meta( $req_id, "_signer_{$n}_signature_path", $copy['path'] );

		$state = array(
			'req'         => $req_id,
			'signer'      => $n,
			'path'        => $copy['path'],
			'lock'        => '',
			'last'        => false,
			'key'         => '',
			'pdf_started' => false,
			'committed'   => false,
			'done'        => false,
		);
		bmc_sign_state( $state );
		add_action( 'shutdown', 'bmc_sign_rollback' );

		update_post_meta( $req_id, "_signer_{$n}_signed_at", current_time( 'mysql' ) );
		update_post_meta( $req_id, "_signer_{$n}_ip", bmc_sign_ip() );

		// Re-read fresh values (another signer may have saved meanwhile).
		wp_cache_delete( $req_id, 'post_meta' );
		for ( $i = 1; $i <= BMC_SIGN_SIGNERS; $i++ ) {
			if ( '' === (string) get_post_meta( $req_id, "_signer_{$i}_signature", true ) ) {
				return; // all_signed stays "no" because it was reset above.
			}
		}

		// All slots filled: only one submission may build the PDF.
		$lock = time() . '|' . $n;
		if ( ! add_post_meta( $req_id, '_pdf_lock', $lock, true ) ) {
			$held = (int) strtok( (string) get_post_meta( $req_id, '_pdf_lock', true ), '|' );
			// Another signer's submission is building the PDF. Take over only after 5 minutes (a crash).
			if ( time() - $held < 5 * MINUTE_IN_SECONDS ) {
				return;
			}
			update_post_meta( $req_id, '_pdf_lock', $lock );
		}

		$state['lock'] = $lock;
		$state['last'] = true;
		$state['key']  = wp_generate_password( 40, false, false );
		bmc_sign_state( $state );

		// Fill the hidden fields for the PDF template and the Send Email action.
		$ctx->update_request( (string) wp_get_attachment_url( (int) get_post_meta( $req_id, '_doc_image_id', true ) ), 'doc_image_url' );
		for ( $i = 1; $i <= BMC_SIGN_SIGNERS; $i++ ) {
			$ctx->update_request( (string) get_post_meta( $req_id, "_signer_{$i}_signature", true ), "signature_{$i}" );
			$ctx->update_request( bmc_sign_time( get_post_meta( $req_id, "_signer_{$i}_signed_at", true ) ), "signed_at_{$i}" );
			$ctx->update_request( (string) get_post_meta( $req_id, "_signer_{$i}_email", true ), "signer_email_{$i}" );
		}
		$ctx->update_request( (string) get_post_meta( $req_id, '_requester_email', true ), 'requester_email' );
		$ctx->update_request( 'yes', 'all_signed' );
	}, 10, 2 );

	// 4b. Flag that Generate PDF passed its condition and is about to run. This hook fires BEFORE the
	// build, so it cannot tell whether a PDF was actually created. If your version of the PDF addon
	// throws on failure, the submission stops before bmc_commit_signature and the rollback frees the slot.
	// A failure that does not throw is NOT detected here: test it on staging (see "Test it").
	add_action( 'jet-form-builder/before-do-action/' . BMC_SIGN_PDF_ACTION, function () {
		$state = bmc_sign_state();
		if ( ! empty( $state['last'] ) ) {
			$state['pdf_started'] = true;
			bmc_sign_state( $state );
		}
	} );

	// 4c. Signing form, LAST action (after Send Email): Call Hook "bmc_commit_signature".
	// It does not run if an earlier action stopped the submission with an error.
	add_action( 'jet-form-builder/custom-action/bmc_commit_signature', function ( $request, $action_handler ) {
		bmc_sign_require_jfb();
		$state = bmc_sign_state();
		if ( empty( $state['req'] ) ) {
			return; // bmc_record_signature did not run in this submission.
		}
		$req_id = (int) $state['req'];

		// Another submission already completed this request: never replace its emailed results link.
		wp_cache_delete( $req_id, 'post_meta' );
		if ( 'complete' === get_post_meta( $req_id, '_status', true ) ) {
			return;
		}

		// Last signature, but Generate PDF never started: roll back (the link stays valid) and tell the admin.
		if ( ! empty( $state['last'] ) && empty( $state['pdf_started'] ) ) {
			error_log( sprintf( 'BMC signing: request %d has all signatures, but Generate PDF did not start.', $req_id ) );
			wp_mail(
				get_option( 'admin_email' ),
				bmc_sign_subject( 'Signed document could not be built' ),
				sprintf( "Signing request #%d has all signatures, but the Generate PDF action did not start. Check BMC_SIGN_PDF_ACTION and the action's condition.\n", $req_id )
			);
			bmc_sign_fail( 'Your signature could not be finalized yet. The site owner has been notified. Please try again later.' );
		}

		delete_post_meta( $req_id, "_signer_{$state['signer']}_token_hash" ); // The link stops working.
		$state['committed'] = true;
		bmc_sign_state( $state );
		if ( empty( $state['last'] ) ) {
			return;
		}

		update_post_meta( $req_id, '_result_key_hash', hash( 'sha256', $state['key'] ) );
		update_post_meta( $req_id, '_status', 'complete' );
		$state['done'] = true;
		bmc_sign_state( $state );

		// Everyone gets the unguessable results link. The raw key is never saved anywhere.
		$result_url = add_query_arg( 'key', $state['key'], home_url( '/signed/' ) );
		$to_notify  = array( (string) get_post_meta( $req_id, '_requester_email', true ) );
		for ( $i = 1; $i <= BMC_SIGN_SIGNERS; $i++ ) {
			$to_notify[] = (string) get_post_meta( $req_id, "_signer_{$i}_email", true );
		}
		$body = "All signatures are in. View the signed document here:\n\n" . esc_url_raw( $result_url ) . "\n";
		foreach ( array_unique( array_filter( $to_notify ) ) as $to ) {
			wp_mail( $to, bmc_sign_subject( 'Document signed by all parties' ), $body );
		}
	}, 10, 2 );

	// 5. [bmc_sign_document] on the /sign/ page.
	add_shortcode( 'bmc_sign_document', function () {
		$req_id = isset( $_GET['req'] ) ? absint( $_GET['req'] ) : 0;
		$n      = isset( $_GET['signer'] ) ? absint( $_GET['signer'] ) : 0;
		$token  = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';

		if ( ! bmc_sign_token_ok( $req_id, $n, $token ) || 'complete' === get_post_meta( $req_id, '_status', true ) ) {
			return '<p class="bmc-sign-notice">' . esc_html( 'This signing link is invalid or has already been used.' ) . '</p>';
		}

		$image_id = (int) get_post_meta( $req_id, '_doc_image_id', true );
		return '<div class="bmc-sign-document"><p>' . esc_html( sprintf( 'You are signer %d of %d. Review the document, then sign below.', $n, BMC_SIGN_SIGNERS ) ) . '</p>' . wp_get_attachment_image( $image_id, 'large' ) . '</div>';
	} );

	// 6. [bmc_signing_result] on the /signed/ page.
	add_shortcode( 'bmc_signing_result', function () {
		$key       = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : '';
		$not_found = '<p class="bmc-sign-notice">' . esc_html( 'This document was not found.' ) . '</p>';
		if ( strlen( $key ) < 20 ) {
			return $not_found;
		}

		$hash = hash( 'sha256', $key );
		$ids  = get_posts( array(
			'post_type'     => 'bmc_sign_request',
			'post_status'   => 'private',
			'meta_key'      => '_result_key_hash',
			'meta_value'    => $hash,
			'fields'        => 'ids',
			'numberposts'   => 1,
			'no_found_rows' => true,
		) );
		if ( empty( $ids ) ) {
			return $not_found;
		}
		$req_id = (int) $ids[0];
		if ( 'complete' !== get_post_meta( $req_id, '_status', true ) || ! hash_equals( (string) get_post_meta( $req_id, '_result_key_hash', true ), $hash ) ) {
			return $not_found;
		}

		$html  = '<div class="bmc-signed-document">' . wp_get_attachment_image( (int) get_post_meta( $req_id, '_doc_image_id', true ), 'large' );
		$html .= '<ul class="bmc-signatures">';
		for ( $i = 1; $i <= BMC_SIGN_SIGNERS; $i++ ) {
			$html .= '<li><img src="' . esc_url( get_post_meta( $req_id, "_signer_{$i}_signature", true ) ) . '" alt="' . esc_attr( sprintf( 'Signature of signer %d', $i ) ) . '" width="300"><br>'
				. esc_html( get_post_meta( $req_id, "_signer_{$i}_email", true ) ) . ' - '
				. esc_html( bmc_sign_time( get_post_meta( $req_id, "_signer_{$i}_signed_at", true ) ) ) . '</li>';
		}
		$html .= '</ul></div>';
		return $html;
	} );

	// 7. Keep /sign/ and /signed/ out of browser caches, search engines and referrer headers.
	// Also exclude both pages in your caching plugin and CDN: these headers cannot stop a page cache.
	function bmc_sign_is_private_page() {
		return is_page( array( 'sign', 'signed' ) );
	}
	add_action( 'template_redirect', function () {
		if ( bmc_sign_is_private_page() ) {
			nocache_headers();
		}
	} );
	add_filter( 'wp_robots', function ( $robots ) {
		return bmc_sign_is_private_page() ? wp_robots_no_robots( $robots ) : $robots;
	} );
	add_action( 'wp_head', function () {
		if ( bmc_sign_is_private_page() ) {
			echo '<meta name="referrer" content="no-referrer">' . "\n";
		}
	}, 1 );
}

How it works: each link carries a 32-character token, and only its SHA-256 hash is stored. bmc_record_signature resets the server-filled fields, re-checks the token with hash_equals(), copies the signature to uploads/bmc-signatures/ and claims the slot with add_post_meta( …, true ). On the last slot, _pdf_lock lets one submission fill the PDF fields. bmc_commit_signature uses up the link and completes the request. A shutdown callback frees the slot if a later action failed, unless another submission already holds the PDF lock. update_request() takes the value first and the field name second; the repeater examples in Crocoblock's Call Hook gist reverse it, so confirm with jet_fb_context()->resolve_request().

Test it (required before going live)

  1. On staging only, with WP_DEBUG_LOG on and the log outside the web root, add these lines directly after $ctx = jet_fb_context(); in bmc_record_signature. They log emails and signature URLs:
    $log = $request;
    unset( $log['token'] );
    error_log( print_r( $log, true ) );
  2. Add this temporary logger after the snippet. It runs on every hook and filter, so use it on staging only and remove it right after the test: it slows the site and floods the log.
    add_action( 'all', function ( $tag ) {
    	if ( 0 === strpos( $tag, 'jet-form-builder/before-do-action/' ) ) {
    		error_log( $tag );
    	}
    } );
  3. Submit the request form with three inboxes you can open. A pending entry appears under Signing requests.
  4. Sign as signer 1. The form shows an error. In the log, confirm req and signer are filled, and find the line BMC signing: the signature file is uploads/….
  5. Set BMC_SIGN_UPLOAD_SUBDIR to the part of that folder path that stays the same between signatures, without the hashed folder or file name. If unsure, sign twice and compare.
  6. Sign as signer 1 again. It succeeds, reopening the link shows the invalid-or-used message, and the token field is empty in JetFormBuilder > Form Records.
  7. Sign as signers 2 and 3. On the final signature, the logger writes one before-do-action line per action, in list order. If the Generate PDF line does not end in generate_pdf, put its ID in BMC_SIGN_PDF_ACTION. If the final signer still sees the could-not-be-finalized error, conditions in your version do not see update_request() changes.
  8. When it works, the final Form Record has a Generated PDF section with a View link, the requester gets the PDF, all four addresses get the results link, and the request is complete. If the PDF's Image blocks are empty but %signature_1% as text prints a URL, your version does not render hidden-field URLs in Image blocks; /signed/ still shows every signature.
  9. Test a failure: on a new request with two signatures in, break Generate PDF (for example, clear FILE NAME) and submit the third. The request should stay pending with slot 3 empty. If it completes without a PDF, your addon fails silently, so check each completed request for a Generated PDF section.

Remove all logging lines and delete the log file when you are done.

  • Indexing. Storage with a WP attachment can lead Google to index attachment pages, and the document image is always an attachment. Since WordPress 6.4, attachment pages are off on new installs but stay on for upgraded sites, so check yours.
  • Audit trail. Each slot keeps the signing time and IP. Behind a CDN or reverse proxy, REMOTE_ADDR is the proxy's address, and tokens in URLs also land in server and analytics logs.
  • Legal weight. A drawn signature is not automatically a legally qualified e-signature. Get legal advice before using this for contracts.
  • Races. The slot claim and the lock use add_post_meta() with $unique = true. That covers normal traffic, but it is not a database transaction.

Troubleshooting

  • Every link is rejected on submit, and the log shows empty req, signer or token. The hidden fields are not receiving the query values. Check their value source.
  • The PDF is generated on every signature. Both conditions must test all_signed equals yes, and bmc_record_signature must be first.
  • Please upload the document as an image. The Media field does not save to the Media Library or does not return an attachment ID.
  • Your signature could not be saved. STORAGE TYPE is Data URL string, BMC_SIGN_UPLOAD_SUBDIR is empty or wrong, or your uploads are offloaded or served from CDN-rewritten URLs, which this snippet does not support.
  • Two PDFs for one request. Send Email failed after Generate PDF, so the last signer signed again. Fix email delivery and delete the duplicate record.
  • Valid links show as invalid on the page. The slug must be sign, and the page must be excluded from your caching plugin and CDN.

You now have a self-hosted multi-signer flow that produces one PDF and rolls back when a step fails. If you would rather have it built and adapted for your site, post a request on BoostMyCroco.

Frequently Asked Questions

Does JetFormBuilder support multiple signers out of the box?
Crocoblock's signature-to-PDF tutorial covers one person signing one form. The Signature Field records the signature of whoever submits that form. To have several people sign from their own devices, combine a request form, a signing form and Call Hook code that tracks each signer's slot.
Do signers need a WordPress account to sign?
No. Set the Signature Field's FIELD VISIBILITY to For all, and the random token in each email link identifies the signer. Only the staff member who sends the signing request has to be logged in.
How do I change the flow to two or four signers?
Change the BMC_SIGN_SIGNERS constant in the snippet. Then add or remove the matching email fields on the request form, the hidden signature, signed_at and signer_email fields on the signing form, and the Image blocks in the PDF template. Keep the same numbering in every field name.
Can signers be asked to sign in a fixed order?
Yes, with a small code change. Email only signer 1 when the request is created, then generate and email the next signer's token inside the bmc_commit_signature hook after each successful signature. Each token has to be created at the moment you send it, because only its hash is stored.
Is a drawn signature from JetFormBuilder legally binding?
That depends on your jurisdiction and the type of document. A drawn signature is not automatically a legally qualified e-signature. Keep the audit trail this setup stores (time, IP address and form records), and ask a lawyer before relying on it for contracts.

This guide answers a question from our community: Form for three users to sign

Sources

  1. Signature Field for WordPress Forms (jetformbuilder.com)
  2. Signature Field 一 JetFormBuilderㅣCrocoblock (jetformbuilder.com)
  3. How to Add a Signature Field to PDF Files 一 JetFormBuilder (jetformbuilder.com)
  4. JetFormBuilder Call Hook action · GitHub (gist.github.com)
  5. How to Generate PDFs from WordPress Forms with JetFormBuilder - Crocoblock (crocoblock.com)
  6. Approval Workflow With Multiple Signatures on a Single Form Submission (jotform.com)
  7. How to Collect Digital Signatures in WordPress with JetFormBuilder - Crocoblock (crocoblock.com)
  8. Fix JetFormBuilder Errors: Complete Troubleshooting Guide 2026 - Crocoblock (crocoblock.com)
  9. JetFormBuilder. Example of custom hook with API request and redirect. · GitHub (gist.github.com)
  10. Can two different parties sign the same form? (jotform.com)

Related tips