Free samples, one per person and one per address (Ticket 1529310)

This page shows a working “one free sample per person, and one per address” setup on a real SureCart store. Everything below is live: you can claim a sample yourself and watch it get blocked the second time.

Step 1. What SureCart does on its own

SureCart has a built in setting that limits how many times one customer can ever buy a product. It is on the product edit screen, in the Advanced box in the right sidebar. Turn on Limit per-customer purchases and set Customer Purchase Limit to 1.

Limit per-customer purchases toggle set to 1

That is not a “one per cart” limit. SureCart adds up every sample that customer has ever received and refuses the checkout if the new order would push them over the limit. If they try again, the checkout stops before an order is created and they see this:

Checkout blocked with the purchase limit message

Step 2. The gap, and how we close it

SureCart matches a customer by their email address. So the built in limit stops the same person ordering twice, but it will not stop someone using a second email address to send another sample to the same house. SureCart has no “one per street address” rule, and there is no way to search past orders by address, so that half needs a small snippet.

The snippet below remembers every address that has received a sample and refuses a second one, even from a different email. It runs on SureCart’s own checkout validation, so it stops the order before anything is created:

Checkout blocked because the address already claimed a sample

Try it yourself

The store is in test mode, so nothing is charged and no card is asked for. The sample costs $0.00 and ships free.

  • Test A. Claim a sample with any email address and any street address. It goes through.
  • Test B. Claim again with the same email. SureCart’s own limit stops you: “One or more items in your cart exceed the purchase limit.”
  • Test C. Claim again with a different email but the same street address. The snippet stops you: “It looks like a free sample has already been sent to this address.”
  • Test D. Try the same street written differently, for example “12 Validate St.” instead of “12 Validate Street”. It is still blocked.
  • Shortcut. The addresses already in the table below have been claimed. Ship to one of those with a brand new email address and you will see the block straight away, without having to do Test A first.

Layer 1, built in. SureCart setting "Limit per-customer purchases" on this product is currently: 1 one per customer

Layer 2, the snippet. Addresses that have already received a sample:

Address Claimed by When
1504 cottonwood ave
Fircrest 98466 US
karen.kotsinyan@gmail.com 2 weeks ago
1506 cottonwood ave
Fircrest 98466 US
karsinyan@gmail.com 2 weeks ago

Reset the demo (clears the claimed list)

The snippet

Add this with any code snippets plugin (WPCode, Code Snippets, WPCodeBox) or in your child theme’s functions.php. Change the product ID on the line marked SETTINGS to your own sample product, and change the wording of the message to suit you.

<?php
/**
 * ONE FREE SAMPLE PER SHIPPING ADDRESS.
 *
 * SureCart's built-in "Limit per-customer purchases" setting already stops the
 * same CUSTOMER (same email address) claiming a second sample. This snippet
 * adds the second half: it also stops a DIFFERENT email address claiming a
 * sample for a street address that has already received one.
 *
 * It runs on SureCart's own `surecart/checkout/validate` filter, which fires
 * before the order is created, so nothing is charged and no order is made.
 */

// ---------------------------------------------------------------------------
// 1. SETTINGS - put your own sample product ID(s) here.
//    Product ID is at the end of the URL when you edit the product in
//    SureCart > Products, and it looks like 93c9537d-6a6c-48b2-....
// ---------------------------------------------------------------------------
function sc_sample_guarded_products() {
	return array(
		'93c9537d-6a6c-48b2-b5b6-4520fe164c59',
	);
}

function sc_sample_message() {
	return __( 'It looks like a free sample has already been sent to this address. We are limited to one sample per household. Please contact us if you think this is a mistake.', 'surecart' );
}

define( 'SC_SAMPLE_OPTION', 'sc_sample_claimed_addresses' );

// ---------------------------------------------------------------------------
// 2. Turn an address into a comparable fingerprint, so that
//    "100 Sample St." and "100 sample street" count as the same house.
// ---------------------------------------------------------------------------
function sc_sample_fingerprint( $address ) {
	if ( empty( $address ) ) {
		return '';
	}
	$address = (array) $address;

	$text = strtolower(
		implode(
			' ',
			array(
				(string) ( $address['line_1'] ?? '' ),
				(string) ( $address['line_2'] ?? '' ),
				(string) ( $address['city'] ?? '' ),
				(string) ( $address['state'] ?? '' ),
				(string) ( $address['postal_code'] ?? '' ),
				(string) ( $address['country'] ?? '' ),
			)
		)
	);

	// Collapse the usual abbreviations so they cannot be used to slip through.
	$map = array(
		'street' => 'st',   'road'      => 'rd',  'avenue' => 'ave', 'av'    => 'ave',
		'drive'  => 'dr',   'lane'      => 'ln',  'court'  => 'ct',  'place' => 'pl',
		'boulevard' => 'blvd', 'apartment' => 'apt', 'flat' => 'apt',
		'suite'  => 'ste',  'unit'      => 'ste',
		'north'  => 'n',    'south'     => 's',   'east'   => 'e',   'west'  => 'w',
		'number' => '',     'no'        => '',
	);
	$text = preg_replace_callback(
		'/[a-z]+/',
		function ( $m ) use ( $map ) {
			return $map[ $m[0] ] ?? $m[0];
		},
		$text
	);

	$text = preg_replace( '/[^a-z0-9]+/', '', $text );

	return '' === $text ? '' : md5( $text );
}

