Guide to Copper Webhooks: Features and Best Practices
Copper webhooks tell your systems when CRM records change: a lead comes in, a person is updated, an opportunity is deleted, a task is created. If you sync Copper into a data warehouse, mirror contacts into another tool, or start workflows when deals move, webhooks deliver those changes in near real time without polling the Copper API.
This guide covers how Copper subscriptions are created, what a notification contains, how to authenticate requests that Copper does not sign, the at-most-once delivery model that shapes everything else, and the best practices for production.
What are Copper webhooks?
A Copper webhook is a subscription: a target URL, an entity type and an event. When that event happens to a record of that type, Copper sends a JSON POST to the URL. Each subscription covers exactly one type and one event, so to hear about new, updated and deleted people you create three subscriptions. There is no UI for this: subscriptions are created and managed through the Developer API.
Copper doesn't sign notifications. A subscription can carry two optional objects that look like authentication:
headers: custom HTTP headers sent with every notification. Put a secret here and compare it on your side.secret: custom key/value pairs that Copper describes as a way for your endpoint to authenticate the request. Copper sends them inside the JSON body, merged in as top-level fields, not as headers.
The header is the better choice. It lets you reject a request before parsing the body, it keeps the secret out of anything that logs payloads, and it is what Hookdeck's Copper source checks.
The other thing to know up front: Copper fires each notification at most once and never retries, whatever your endpoint returns.
Copper webhook features
| Feature | Details |
|---|---|
| Configuration | Developer API only: POST https://api.copper.com/developer_ |
| Subscription model | One entity type and one event per subscription |
| Subscription limit | 100 active subscriptions per account |
| Entity types | lead, person, company, opportunity, project, task, and activity_log per the overview |
| Events | new, update, delete |
| Target | HTTPS URLs only |
| Batching | 1 to 30 record IDs per notification. More records means more notifications |
| Authentication | Custom headers, or secret key/value pairs in the body |
| Signature | None |
| Retries | None. At most once per event, regardless of the response |
| Rate limits | 600 notifications per minute and 1,800 per 10 minutes, per account |
| Custom fields | custom_ converts dropdown option IDs to names |
Common events
Copper's events are built from two fields in the body: type (the entity) and event (the action). Combining them gives keys like person.update.
event | Fires when |
|---|---|
new | A new record is created |
update | Any field on an existing record changes. Excludes new entity relationships, new activities and metadata changes |
delete | An existing record is removed |
The overview page lists all seven entity types, including activity_log for activities, as supporting all three events. The create-subscription page lists only six valid type values and leaves activity_log out, so test an activity_log subscription before you depend on it. Copper's overview labels the create action "Create", but the value you subscribe with and receive is new.
Here is Copper's example notification for an updated person:
{
"ids": [123],
"secret": "secret",
"key": "key",
"subscription_id": 1,
"event": "update",
"type": "person",
"updated_attributes": {
"custom_fields": {
"1": [[1], [1, 2]]
}
},
"timestamp": "2021-12-13T19:18:22.084Z"
}
The secret and key fields at the top level come from the subscription's secret object, which was {"secret": "secret", "key": "key"}. A subscription with no secret adds no fields. updated_attributes appears only on update events, as [old, new] pairs. ids is always an array, so a bulk change can deliver up to 30 records in one request. timestamp is an ISO 8601 string.
The notification identifies records but doesn't contain them. To act on a new or updated record, fetch it from the API using the IDs.
See Copper webhook payloads in action. Inspect and replay sample Copper webhook payloads in Hookdeck Inspect, with no account or setup required.
Setting up Copper webhooks
Generate a long random secret (openssl rand -hex 32 works) and store it in your app's environment. Then create a subscription for each type and event you need, passing the secret in headers:
curl -X POST "https://api.copper.com/developer_api/v1/webhooks" \
-H "X-PW-AccessToken: YOUR_TOKEN_HERE" \
-H "X-PW-Application: developer_api" \
-H "X-PW-UserEmail: YOUR_EMAIL_HERE" \
-H "Content-Type: application/json" \
-d '{
"target": "https://example.com/webhooks/copper",
"type": "person",
"event": "update",
"headers": { "X-Webhook-Secret": "your-long-random-secret" }
}'
Repeat for every combination, for example person with new and delete, then opportunity with all three. Add "custom_ if you'd rather receive dropdown option names than IDs. Use the list, update and delete subscription endpoints to manage what you've created, and keep an eye on the limit of 100.
The header name is your choice. Copper's own example uses "Authentication": "Bearer token". Whatever you send, your receiver must expect the whole value, so a header of Bearer token means comparing against Bearer token, not token.
For local development, use the Hookdeck CLI: hookdeck listen 3000 copper --path /webhooks/copper gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the subscription's target, since Copper only accepts https:// targets.
With Hookdeck. Create a Copper source in Hookdeck, enter the Header Name and Header Value exactly as they appear in your subscription's headers, and use the source URL as the target. If you only set the secret object, the source has nothing to check, so set headers too.
Securing Copper webhooks
Compare your secret header with the stored value. Look it up case-insensitively, compare the whole value in constant time, and check lengths first because crypto.timingSafeEqual throws on buffers of different lengths.
const crypto = require('crypto');
const express = require('express');
const app = express();
function safeEqual(a, b) {
const x = Buffer.from(String(a), 'utf8');
const y = Buffer.from(String(b), 'utf8');
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyCopperRequest(headers) {
const expected = process.env.COPPER_WEBHOOK_SECRET;
if (!expected) return false; // fail closed when unconfigured
const name = (process.env.COPPER_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
const received = headers[name];
return typeof received === 'string' && safeEqual(received, expected);
}
app.post('/webhooks/copper', express.raw({ type: '*/*' }), async (req, res) => {
if (!verifyCopperRequest(req.headers)) {
return res.sendStatus(401);
}
let notification;
try {
notification = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Copper never retries, so store the notification before acknowledging
await saveNotification(notification);
res.sendStatus(200);
const key = `${notification.type}.${notification.event}`; // e.g. person.update
// Hand key and notification.ids to a background worker
});
The same check in Python:
import hmac
import os
def verify_copper_request(headers) -> bool:
expected = os.environ.get("COPPER_WEBHOOK_SECRET")
if not expected:
return False
name = os.environ.get("COPPER_WEBHOOK_HEADER", "X-Webhook-Secret")
received = headers.get(name) # case-insensitive in Starlette and Flask
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))
A static header authenticates the sender, not the body, and offers no replay protection, since nothing is signed and the body's timestamp is not covered by any check. Keep the endpoint on HTTPS (Copper requires it anyway), keep the secret out of logs and source control, and rotate it by updating the subscription if it might have leaked. If you also use the secret object, remember that its values travel in every body, so anything that logs payloads logs the secret too.
Make Copper webhooks production-ready. Hookdeck Event Gateway checks your secret header on every notification and stores it durably the moment it arrives, so Copper's no-retry policy no longer means a lost event when your service is down.
Copper webhook limitations and pain points
No retries, ever
The Problem: If your endpoint is down, slow or returns an error, the notification is gone. Copper won't send it again.
Why It Happens: Copper says its notifications "are fired at most once per event, and we do not retry them, regardless of the status returned by the notification endpoint."
Workarounds:
- Keep the receiver as simple and available as possible: authenticate, store, acknowledge.
- Run a periodic reconciliation job that searches Copper for records changed since your last sync, to fill any gaps.
- Alert on endpoint errors so you know when to reconcile.
How Hookdeck Can Help: Hookdeck accepts each notification as it arrives and stores it before your service is involved, then delivers it with automatic retries on a schedule you control. Your downtime becomes a delay rather than data loss, and Issues alert you when deliveries to your service fail.
The documented "secret" travels in the body
The Problem: The secret object, which Copper presents as the way to authenticate notifications, is merged into the JSON body. You have to parse the body to check it, and every system that logs payloads also logs the secret.
Why It Happens: Copper copies the secret key/value pairs into each notification as ordinary top-level fields.
Workarounds:
- Authenticate with
headersinstead, and skipsecretor treat it as a secondary check. - If you keep
secret, strip those fields before logging or forwarding the body.
How Hookdeck Can Help: Hookdeck's Copper source checks the header you name against the exact value you enter. Requests without a matching header still get a 200 response, but Hookdeck marks them as failed verification and creates no event, so they never reach your destination. Transformations can remove secret fields from the body before delivery.
One subscription per type and event
The Problem: Covering every change across the CRM takes up to 21 subscriptions, each created and maintained through the API, all counting toward a limit of 100.
Why It Happens: A subscription takes exactly one type and one event, and there's no UI to manage them.
Workarounds:
- Script subscription setup so every environment gets the same set, and point them all at one URL.
- Periodically list subscriptions to catch drift or accidental deletions.
How Hookdeck Can Help: Point every subscription at one Hookdeck source and use filters on type and event to route notifications to different destinations, so routing lives in configuration rather than in your handler.
Bursts and batches
The Problem: A bulk import can produce many notifications, each with up to 30 IDs, and each ID usually needs an API call to fetch the record.
Why It Happens: Copper batches IDs per notification and caps the account at 600 notifications per minute and 1,800 per 10 minutes, but your follow-up API calls are on you.
Workarounds:
- Iterate every ID in
ids, never just the first. - Queue fetches and throttle them to stay inside Copper's API rate limits.
How Hookdeck Can Help: Hookdeck can rate-limit delivery to your service, so a burst from a bulk update arrives at a pace your workers can handle.
Best practices
Store before you acknowledge
With no retries, the only safe order is: authenticate, write the notification to durable storage, return 200, then process. Anything that fails after storage can be retried by you. See why to process webhooks asynchronously.
Treat the notification as a pointer
The body carries IDs and, for updates, the changed attributes. Fetch the current record before writing anything that depends on its full state, especially when notifications for the same record arrive close together.
Make handlers idempotent
Copper sends no event ID. Build a key from subscription_id, event, the record ID and timestamp, and skip repeats, for example after you replay stored notifications. See our guide to webhook idempotency.
Plan for gaps
Even a well-run receiver can miss a notification Copper never retries. A scheduled reconciliation against the Copper API is the backstop.
Conclusion
Copper sends unsigned JSON notifications for new, updated and deleted CRM records, one subscription per entity type and event, with up to 30 record IDs per request. Authenticate with a secret in the subscription's headers rather than the body-borne secret, and design around the fact that Copper sends each notification once and never retries.
Hookdeck Event Gateway checks the secret header, stores every notification on arrival and retries delivery to your service, which gives Copper's at-most-once model the safety net it doesn't have on its own.
Get started with Hookdeck for free and handle Copper webhooks reliably in minutes.