๐ Meet Copper CRM
Most CRMs feel like you need a weekend course before you can send your first email. Copper is different. It's built to live inside Google Workspace, so if your team already spends the day in Gmail, Calendar and Drive, Copper feels like a natural extension instead of yet another tab.
For WordPress developers, the interesting part is that Copper has a clean REST + JSON Developer API. You can create records, search, update, and subscribe to events. That means your WordPress forms don't have to end their life in an admin email inbox. They can land straight in a CRM, ready for follow-up.
In this article, we'll go through:
- What Prospects and Contacts really are (and the naming trap ๐ชค)
- Where tags, custom fields and webhooks live
- A duplicate-entry gotcha I ran into
- How to push Contact Us and Newsletter form entries to Copper using PHP
Let's go! ๐
๐งญ Contacts vs Prospects
Copper has two main "people-like" record types:
- Prospects: someone who showed interest but isn't a real business relationship yet. Think "just filled the contact form."
- Contacts: someone you're actually doing business with, or are ready to.
Copper's own help docs describe this flow nicely. You can start someone as a prospect while you check if they're a good fit, or skip that step and add them straight as a contact. When a prospect looks promising, you convert them. (Working with People)
โ ๏ธ The Naming Trap (Read This Before You Write Code!)
Here's something that confused me at first. The Copper dashboard talks about Prospects and Contacts, but the developer documentation uses different words:
๐ Prospects =
leadsin the developer API
๐ Contacts =peoplein the developer API
So when you read the docs and see /leads, think "Prospects". When you see /people, think "Contacts". Copper also lets admins rename record types in settings, so your own account might use yet another label. Always map by endpoint, not by what the sidebar says.
Docs: Leads Overview ยท People Overview
๐ Quick Comparison
Prospects (API: leads) |
Contacts (API: people) |
|
|---|---|---|
| Meaning | Early-stage interest, not yet qualified | Real business relationship |
| Typical source | Contact form, enquiry, cold outreach | Newsletter signup, customers, qualified people |
| Duplicates | โ ๏ธ Can store duplicates | โ Doesn't store duplicate entries (more below) |
| Extra fields | Status, Customer Source | Contact Type |
| Company / pipeline link | Can't be related to Companies or Pipelines | Can be related to Companies and Opportunities |
| Moving forward | Can be converted to a Contact | Already the "final" form |
| Upsert endpoint | Yes, PUT /leads/upsert
|
Not listed in the docs, so check first, then create or update |
| Enabled by default? | No, you toggle Prospects on in settings | Yes |
Sources: Copper: Customizing your account, Creating a Lead form, Convert a Lead API
๐จ The Duplicate Gotcha (My Real-World Finding)
This one cost me a bit of debugging time, so let me save you the trouble.
๐ฅ Highlight
While working with Copper CRM, I noticed that the Contacts (
people) section does not store duplicate entries, but the Prospects (leads) section does.
What that means in practice:
- Submit the same email to Contacts twice โ you won't get two clean records. The second attempt is rejected or needs a different approach.
- Submit the same email to Prospects twice โ you can end up with two (or ten!) identical prospects. ๐
If your "Contact Us" form creates prospects with a plain POST /leads, every repeat visitor adds a new record. Your sales team will not thank you.
The fix: use the Upsert Lead endpoint. It checks for a match (name, email, or custom field), updates the record if it exists, and creates one if it doesn't. The docs say it's particularly useful to avoid duplicate leads. We'll use it in the code below.
Docs: UPSERT a Lead
๐ ๏ธ Tags, Custom Fields & Webhooks from the Dashboard
You can do a lot without writing any code. Here's where to look.
๐ท๏ธ Tags
Tags are flexible labels, like newsletter, website-lead, or wordpress. They're handy for things that don't deserve a full custom field. Tags are specific to each record type, and you can add, edit or delete them in settings. Good news for devs: you can also attach tags through the API when creating records.
โ ๏ธ Heads up: if a tag is renamed or deleted in Settings, automations (and form actions) that referenced it won't apply it anymore.
Docs: Tags API
๐งฉ Custom Fields
Need to track "Budget", "Service Interest" or "Preferred Language"? That's what custom fields are for. In the dashboard, go to Settings โ Manage Fields on Records, pick the record type (leads, people, companies, etc.) and add your field. Admins can also reorder, deactivate or reactivate fields from the same place. (Manage Field Settings)
For API usage, every custom field has a numeric custom_field_definition_id. You send values like this:
"custom_fields": [
{ "custom_field_definition_id": 100764, "value": "Hello" }
]
You can list your definitions via the API to find the IDs: List Custom Field Definitions ยท Custom Fields Overview
๐ Webhooks
Webhooks flip the direction. Instead of you sending data to Copper, Copper pings your WordPress site when something happens (new lead, updated person, etc.). Great for syncing data back, notifying Slack, or kicking off a custom workflow.
On the developer side, you create a subscription with a target URL and the event you care about. Your endpoint receives a notification and processes it. (Pro tip: make your endpoint respond fast and handle repeated deliveries safely.)
Docs: Webhooks Overview ยท Create new subscription ยท Notification example
๐ WordPress Forms โ Copper (PHP Example)
Now the fun part. Imagine two forms on a WordPress site:
| Form | Where it should go | Why |
|---|---|---|
| Contact Us |
Prospects (/leads) |
Unqualified enquiry, so it needs a sales check |
| Subscribe Newsletter |
Contacts (/people) |
Just an email and name, no sales follow-up needed |
(This split is just an example. You can route either form to either place.)
๐ Step 1: API Credentials
In Copper, generate an API key from your account settings. Every request needs three headers: X-PW-AccessToken, X-PW-Application (always developer_api) and X-PW-UserEmail. (Authentication docs)
Never hard-code secrets in plugin files. Put them in wp-config.php:
define( 'COPPER_API_KEY', 'your-api-key' );
define( 'COPPER_USER_EMAIL', 'you@yourcompany.com' );
๐๏ธ Step 2: File Structure (OOP style)
copper-forms/
โโโ copper-forms.php
โโโ includes/
โโโ class-client.php // Talks to Copper
โโโ class-prospect-service.php // Leads
โโโ class-contact-service.php // People
โโโ class-form-handler.php // Reads and sanitizes form data
๐ Step 3: The API Client
One small class that knows how to talk to Copper. Everything else reuses it.
<?php
/**
* Copper API client.
*
* @package CopperForms
*/
namespace CopperForms;
defined( 'ABSPATH' ) || exit;
/**
* Class Client
*/
class Client {
const BASE_URL = 'https://api.copper.com/developer_api/v1';
/**
* Send a request to Copper.
*
* @param string $method HTTP method.
* @param string $endpoint Endpoint, e.g. '/leads/upsert'.
* @param array $body Request body.
*
* @return array|\WP_Error
*/
public function request( $method, $endpoint, array $body = array() ) {
$args = array(
'method' => $method,
'timeout' => 5,
'headers' => array(
'X-PW-AccessToken' => COPPER_API_KEY,
'X-PW-Application' => 'developer_api',
'X-PW-UserEmail' => COPPER_USER_EMAIL,
'Content-Type' => 'application/json',
),
);
if ( ! empty( $body ) ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_remote_request( self::BASE_URL . $endpoint, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code >= 400 ) {
return new \WP_Error(
'copper_api_error',
'Copper API request failed.',
array( 'status' => $code )
);
}
return is_array( $data ) ? $data : array();
}
}
๐ฏ Step 4: Contact Us โ Prospect (Lead)
We use upsert matched on email, so repeat submissions update the same prospect instead of creating duplicates.
Docs: UPSERT a Lead ยท Create a New Lead
<?php
/**
* Prospect (Lead) service.
*
* @package CopperForms
*/
namespace CopperForms;
defined( 'ABSPATH' ) || exit;
/**
* Class Prospect_Service
*/
class Prospect_Service {
/**
* Client instance.
*
* @var Client
*/
private $client;
/**
* Constructor.
*
* @param Client $client API client.
*/
public function __construct( Client $client ) {
$this->client = $client;
}
/**
* Create or update a prospect from Contact Us data.
*
* @param array $data Sanitized form data.
*
* @return array|\WP_Error
*/
public function upsert( array $data ) {
$payload = array(
'properties' => array(
'name' => $data['name'],
'email' => array(
'email' => $data['email'],
'category' => 'work',
),
'details' => $data['message'],
'tags' => array( 'website-contact-form' ),
),
'match' => array(
'field_name' => 'email',
'field_value' => $data['email'],
),
);
return $this->client->request( 'PUT', '/leads/upsert', $payload );
}
}
๐ Step 5: Newsletter โ Contact (Person)
People has no upsert endpoint in the docs, so we do it manually: look up by email โ update if found โ create if not. This also protects us from the duplicate behaviour we talked about.
Docs: Fetch a Person by Email ยท Create a New Person ยท Update a Person
<?php
/**
* Contact (Person) service.
*
* @package CopperForms
*/
namespace CopperForms;
defined( 'ABSPATH' ) || exit;
/**
* Class Contact_Service
*/
class Contact_Service {
/**
* Client instance.
*
* @var Client
*/
private $client;
/**
* Constructor.
*
* @param Client $client API client.
*/
public function __construct( Client $client ) {
$this->client = $client;
}
/**
* Create or update a contact from newsletter data.
*
* @param array $data Sanitized form data.
*
* @return array|\WP_Error
*/
public function save( array $data ) {
$existing = $this->client->request(
'POST',
'/people/fetch_by_email',
array( 'email' => $data['email'] )
);
// Found: update the existing person, keeping their current tags.
if ( ! is_wp_error( $existing ) && ! empty( $existing['id'] ) ) {
$old_tags = isset( $existing['tags'] ) ? (array) $existing['tags'] : array();
$tags = array_values( array_unique( array_merge( $old_tags, array( 'newsletter' ) ) ) );
return $this->client->request(
'PUT',
'/people/' . absint( $existing['id'] ),
array( 'tags' => $tags )
);
}
// Not found (404) or lookup failed: try creating a new person.
$payload = array(
'name' => $data['name'],
'emails' => array(
array(
'email' => $data['email'],
'category' => 'work',
),
),
'tags' => array( 'newsletter' ),
);
return $this->client->request( 'POST', '/people', $payload );
}
}
๐ก Why merge tags manually? Sending tags on an update can replace the existing list, so we merge first and nothing gets wiped by accident.
๐งผ Step 6: Form Handler (Validate, Sanitize, Escape)
This class receives both forms, checks the nonce, cleans the input and hands the data to the right service.
<?php
/**
* Form handler.
*
* @package CopperForms
*/
namespace CopperForms;
defined( 'ABSPATH' ) || exit;
/**
* Class Form_Handler
*/
class Form_Handler {
/**
* Prospect service.
*
* @var Prospect_Service
*/
private $prospects;
/**
* Contact service.
*
* @var Contact_Service
*/
private $contacts;
/**
* Constructor.
*
* @param Prospect_Service $prospects Prospect service.
* @param Contact_Service $contacts Contact service.
*/
public function __construct( Prospect_Service $prospects, Contact_Service $contacts ) {
$this->prospects = $prospects;
$this->contacts = $contacts;
}
/**
* Register hooks.
*
* @return void
*/
public function register() {
add_action( 'admin_post_nopriv_cf_contact_us', array( $this, 'handle_contact_us' ) );
add_action( 'admin_post_cf_contact_us', array( $this, 'handle_contact_us' ) );
add_action( 'admin_post_nopriv_cf_newsletter', array( $this, 'handle_newsletter' ) );
add_action( 'admin_post_cf_newsletter', array( $this, 'handle_newsletter' ) );
}
/**
* Handle the Contact Us form.
*
* @return void
*/
public function handle_contact_us() {
$this->verify_nonce( 'cf_contact_us' );
$data = array(
'name' => isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '',
'email' => isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '',
'message' => isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '',
);
if ( '' === $data['name'] || ! is_email( $data['email'] ) ) {
$this->redirect( 'invalid' );
}
$result = $this->prospects->upsert( $data );
$this->redirect( is_wp_error( $result ) ? 'error' : 'success' );
}
/**
* Handle the Newsletter form.
*
* @return void
*/
public function handle_newsletter() {
$this->verify_nonce( 'cf_newsletter' );
$data = array(
'name' => isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '',
'email' => isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '',
);
if ( '' === $data['name'] || ! is_email( $data['email'] ) ) {
$this->redirect( 'invalid' );
}
$result = $this->contacts->save( $data );
$this->redirect( is_wp_error( $result ) ? 'error' : 'success' );
}
/**
* Verify the nonce or stop.
*
* @param string $action Nonce action.
*
* @return void
*/
private function verify_nonce( $action ) {
$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, $action ) ) {
wp_die( esc_html__( 'Security check failed.', 'copper-forms' ), '', array( 'response' => 403 ) );
}
}
/**
* Redirect back with a status.
*
* @param string $status Status key.
*
* @return void
*/
private function redirect( $status ) {
$back = wp_get_referer() ? wp_get_referer() : home_url( '/' );
wp_safe_redirect( add_query_arg( 'cf_status', rawurlencode( $status ), esc_url_raw( $back ) ) );
exit;
}
}
๐ฆ Step 7: Bootstrap
<?php
/**
* Plugin Name: Copper Forms
* Description: Sends WordPress form entries to Copper CRM.
* Version: 1.0.0
* Text Domain: copper-forms
*
* @package CopperForms
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/includes/class-client.php';
require_once __DIR__ . '/includes/class-prospect-service.php';
require_once __DIR__ . '/includes/class-contact-service.php';
require_once __DIR__ . '/includes/class-form-handler.php';
add_action(
'plugins_loaded',
static function () {
$client = new \CopperForms\Client();
( new \CopperForms\Form_Handler(
new \CopperForms\Prospect_Service( $client ),
new \CopperForms\Contact_Service( $client )
) )->register();
}
);
On the front-end, each form posts to admin-post.php with a hidden action field (cf_contact_us or cf_newsletter) and a nonce from wp_nonce_field(). Since the form's output goes to the browser, remember to escape with esc_url() and esc_attr() there too. โ
๐ A Few Notes Before Production
- Don't block the visitor. For busy sites, push the Copper call to a background job (like WP-Cron or Action Scheduler) so a slow API call doesn't slow down the form.
- Log errors, but never log your API key.
-
Custom fields: look up your
custom_field_definition_idvalues first, then add them to the payloads. - Rate limits and bulk: if you import in bulk, check the Bulk Create People and Bulk Create Leads docs. Bulk requests are capped at 10 records.
๐ฌ Conclusion
Copper CRM is a friendly, Google-Workspace-first CRM, and its API makes it very pleasant to connect with WordPress. Quick recap:
-
Prospects =
leads, Contacts =peoplein the developer docs. Map by endpoint, not by label. - Contacts don't store duplicates, Prospects do. Use the upsert endpoint for leads, and a "fetch by email first" check for people.
- Tags, custom fields and webhooks give you a lot of power straight from the dashboard.
- A small OOP structure (client + services + handler) keeps your integration clean, secure and easy to extend.
Once the basics work, you've got a solid pipeline from "someone typed in a form" to "sales team knows about it." ๐
๐ What's Next?
In the next article, we'll do the same thing with Elementor Forms: hooking into the form submission action and sending those entries to Copper as Prospects and Contacts. Stay tuned! ๐
If this helped, drop a โค๏ธ or a comment. I'd love to hear how you're using Copper with WordPress!
Top comments (0)