function sc_sample_claims() {
	$claims = get_option( SC_SAMPLE_OPTION, array() );
	return is_array( $claims ) ? $claims : array();
}

// ---------------------------------------------------------------------------
// 3. Read the checkout from SureCart and pull out what we need.
//    We always re-read it from the API rather than trusting the submitted
//    values, because Apple Pay and Google Pay submit an empty body.
// ---------------------------------------------------------------------------
function sc_sample_inspect( $checkout_id ) {
	if ( empty( $checkout_id ) || ! class_exists( '\SureCart\Models\Checkout' ) ) {
		return null;
	}

	$checkout = \SureCart\Models\Checkout::where(
		array(
			'expand' => array( 'line_items', 'line_item.price', 'price.product', 'shipping_address' ),
		)
	)->find( $checkout_id );

	if ( is_wp_error( $checkout ) || empty( $checkout ) ) {
		return null;
	}

	$guarded  = sc_sample_guarded_products();
	$is_guard = false;

	foreach ( (array) ( $checkout->line_items->data ?? array() ) as $item ) {
		$product = $item->price->product ?? null;
		$id      = is_object( $product ) ? ( $product->id ?? null ) : $product;
		if ( $id && in_array( $id, $guarded, true ) ) {
			$is_guard = true;
			break;
		}
	}

	if ( ! $is_guard ) {
		return null;
	}

	$address = $checkout->shipping_address ?? null;
	if ( empty( $address ) ) {
		$address = $checkout->billing_address ?? null;
	}
	if ( is_object( $address ) ) {
		$address = json_decode( wp_json_encode( $address ), true );
	}

	return array(
		'fingerprint' => sc_sample_fingerprint( $address ),
		'email'       => strtolower( trim( (string) ( $checkout->email ?? '' ) ) ),
		'address'     => $address,
	);
}

// ---------------------------------------------------------------------------
// 4. THE BLOCK. Runs before the order is created.
// ---------------------------------------------------------------------------
add_filter(
	'surecart/checkout/validate',
	function ( $errors, $args, $request ) {
		$info = sc_sample_inspect( $request['id'] ?? '' );

		if ( empty( $info ) || empty( $info['fingerprint'] ) ) {
			return $errors;
		}

		$claims = sc_sample_claims();
		$claim  = $claims[ $info['fingerprint'] ] ?? null;

		// The address already has a sample, and it was someone else's email.
		if ( $claim && ( $claim['email'] ?? '' ) !== $info['email'] ) {
			$errors->add( 'sample_already_claimed', sc_sample_message(), array( 'status' => 422 ) );
		}

		return $errors;
	},
	10,
	3
);

// ---------------------------------------------------------------------------
// 5. THE RECORD. Remember the address once the order actually goes through.
// ---------------------------------------------------------------------------
add_filter(
	'rest_post_dispatch',
	function ( $response, $server, $request ) {
		if ( ! preg_match( '#^/surecart/v[0-9]+/(?:draft-)?checkouts/([^/]+)/(?:finalize|confirm|manually_pay)#', (string) $request->get_route(), $m ) ) {
			return $response;
		}

		$data = $response instanceof \WP_REST_Response ? $response->get_data() : null;
		if ( is_object( $data ) ) {
			$data = json_decode( wp_json_encode( $data ), true );
		}
		if ( ! is_array( $data ) || ! in_array( $data['status'] ?? '', array( 'paid', 'processing' ), true ) ) {
			return $response;
		}

		$info = sc_sample_inspect( $m[1] );
		if ( empty( $info ) || empty( $info['fingerprint'] ) ) {
			return $response;
		}

		$claims = sc_sample_claims();
		if ( ! isset( $claims[ $info['fingerprint'] ] ) ) {
			$claims[ $info['fingerprint'] ] = array(
				'email'   => $info['email'],
				'order'   => $data['order'] ?? '',
				'address' => $info['address'],
				'time'    => time(),
			);
			update_option( SC_SAMPLE_OPTION, $claims, false );
		}

		return $response;
	},
	10,
	3
);

// ---------------------------------------------------------------------------
// 6. Optional: a readout for the shop owner.
//    /?sc_samples=1  shows the list,  /?sc_samples=1&clear=1  empties it.
// ---------------------------------------------------------------------------
add_action(
	'init',
	function () {
		if ( empty( $_GET['sc_samples'] ) || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! empty( $_GET['clear'] ) ) {
			delete_option( SC_SAMPLE_OPTION );
		}
		header( 'Content-Type: text/plain' );
		print_r( sc_sample_claims() );
		exit;
	}
);

Worth knowing before you launch

  • The address list starts empty. The snippet only knows about samples claimed after you install it. Samples sent before that are not in the list.
  • A refund frees the limit. If you refund a sample order and tick the option to revoke the purchase, SureCart stops counting it and that customer can claim again.
  • Nobody verifies the email. Someone determined enough can use a second inbox and a slightly different address. The two layers together stop casual double claiming, which is what this is for. If you need it airtight, ask people to create an account, or review the sample orders by hand before you ship.
  • Keep the total at $0.00. A free sample with a paid shipping rate is no longer a free checkout, and the card fields come back. Give the sample product its own shipping profile with a $0 rate, as this demo does.
Scroll to Top