Connect with Webhooks or the Developer API
CopyLoop provides two ways to send customer data through one shared /v1 architecture:
| Use | Best for | What it sends | List routing |
|---|---|---|---|
| Webhook | Zapier, Make, n8n, form builders, and other POST-capable tools | Contacts | One list selected in CopyLoop |
| Developer API | A trusted backend, serverless function, or durable worker | Contact identity, product events, and consent evidence | Each identify request may name different lists |
- Best for
- Zapier, Make, n8n, form builders, and other POST-capable tools
- What it sends
- Contacts
- List routing
- One list selected in CopyLoop
- Best for
- A trusted backend, serverless function, or durable worker
- What it sends
- Contact identity, product events, and consent evidence
- List routing
- Each identify request may name different lists
Both return durable receipts. They intentionally use different edge credentials and payloads: a Webhook secret cannot call Developer API operations, and a Developer API key cannot submit to a Webhook URL.
Accepted data provides evidence and potential automation candidates. It does not give Loop or an automation authority to send email, publish content, or take another external action. CopyLoop still applies capability, approval, consent, suppression, and final-authority checks.
The OpenAPI document is the authoritative v1 contract.
Set up a connection
Section titled “Set up a connection”- In CopyLoop, open Settings → Integrations.
- Select Webhooks for an automation tool or Developer API for application code.
- Give the connection a name that identifies the system sending data, such as
Customer portalorSignup automation. - For a Webhook, select its destination contact list. For the Developer API, create any contact lists your application will use.
- Create the connection and save the displayed secret immediately. It is shown only once.
Customer-created connections send to the production workspace. There is no environment selector in the request or setup form. Use a controlled email address and a clearly named list for verification.
Keep every secret in protected automation-tool connection settings or a server-side secret manager. Never put one in browser JavaScript, page HTML, a URL, a public repository, ordinary logs, analytics, or a distributed mobile application.
Use a Webhook
Section titled “Use a Webhook”Choose a Webhook when a tool should send contacts without implementing the strict Developer API contract.
Save the generated URL and secret in the automation platform’s protected connection settings:
COPYLOOP_WEBHOOK_URL=https://api.copyloop.com/v1/webhooks/<key-id>COPYLOOP_WEBHOOK_SECRET=<secret-shown-once>Send JSON or form-encoded data to the generated URL:
curl --fail-with-body \ --request POST "$COPYLOOP_WEBHOOK_URL" \ --header "X-API-Key: $COPYLOOP_WEBHOOK_SECRET" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: signup:customer_123" \ --data '{ "email": "jane@example.com", "firstName": "Jane", "lastName": "Doe", "company": "Acme", "job_title": "VP Marketing" }'Only a valid email is required. Webhook v1 recognizes these standard fields:
| CopyLoop field | Accepted webhook names |
|---|---|
email, email_address, emailAddress |
|
| First name | firstName, first_name |
| Last name | lastName, last_name |
| Company | company, company_name, organization |
| Title | title, job_title |
| Phone | phone, phone_number |
| Mobile phone | mobilePhone, mobile_phone, mobile |
| City | city |
| State or region | state, region, province |
| Postal code | postalCode, postal_code, zip |
| Country | country |
| Timezone | timezone, time_zone |
Unknown fields are ignored. They are not automatically created as custom fields. Use the Developer API when you need explicit source identity, product events, or consent evidence.
A successful Webhook submission returns 202 Accepted and a receipt. When the sending tool has a
stable delivery ID, send it as Idempotency-Key:
- an identical retry returns the same receipt with
admissionDisposition: "transport_replay" - reusing the key with a different accepted contact value returns
409 idempotency_conflict - ignored fields and CopyLoop-generated observation metadata do not affect retry equality
- without a key, contact state still converges by normalized email, but retries can create multiple receipts
The Webhook always sends the contact to the list selected during setup. Change that destination in CopyLoop rather than adding a list ID to the Webhook payload.
Use the Developer API
Section titled “Use the Developer API”Choose the Developer API when you control a trusted backend and need a versioned contract.
Set these server-side environment variables:
COPYLOOP_API_BASE_URL=https://api.copyloop.comCOPYLOOP_APPLICATION_SOURCE_KEY=cl_src_<key-id>_<secret>Use one API connection per application or sending system. The key is not tied to a contact list: the same key can send different contacts to different lists.
Every write requires:
Authorization: Bearer <credential>Content-Type: application/json- an opaque
Idempotency-Keyof at most 200 characters
Use stable, non-personal identifiers. Do not put an email address or other personal data in a source user ID, idempotency key, observation ID, event ID, or consent decision ID.
1. Discover contact-list destinations
Section titled “1. Discover contact-list destinations”Read the active contact lists available to this API connection:
curl --fail-with-body \ --header "Authorization: Bearer $COPYLOOP_APPLICATION_SOURCE_KEY" \ "$COPYLOOP_API_BASE_URL/v1/contact-lists"{ "contactLists": [ { "id": "0198ec8e-3210-7abc-9123-0123456789ab", "name": "Product updates", "brand": { "id": "0198ec8e-3210-7abc-9123-0123456789ac", "name": "Acme" } }, { "id": "0198ec8e-3210-7abc-9123-0123456789ad", "name": "Workspace newsletter", "brand": null } ]}Persist or configure the stable id, not the display name. Names can change and need not be unique.
The optional brand helps distinguish similarly named lists.
This endpoint does not return contacts, membership counts, deleted lists, or credentials. An API key also cannot create, rename, or delete lists. If a destination is missing, a workspace owner creates it in Contacts → Lists, then the application reads this endpoint again.
2. Identify a contact
Section titled “2. Identify a contact”Send POST /v1/contacts/identify when a user is created or supported profile data changes.
CopyLoop contacts are email-addressable recipients. Email is required and is the canonical cross-source match key within a workspace. An application user and a Salesforce record with the same normalized email can therefore resolve to one CopyLoop contact. The application user ID remains a durable source alias for later signals.
This is a complete routine request:
curl --fail-with-body \ --request POST "$COPYLOOP_API_BASE_URL/v1/contacts/identify" \ --header "Authorization: Bearer $COPYLOOP_APPLICATION_SOURCE_KEY" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: identify:obs_user_123_revision_42" \ --data '{ "observationId": "obs_user_123_revision_42", "subject": { "type": "user", "id": "user_123" }, "observedAt": "2026-07-27T12:00:00.000Z", "email": { "address": "jane@example.com" }, "traits": { "firstName": "Jane", "lastName": "Doe", "company": "Acme" }, "addToListIds": [ "0198ec8e-3210-7abc-9123-0123456789ab" ] }'Only observationId, subject, observedAt, and email are required. CopyLoop applies these
routine defaults:
| Field | Default |
|---|---|
traitSchemaVersion |
1 |
mode |
patch |
state |
active |
traits |
{} |
unsetTraits |
[] |
Omitting a default and sending it explicitly have the same domain meaning.
addToListIds is optional and add-only:
- include up to 25 IDs returned by
GET /v1/contact-lists - use different IDs on different requests with the same API key
- omit it when the identify should not change list membership
- one invalid, deleted, or different-workspace ID rejects the complete request before CopyLoop creates the contact or receipt
Identify never removes an unlisted membership, restores an unsubscribed membership, records consent, or clears suppression.
Supported traits
Section titled “Supported traits”Trait schema version 1 accepts:
| Category | Fields |
|---|---|
| Standard contact | firstName, lastName, company, title, phone, mobilePhone, city, state, postalCode, country, timezone |
| Application context | plan, signedUpAt |
Standard contact traits can fill an empty CopyLoop contact field. The concrete application subject then owns that field and may update or unset it. An existing Salesforce-projected or manually managed value is not overwritten. If Salesforce or a manual edit takes over later, the Application Source relinquishes ownership instead of overwriting that value.
plan and signedUpAt remain source-owned context. They do not silently create workspace custom
fields.
The schema is closed intentionally. Unknown keys return 422 rather than becoming untyped,
unauditable data. Custom traits need an explicit field definition so CopyLoop can preserve their
type, provenance, rights, deletion, segmentation, and personalization behavior.
For advanced updates:
patchpreserves omitted traits; put a field name inunsetTraitsto remove a value owned by this sourcesnapshotreplaces the full source-owned trait snapshot and requiresunsetTraitsto be empty- use
unsetTraitsinstead of sending an empty string state: "inactive"stops this source alias from creating new personalized candidates; it does not delete the shared CopyLoop contact- an already-bound user reporting a different email is quarantined for reconciliation instead of being silently moved or merged
Optional email.verification is source-asserted evidence:
{ "status": "verified", "verifiedAt": "2026-07-27T11:59:00.000Z", "method": "customer_account"}verifiedAt is required for verified and must not be later than observedAt. The unverified
and unknown statuses must not include it. Omission preserves existing evidence only when the
normalized address is unchanged; verification never carries to another address.
3. Store the receipt
Section titled “3. Store the receipt”A successful write returns 202 Accepted after durable admission:
{ "receiptId": "rcpt_0198ec8e-3210-7abc-9123-0123456789ab", "admissionDisposition": "new", "requestId": "req_0198ec8e-3210-7abc-9123-0123456789ab", "acceptedAt": "2026-07-27T12:00:00.100Z"}Store receiptId. 202 means CopyLoop durably accepted the operation; it does not mean processing
has finished.
Read status with the same API key:
curl --fail-with-body \ --header "Authorization: Bearer $COPYLOOP_APPLICATION_SOURCE_KEY" \ "$COPYLOOP_API_BASE_URL/v1/receipts/$RECEIPT_ID"Treat the response’s terminal boolean as authoritative. Known statuses are accepted,
processing, applied, unresolved, quarantined, and failed.
unresolvedis nonterminal because a later identify may resolve the signal; still use a bounded polling deadlinequarantinedis terminal; correct the source data or arrange audited reconciliation instead of polling forever
Backend examples
Section titled “Backend examples”These helpers cover all three write operations:
POST /v1/contacts/identifyPOST /v1/eventsPOST /v1/consentcurl --fail-with-body \ --request POST "$COPYLOOP_API_BASE_URL/v1/contacts/identify" \ --header "Authorization: Bearer $COPYLOOP_APPLICATION_SOURCE_KEY" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: identify:obs_user_123_revision_42" \ --data @copyloop-identify.jsontype CopyLoopPath = '/v1/contacts/identify' | '/v1/events' | '/v1/consent';
export async function writeToCopyLoop( path: CopyLoopPath, idempotencyKey: string, body: unknown,) { const response = await fetch(`${process.env.COPYLOOP_API_BASE_URL}${path}`, { method: 'POST', headers: { authorization: `Bearer ${process.env.COPYLOOP_APPLICATION_SOURCE_KEY}`, 'content-type': 'application/json', 'idempotency-key': idempotencyKey, }, body: JSON.stringify(body), });
const result = await response.json(); if (response.status !== 202) { throw new Error( `CopyLoop ${response.status}: ` + `${result.error?.code ?? 'unknown_error'}`, ); } return result;}import osimport requests
WRITE_PATHS = { "identify": "/v1/contacts/identify", "event": "/v1/events", "consent": "/v1/consent",}
def write_to_copyloop(kind, idempotency_key, body): response = requests.post( os.environ["COPYLOOP_API_BASE_URL"] + WRITE_PATHS[kind], headers={ "Authorization": f"Bearer {os.environ['COPYLOOP_APPLICATION_SOURCE_KEY']}", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }, json=body, timeout=15, ) result = response.json() if response.status_code != 202: code = result.get("error", {}).get("code", "unknown_error") raise RuntimeError(f"CopyLoop {response.status_code}: {code}") return result<?phpfunction writeToCopyLoop( string $path, string $idempotencyKey, array $body): array { $handle = curl_init( getenv('COPYLOOP_API_BASE_URL') . $path ); curl_setopt_array($handle, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('COPYLOOP_APPLICATION_SOURCE_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode( $body, JSON_THROW_ON_ERROR ), ]);
$raw = curl_exec($handle); if ($raw === false) { throw new RuntimeException(curl_error($handle)); } $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $result = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); if ($status !== 202) { $code = $result['error']['code'] ?? 'unknown_error'; throw new RuntimeException("CopyLoop {$status}: {$code}"); } return $result;}using System.Net.Http.Headers;using System.Net.Http.Json;using System.Text.Json;
static async Task<JsonElement> WriteToCopyLoop( HttpClient client, string path, string idempotencyKey, object body){ using var request = new HttpRequestMessage(HttpMethod.Post, path); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", Environment.GetEnvironmentVariable( "COPYLOOP_APPLICATION_SOURCE_KEY")); request.Headers.Add("Idempotency-Key", idempotencyKey); request.Content = JsonContent.Create(body);
using var response = await client.SendAsync(request); var result = await response.Content.ReadFromJsonAsync<JsonElement>(); if ((int)response.StatusCode != 202) { var code = result.TryGetProperty("error", out var error) && error.TryGetProperty("code", out var value) ? value.GetString() : "unknown_error"; throw new InvalidOperationException( $"CopyLoop {(int)response.StatusCode}: {code}"); } return result;}Set HttpClient.BaseAddress from COPYLOOP_API_BASE_URL. Reuse the client instead of creating one
per request.
Deliver from durable work
Section titled “Deliver from durable work”Save your application’s primary action first. Then enqueue the CopyLoop operation in a durable job queue or transactional outbox. A CopyLoop outage should not block signup, checkout, profile updates, or consent changes in your application.
Persist the exact body, idempotency key, receipt ID, and CopyLoop request ID. Redact credentials, email addresses, and request bodies from ordinary logs.
Record a product event
Section titled “Record a product event”Send immutable activity to POST /v1/events. The initial catalog accepts trial.started schema
version 1:
{ "eventId": "evt_456", "subject": { "type": "user", "id": "user_123" }, "event": "trial.started", "schemaVersion": 1, "occurredAt": "2026-07-27T12:05:00.000Z", "properties": { "plan": "pro", "trialDays": 14 }}An event may arrive before identify. CopyLoop stores it as unresolved; it cannot personalize,
enroll, send, or publish. A later identify for the same source user can resolve it as historical
evidence.
Record consent evidence
Section titled “Record consent evidence”Send consent decisions to POST /v1/consent. Consent is immutable evidence, not a contact trait.
Include the email snapshot even when the user is already identified:
{ "decisionId": "consent_evt_789", "subject": { "type": "user", "id": "user_123" }, "scope": { "type": "marketing_channel", "channel": "email" }, "contactPoint": { "type": "email", "address": "jane@example.com" }, "decision": "withdraw", "occurredAt": "2026-07-27T12:10:00.000Z", "evidence": { "actorType": "end_user", "method": "preference_center", "collectionPoint": "application_preferences", "noticeVersion": "marketing-2026-07", "contentRef": "consent-copy:marketing-2026-07", "region": "EEA" }}Accepted decisions are grant, deny, withdraw, and resubscribe. V1 accepts the
marketing_channel and email scope.
An identify or event can never clear a denial or suppression. Production deny and withdraw
install the negative source/address fence and CopyLoop email suppression in the same database
transaction as the receipt. resubscribe requires supersedesDecisionId and is quarantined for
policy review. grant does not by itself clear an existing suppression.
Retry safely
Section titled “Retry safely”Retry network timeouts, connection resets, and HTTP 429, 500, 502, 503, and 504 with the
same idempotency key and exact body. Use capped exponential backoff with jitter and honor
Retry-After.
Do not retry 400, 401, 403, 409, 413, or 422 until the request or credential is
corrected.
Developer API admission dispositions are:
new: first durable admissiontransport_replay: same operation, idempotency key, and canonical bodydomain_duplicate: a new transport key carried the same observation, event, or decision
Reusing an idempotency key or domain ID with different content returns 409.
Limits and data restrictions
Section titled “Limits and data restrictions”- Maximum request body: 65,536 bytes
- Burst limit: 600 requests per minute, independently per credential and originating IP
- Daily admission limit: configured per connection
- Timestamp window: no more than 10 years old and no more than 10 minutes in the future
Negative consent bypasses the daily admission quota so quota exhaustion cannot discard a withdrawal, but it remains subject to abuse-protection burst limits.
Send only declared data needed for the integration. Do not send passwords, authentication tokens, API keys, payment-card data, national identifiers, private keys, health data, message bodies, or unrestricted free-form notes. CopyLoop does not put accepted raw properties in logs, workflow queue messages, analytics rows, or AI context.
Notify CopyLoop immediately about an access, export, correction, restriction, or erasure request that may include Application Source data. Deleting an ordinary contact does not represent completion of a legal rights request.
Verify the integration
Section titled “Verify the integration”- Create a clearly named contact list with automations disabled.
- Create the Webhook or Developer API connection.
- For the Developer API, read
GET /v1/contact-listsand configure the intended IDs. - Send one controlled contact and store the returned receipt.
- Read the receipt until
terminalis true and confirmstatus: "applied". - Confirm the contact and intended list membership in CopyLoop.
- Verify an exact retry returns the same receipt and changed content under that key returns
409. - For events, send one before identify and confirm it is initially
unresolved. - Identify the same source user and confirm resolution by email.
- Confirm no email was sent and no automation or Loop action gained authority from ingestion.
Monitor accepted, terminal, failed, unresolved, and quarantined receipts after launch. Reconcile a small sample against source records before expanding volume.