# Create a new contact structure Source: https://developers.benchmarkemail.io/api-reference/contact-structure/create-a-new-contact-structure /openapi.json post /api/contact-structure # Get a contact structure by id Source: https://developers.benchmarkemail.io/api-reference/contact-structure/get-a-contact-structure-by-id /openapi.json get /api/contact-structure/{contactStructureId} # Get all contact structures Source: https://developers.benchmarkemail.io/api-reference/contact-structure/get-all-contact-structures /openapi.json get /api/contact-structure # Update a contact structure by id Source: https://developers.benchmarkemail.io/api-reference/contact-structure/update-a-contact-structure-by-id /openapi.json put /api/contact-structure/{contactStructureId} # Create a new contact Source: https://developers.benchmarkemail.io/api-reference/contact/create-a-new-contact /openapi.json post /api/contact # Delete a contact by id Source: https://developers.benchmarkemail.io/api-reference/contact/delete-a-contact-by-id /openapi.json delete /api/contact/{contactId} # Export contacts Source: https://developers.benchmarkemail.io/api-reference/contact/export-contacts /openapi.json post /api/contact/export # Get a contact by id Source: https://developers.benchmarkemail.io/api-reference/contact/get-a-contact-by-id /openapi.json get /api/contact/{contactId} # Get account contact events Source: https://developers.benchmarkemail.io/api-reference/contact/get-account-contact-events /openapi.json get /api/contact/events Get all contact events for the account # Get all contacts Source: https://developers.benchmarkemail.io/api-reference/contact/get-all-contacts /openapi.json get /api/contact # Get contact events Source: https://developers.benchmarkemail.io/api-reference/contact/get-contact-events /openapi.json get /api/contact/{contactId}/events Get events for a contact # Partial update a contact by id Source: https://developers.benchmarkemail.io/api-reference/contact/partial-update-a-contact-by-id /openapi.json patch /api/contact/{contactId} # Search for contacts Source: https://developers.benchmarkemail.io/api-reference/contact/search-for-contacts /openapi.json post /api/contact/search # Update a contact by id Source: https://developers.benchmarkemail.io/api-reference/contact/update-a-contact-by-id /openapi.json put /api/contact/{contactId} # Add a campaign Source: https://developers.benchmarkemail.io/api-reference/email-campaign/add-a-campaign /openapi.json post /api/email/campaign # Delete campaign by id Source: https://developers.benchmarkemail.io/api-reference/email-campaign/delete-campaign-by-id /openapi.json delete /api/email/campaign/{campaignId} # Duplicate an existing campaign Source: https://developers.benchmarkemail.io/api-reference/email-campaign/duplicate-an-existing-campaign /openapi.json post /api/email/campaign/{campaignId}/duplicate # Get all campaigns Source: https://developers.benchmarkemail.io/api-reference/email-campaign/get-all-campaigns /openapi.json get /api/email/campaign # Get campaign by id Source: https://developers.benchmarkemail.io/api-reference/email-campaign/get-campaign-by-id /openapi.json get /api/email/campaign/{campaignId} # Update campaign by id Source: https://developers.benchmarkemail.io/api-reference/email-campaign/update-campaign-by-id /openapi.json patch /api/email/campaign/{campaignId} # Get a domain by id Source: https://developers.benchmarkemail.io/api-reference/email-domain/get-a-domain-by-id /openapi.json get /api/email/domain/{domainId} # Get all domains Source: https://developers.benchmarkemail.io/api-reference/email-domain/get-all-domains /openapi.json get /api/email/domain # Get the grey label domain for the account Source: https://developers.benchmarkemail.io/api-reference/email-domain/get-the-grey-label-domain-for-the-account /openapi.json get /api/email/domain/grey-label # Get email template by ID Source: https://developers.benchmarkemail.io/api-reference/email-template/get-email-template-by-id /openapi.json get /api/email/template/{id} Get an email template by its ID # Get email template categories Source: https://developers.benchmarkemail.io/api-reference/email-template/get-email-template-categories /openapi.json get /api/email/template/categories Get all email template categories # Get email templates Source: https://developers.benchmarkemail.io/api-reference/email-template/get-email-templates /openapi.json get /api/email/template Get email templates with optional filters # Create a new list Source: https://developers.benchmarkemail.io/api-reference/lists/create-a-new-list /openapi.json post /api/contact-structure/{contactStructureId}/lists # Delete a list by id Source: https://developers.benchmarkemail.io/api-reference/lists/delete-a-list-by-id /openapi.json delete /api/contact-structure/{contactStructureId}/lists/{listId} # Delete multiple lists Source: https://developers.benchmarkemail.io/api-reference/lists/delete-multiple-lists /openapi.json delete /api/contact-structure/{contactStructureId}/lists # Duplicate a list Source: https://developers.benchmarkemail.io/api-reference/lists/duplicate-a-list /openapi.json post /api/contact-structure/{contactStructureId}/lists/{listId}/duplicate # Get a list by id Source: https://developers.benchmarkemail.io/api-reference/lists/get-a-list-by-id /openapi.json get /api/contact-structure/{contactStructureId}/lists/{listId} # Get all lists Source: https://developers.benchmarkemail.io/api-reference/lists/get-all-lists /openapi.json get /api/contact-structure/{contactStructureId}/lists/all # Get paginated lists Source: https://developers.benchmarkemail.io/api-reference/lists/get-paginated-lists /openapi.json get /api/contact-structure/{contactStructureId}/lists # Merge lists into a new list. Source: https://developers.benchmarkemail.io/api-reference/lists/merge-lists-into-a-new-list /openapi.json post /api/contact-structure/{contactStructureId}/lists/merge # Update a list by id Source: https://developers.benchmarkemail.io/api-reference/lists/update-a-list-by-id /openapi.json patch /api/contact-structure/{contactStructureId}/lists/{listId} # Get dashboard snapshot reports Source: https://developers.benchmarkemail.io/api-reference/reports/get-dashboard-snapshot-reports /openapi.json get /api/reports/dashboard # Get individual email campaign engagement reports Source: https://developers.benchmarkemail.io/api-reference/reports/get-individual-email-campaign-engagement-reports /openapi.json get /api/reports/email/{id}/engagement # Get individual email campaign reports Source: https://developers.benchmarkemail.io/api-reference/reports/get-individual-email-campaign-reports /openapi.json get /api/reports/email/{id} # Get overall email histogram by event type Source: https://developers.benchmarkemail.io/api-reference/reports/get-overall-email-histogram-by-event-type /openapi.json get /api/reports/email/overall/histogram # Get overall email reports Source: https://developers.benchmarkemail.io/api-reference/reports/get-overall-email-reports /openapi.json get /api/reports/email/overall # Authentication Source: https://developers.benchmarkemail.io/authentication Authenticate API requests with an API key and manage permissions via scopes. All API requests must include a valid API key in the `X-API-Key` header. ```bash theme={null} curl -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ "/api/contact-structure" ``` ## Getting an API Key 1. Log in to Benchmark Email with an **Owner** account. Only users with the Owner role can create and manage API keys. 2. Navigate to **Settings > API Keys**. 3. Click **Create API Key**. 4. Enter a descriptive name (e.g., "Zapier Sync", "CRM Integration"). 5. Select the scopes (permissions) the key needs -- see [Scopes](#scopes) below. 6. Optionally set an expiration date. If you skip this, the key never expires. 7. Click **Create** and copy the key immediately. The full API key is displayed **only once** at creation time. If you lose it, you can regenerate the key from the API Keys page (this invalidates the old key and issues a new one). ## API Key Format All Benchmark Email API keys are 50 characters long. Each key starts with `bme_` followed by a 2-letter region code (such as `us`, `jp`, or `eu`) and 43 random characters: ``` bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v ``` The region code reflects your account's region and is set automatically when the key is created. You do not need to do anything with it -- use the key exactly as shown on the API Keys page. ## Scopes Each API key is granted one or more scopes that control which resources it can access. Scopes follow the `{resource}:{access}` format. ### Available Scopes | Scope | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------- | | `contacts:read` | Read contacts, lists, contact structures, search contacts, export contacts, view contact events and history | | `contacts:write` | Create, update, and delete contacts and lists; update contact structures | | `campaigns:read` | Read campaigns and browse email templates | | `campaigns:write` | Create, update, delete, and duplicate campaigns | | `reports:read` | View dashboard summaries and email performance reports | | `domains:read` | View email sending domains | ### Write Implies Read Granting **write** access for a resource automatically includes **read** access. For example, a key with `contacts:write` can also read contacts -- you do not need to select both. ### Principle of Least Privilege Create keys with only the permissions they need. For example: * A reporting dashboard only needs `reports:read`. * A contact sync integration needs `contacts:write` (which includes read access). * A read-only data export tool needs `contacts:read`. ### Scope Errors If a request requires a scope that your key does not have, you will receive a `403 Forbidden` response with a message identifying the required scope. See [Errors](/errors#missing-required-scope-403) for details. ## Account Standing API keys only work when your Benchmark Email account is in good standing. Keys are active when your account status is: * **Open** -- normal active account * **Pending Cancel** -- account is scheduled for cancellation but still active Keys will stop working (returning `403 Forbidden`) if your account is in any other status, such as suspended, past due, or terminated. ## Key Lifecycle | Key State | Behavior | | ------------ | ----------------------------------------------------------------- | | **Active** | Key authenticates requests normally | | **Inactive** | Key has been deactivated by the owner; returns `401 Unauthorized` | | **Expired** | Key's expiration date has passed; returns `401 Unauthorized` | | **Deleted** | Key has been permanently removed; returns `401 Unauthorized` | You can deactivate and reactivate keys from the API Keys page without deleting them. This is useful for temporarily disabling an integration. ## Example: Listing Contact Structures ```bash theme={null} curl -X GET \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ "/api/contact-structure" ``` **Response (200 OK):** ```json theme={null} [ { "_id": "64a1b2c3d4e5f6a7b8c9d0e1", "label": "Default Contacts", "keyName": "Email", "keyType": "email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text" } ] } ] ``` ## Example: Creating a Contact ```bash theme={null} curl -X POST \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "key": "john.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "John" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ] }' \ "/api/contact" ``` **Response (200 OK):** ```json theme={null} { "_id": "65a1b2c3d4e5f6a7b8c9d0e2", "key": "john.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "John" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "status": { "primary": "active", "secondary": "confirmed" }, "createdAt": "2026-03-30T14:22:00.000Z" } ``` This request requires the `contacts:write` scope. If your key only has `contacts:read`, you will receive a `403 Forbidden` error. ## Next Steps * [Rate Limits](/rate-limits) -- understand request limits and quotas * [Errors](/errors) -- handle error responses * [API Reference](/api-reference) -- explore available resources # Errors Source: https://developers.benchmarkemail.io/errors HTTP status codes and the JSON error format returned by the API. The Benchmark Email API uses standard HTTP status codes and returns structured error responses to help you diagnose and handle problems. ## Error Response Format All errors return a JSON body with an `errors` array: ```json theme={null} { "errors": [ { "errorType": "ForbiddenError", "message": "API key missing required scope: contacts:write" } ] } ``` Each error object contains: | Field | Type | Description | | ----------- | ------ | ------------------------------------------------------------------------------------- | | `errorType` | string | The error class name (e.g., `UnauthorizedError`, `ForbiddenError`, `ValidationError`) | | `message` | string | A human-readable description of what went wrong | The `errors` array typically contains a single error object. Validation errors may include additional fields like `field` or `fieldId` to identify the problematic input. ## HTTP Status Codes | Status | Meaning | When It Happens | | ------ | ----------------- | ------------------------------------------------------------------------------------ | | `400` | Bad Request | Invalid request body, missing required fields, validation failures, duplicate values | | `401` | Unauthorized | Invalid, expired, or inactive API key | | `403` | Forbidden | Valid key but missing required scope, or account not in good standing | | `404` | Not Found | Requested resource does not exist | | `429` | Too Many Requests | Hourly rate limit or monthly quota exceeded, or IP temporarily blocked | ## Common Error Scenarios ### Invalid API Key (401) Returned when the API key is malformed, does not exist, or has been deleted. ```bash theme={null} curl -H "X-API-Key: bme_invalid_key_value" \ "/api/contact" ``` ``` HTTP/1.1 401 Unauthorized ``` ```json theme={null} { "errors": [ { "errorType": "UnauthorizedError", "message": "Invalid API key" } ] } ``` For security reasons, the error message does not distinguish between a malformed key, a nonexistent key, or a deleted key. ### Expired API Key (401) Returned when the API key's expiration date has passed. ``` HTTP/1.1 401 Unauthorized ``` ```json theme={null} { "errors": [ { "errorType": "UnauthorizedError", "message": "API key expired" } ] } ``` To resolve this, create a new API key or regenerate the expired one from the API Keys page. ### Inactive API Key (401) Returned when the API key has been deactivated by the account owner. ``` HTTP/1.1 401 Unauthorized ``` ```json theme={null} { "errors": [ { "errorType": "UnauthorizedError", "message": "Invalid API key" } ] } ``` Inactive keys return the same generic message as invalid keys. Reactivate the key from the API Keys page to restore access. ### Missing Required Scope (403) Returned when the API key is valid but does not have the scope required for the requested operation. The error message tells you which scope is needed. ```bash theme={null} # Attempting to create a contact with a read-only key curl -X POST \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com"}' \ "/api/contact" ``` ``` HTTP/1.1 403 Forbidden ``` ```json theme={null} { "errors": [ { "errorType": "ForbiddenError", "message": "API key missing required scope: contacts:write" } ] } ``` To resolve this, either: * Edit the key's scopes from the API Keys page to add the required permission. * Create a new key with the appropriate scopes. ### Endpoint Not Accessible via API Key (403) Returned when the endpoint is not available for API key access (e.g., billing, user management, or admin endpoints). ``` HTTP/1.1 403 Forbidden ``` ```json theme={null} { "errors": [ { "errorType": "ForbiddenError", "message": "This endpoint is not accessible via API key" } ] } ``` Only endpoints listed in the [API Reference](/api-reference) are accessible via API key. ### Account Not in Good Standing (403) Returned when the API key is valid but the account status does not allow API access (e.g., suspended, past due, or terminated accounts). ``` HTTP/1.1 403 Forbidden ``` ```json theme={null} { "errors": [ { "errorType": "ForbiddenError", "message": "Account does not have API access" } ] } ``` API keys work when the account status is `open` or `pending_cancel`. See [Authentication](/authentication#account-standing) for details. ### Resource Not Found (404) Returned when the requested resource does not exist. ```bash theme={null} curl -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ "/api/contact/000000000000000000000000" ``` ``` HTTP/1.1 404 Not Found ``` ```json theme={null} { "errors": [ { "errorType": "RecordNotFound", "message": "Contact not found" } ] } ``` ### Validation Error (400) Returned when the request body fails validation (e.g., missing required fields, invalid field values). ```bash theme={null} curl -X POST \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{"key": "test@example.com"}' \ "/api/contact" ``` ``` HTTP/1.1 400 Bad Request ``` ```json theme={null} { "errors": [ { "errorType": "ValidationError", "message": "contactStructureId is required", "field": "contactStructureId" } ] } ``` ### Duplicate Field (400) Returned when the operation conflicts with existing data (e.g., creating an API key with a name that already exists). ``` HTTP/1.1 400 Bad Request ``` ```json theme={null} { "errors": [ { "errorType": "DuplicateFieldError", "message": "API key name is already in use" } ] } ``` ### Rate Limit Exceeded (429) Returned when the hourly rate limit or monthly quota is exceeded. See [Rate Limits](/rate-limits) for full details. **Hourly limit exceeded:** ``` HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 3600 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711828800 ``` ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Rate limit exceeded. Retry after 45 seconds." } ] } ``` **Monthly quota exceeded:** ``` HTTP/1.1 429 Too Many Requests Retry-After: 86400 ``` ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Monthly API quota exceeded." } ] } ``` The `Retry-After` header indicates the number of seconds to wait before retrying. For hourly limits, this is the time until the current window resets. For monthly quotas, this is the time until the billing period resets. ### IP Temporarily Blocked (429) Returned when your IP address has been temporarily blocked due to too many failed authentication attempts. ``` HTTP/1.1 429 Too Many Requests Retry-After: 900 ``` ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Too many failed authentication attempts. Please try again later." } ] } ``` Stop retrying and verify your API key is correct. The block will automatically expire after the time indicated in `Retry-After`. ## Error Handling Best Practices 1. **Always check the status code first.** Use the HTTP status to determine the category of error before parsing the response body. 2. **Retry on 429 only.** Use the `Retry-After` header to determine when to retry. Do not retry `401` or `403` errors -- they will not resolve without configuration changes. 3. **Log the full error response.** Include the `errorType` and `message` in your logs for debugging. 4. **Handle scope errors proactively.** If you receive a `403` with a scope message, update your API key's permissions in Settings rather than retrying the request. 5. **Use exponential backoff for rate limits.** See the [Rate Limits](/rate-limits#handling-rate-limits) guide for retry strategies. ## Next Steps * [Rate Limits](/rate-limits) -- detailed rate limiting information * [Authentication](/authentication) -- API key setup and scopes * [API Reference](/api-reference) -- explore available resources # Benchmark Email API Source: https://developers.benchmarkemail.io/introduction Manage contacts, campaigns, reports, and domains programmatically. The Benchmark Email API lets you manage contacts, campaigns, reports, and domains programmatically. Use it to build custom integrations, automate workflows with tools like Zapier or n8n, or sync data between Benchmark Email and your other systems. ## Quick start ### 1. Create an API key 1. Log in to Benchmark Email as an account owner. 2. Go to **Settings > API Keys**. 3. Click **Create API Key**. 4. Give it a name (e.g., "My Integration") and select the permissions you need. 5. Copy the key immediately -- it will only be shown once. ### 2. Copy your base URL Your base URL depends on the region and cluster your account is provisioned in. It looks like this: `https://api-{region}-{cluster}.benchmarkemail.io` For example: `https://api-us-west-2-a.benchmarkemail.io` Find your base URL in **Account Settings > API Keys** and use it wherever you see `` in the examples on this site. ### 3. Make your first request Copy your API base URL from **Account Settings > API Keys** and use it in place of ``: ```bash theme={null} curl -H "X-API-Key: bme_your-api-key-here" \ "/api/contact-structure" ``` You should receive a JSON response with your contact structure, including your custom field definitions. If you get a `401` or `403` error, see the [errors guide](/errors) for troubleshooting. ## API reference | Document | Description | | --------------------------------- | --------------------------------------------------------------- | | [Authentication](/authentication) | How to authenticate requests, scopes, and account requirements | | [Rate Limits](/rate-limits) | Hourly rate limits, monthly quotas, and response headers | | [Errors](/errors) | Error response format, status codes, and common error scenarios | | [API Reference](/api-reference) | Browse the full OpenAPI specification | ## Available resources | Resource | Scopes | What You Can Do | | --------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------- | | Contacts | `contacts:read`, `contacts:write` | Create, read, update, delete contacts; manage lists; update custom fields; search and export contacts | | Campaigns | `campaigns:read`, `campaigns:write` | Create, read, update, delete, and duplicate email campaigns; browse templates | | Reports | `reports:read` | View dashboard summaries and email performance reports | | Domains | `domains:read` | View email sending domains | ## Scenario guides For step-by-step walkthroughs of common workflows, see the [scenario guides](/scenarios/manage-contacts). ## Building with an AI assistant? If you're using an AI coding assistant, give it these resources before you start: * [llms.txt](https://developers.benchmarkemail.io/llms.txt): sitemap-style index of every documentation page * [llms-full.txt](https://developers.benchmarkemail.io/llms-full.txt): full text of every documentation page in one file * [SKILL.md](https://developers.benchmarkemail.io/.well-known/skills/benchmarkinternetgroup/SKILL.md): agent-specific playbook with endpoints, decision guidance, and common pitfalls Here's a prompt you can give your AI assistant: ``` You're helping me integrate with the Benchmark Email API. Before writing any code, read https://developers.benchmarkemail.io/.well-known/skills/benchmarkinternetgroup/SKILL.md for the most useful starting context. Then read the relevant scenario guide at https://developers.benchmarkemail.io/scenarios/[scenario-name]. ``` ## Need more help? Visit the Benchmark Email [Help Center](https://benchmarkemail.helpscoutdocs.com/) for guides, FAQs, and live chat support. # Rate Limits Source: https://developers.benchmarkemail.io/rate-limits Hourly request limits and monthly quotas that govern your API usage. The Benchmark Email API enforces two levels of rate limiting to ensure fair usage and platform stability: an **hourly rate limit** and a **monthly quota**. ## Hourly Rate Limit Each account is limited to **3,600 requests per hour**, which is approximately 1 request per second sustained. This limit applies across all API keys on the account -- if you have multiple keys, they share the same hourly budget. ### Rate Limit Response Headers Every successful API key request includes headers showing your current rate limit status: | Header | Description | Example | | ----------------------- | ------------------------------------------------------ | ------------ | | `X-RateLimit-Limit` | Maximum requests allowed per hour | `3600` | | `X-RateLimit-Remaining` | Requests remaining in the current hour | `3542` | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the hourly window resets | `1711828800` | **Example response headers:** ``` HTTP/1.1 200 OK X-RateLimit-Limit: 3600 X-RateLimit-Remaining: 3542 X-RateLimit-Reset: 1711828800 X-Monthly-Limit: 100000 X-Monthly-Remaining: 98750 Content-Type: application/json ``` ## Monthly Quota Each account has a monthly API request quota that resets at the start of each billing period. The default quota depends on your plan type: | Plan type | Default quota | | --------- | ------------------- | | Free | Contact limit x 10 | | Paid | Contact limit x 100 | ### Monthly Quota Response Headers | Header | Description | Example | | --------------------- | ------------------------------------------------------ | -------- | | `X-Monthly-Limit` | Maximum requests allowed in the current billing period | `100000` | | `X-Monthly-Remaining` | Requests remaining in the current billing period | `98750` | ## Exceeding Rate Limits When you exceed either limit, the API returns a `429 Too Many Requests` response. ### Hourly Limit Exceeded ```bash theme={null} curl -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ "/api/contact" ``` **Response (429 Too Many Requests):** ``` HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 3600 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711828800 ``` ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Rate limit exceeded. Retry after 45 seconds." } ] } ``` The `Retry-After` header indicates how many seconds to wait before retrying. ### Monthly Quota Exceeded ``` HTTP/1.1 429 Too Many Requests Retry-After: 86400 ``` ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Monthly API quota exceeded." } ] } ``` When the monthly quota is exceeded, the `Retry-After` value indicates the number of seconds until the billing period resets. ## Handling Rate Limits ### Best Practices 1. **Monitor response headers.** Check `X-RateLimit-Remaining` and `X-Monthly-Remaining` on each response to stay within limits. 2. **Use exponential backoff.** When you receive a `429` response, wait for the duration specified in the `Retry-After` header. If you continue to receive `429` responses, increase the wait time exponentially: ``` Wait time = min(Retry-After * 2^attempt, 300) ``` Cap the maximum wait at 5 minutes (300 seconds). 3. **Spread requests evenly.** Instead of bursting 3,600 requests in a few minutes, distribute them evenly across the hour (\~1 per second). 4. **Cache responses.** If you repeatedly fetch the same data, cache it locally instead of re-requesting it from the API. ### Example: Retry Logic (pseudocode) ``` function makeRequest(url, headers): maxRetries = 3 for attempt in 0..maxRetries: response = httpGet(url, headers) if response.status != 429: return response retryAfter = response.headers["Retry-After"] or 60 waitTime = min(retryAfter * (2 ^ attempt), 300) sleep(waitTime) raise Error("Rate limit exceeded after retries") ``` ## Failed Authentication Protection To protect against key probing and brute-force attacks, the API monitors failed authentication attempts by IP address. If an IP address sends too many requests with invalid API keys, it will be temporarily blocked. During a block, all API key requests from that IP address receive a `429 Too Many Requests` response: ```json theme={null} { "errors": [ { "errorType": "TooManyRequestsError", "message": "Too many failed authentication attempts. Please try again later." } ] } ``` The `Retry-After` header indicates when the block will expire. To avoid triggering this protection: * Verify your API key is correct before sending many requests. * Do not cycle through possible key values. * If you receive repeated `401` responses, stop and check your key rather than retrying immediately. ## Summary | Limit | Threshold | Scope | Reset | | -------------------- | ---------------------------------------- | ------------------------------------ | --------------------------- | | Hourly rate limit | 3,600 requests/hour | Per account (shared across all keys) | Fixed 1-hour window | | Monthly quota | Contact limit x 10 (free) / x 100 (paid) | Per account | Billing period start | | Failed auth blocking | Excessive invalid keys per IP | Per IP address | Automatic (temporary block) | ## Next Steps * [Errors](/errors) -- understand all error responses * [Authentication](/authentication) -- API key setup and scopes # Manage Campaigns Source: https://developers.benchmarkemail.io/scenarios/manage-campaigns Create, update, and manage email campaigns using the API. Create, read, update, and manage email campaigns using the API. Browse available email templates to use as starting points. ## Goal By the end of this guide you will be able to list campaigns, retrieve campaign details, create new campaigns, update campaign settings, duplicate campaigns, delete campaigns, and browse email templates. ## Prerequisites * An API key with **`campaigns:write`** scope (for full CRUD) or **`campaigns:read`** scope (for read-only access) * Your API base URL (found on the Settings > API Keys page) * At least one contact list and a verified sending domain (for creating campaigns) **Important:** Scheduling, sending, canceling, and test-sending campaigns is not available via API key. Use the Benchmark Email web application for those actions. API keys can prepare campaigns (create, configure, assign recipients) that are then sent manually from the UI. ## Steps ### 1. List campaigns Retrieve campaigns with optional filtering by status, pagination, and sorting. ```bash theme={null} curl "/api/email/campaign?page=1&size=25&sort=updatedAt:desc" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "total": 42, "records": [ { "_id": "66f1a6e4698f1bca60426001", "name": "March Newsletter", "from": "news@mybusiness.com", "subject": "Your March Update is Here", "previewText": "New features, tips, and more", "status": "draft", "createdAt": "2026-03-25T10:00:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z" }, { "_id": "66f1a6e4698f1bca60426002", "name": "Spring Sale Announcement", "from": "offers@mybusiness.com", "subject": "Spring Sale - 30% Off Everything", "previewText": "Limited time offer", "previewImageUrl": "https://cdn.benchmarkemail.com/previews/66f1a6e4698f1bca60426002.png", "status": "sent", "sentCount": 4520, "failedCount": 12, "totalRecipients": 4532, "scheduledAt": "2026-03-20T09:00:00.000Z", "createdAt": "2026-03-18T16:00:00.000Z", "updatedAt": "2026-03-20T09:05:00.000Z", "stats": { "sent": 4520, "delivered": 4389, "opens": { "unique": 1876, "rate": 42.74 }, "clicks": { "unique": 623, "rate": 14.19 } } } ] } ``` **Query parameters:** | Parameter | Type | Description | | ---------- | ------- | ------------------------------------------------------------------------------------------------- | | `page` | integer | Page number (1-indexed) | | `size` | integer | Results per page | | `statuses` | string | Filter by status: `draft`, `scheduled`, `sent`, `sending`, `paused`, `failed`, `cancelled` | | `sort` | string | Sort field and direction, e.g. `name:asc`, `createdAt:desc`, `updatedAt:desc`, `scheduledAt:desc` | | `criteria` | string | Search filter text to match campaign names | ### 2. Get a campaign by ID ```bash theme={null} curl "/api/email/campaign/66f1a6e4698f1bca60426001" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60426001", "name": "March Newsletter", "from": "news@mybusiness.com", "fromName": "My Business", "subject": "Your March Update is Here", "previewText": "New features, tips, and more", "body": "...", "plainTextBody": "Your March Update is Here...", "replyToAddresses": ["support@mybusiness.com"], "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "lists": ["66f1a6e4698f1bca60425001"], "status": "draft", "createdAt": "2026-03-25T10:00:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 2 } ``` ### 3. Create a campaign Create a new draft campaign. At minimum, provide a name. Other fields like subject, from address, and body can be set during creation or added later via update. ```bash theme={null} curl -X POST "/api/email/campaign" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "April Product Update", "from": "news@mybusiness.com", "fromName": "My Business", "subject": "What'\''s New in April", "previewText": "Exciting updates inside", "body": "

April Update

Here are the latest updates...

", "plainTextBody": "April Update\n\nHere are the latest updates...", "replyToAddresses": ["support@mybusiness.com"], "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "lists": ["66f1a6e4698f1bca60425001"] }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60426003", "name": "April Product Update", "from": "news@mybusiness.com", "fromName": "My Business", "subject": "What's New in April", "previewText": "Exciting updates inside", "status": "draft", "createdAt": "2026-03-28T15:00:00.000Z", "updatedAt": "2026-03-28T15:00:00.000Z" } ``` **Campaign fields:** | Field | Type | Description | | -------------------- | ------- | ------------------------------------------------------------------ | | `name` | string | Campaign name (for internal reference) | | `from` | string | Sender email address (must be from a verified domain) | | `fromName` | string | Sender display name (max 100 characters) | | `subject` | string | Email subject line | | `previewText` | string | Preview text shown in inbox (max 100 characters) | | `body` | string | HTML email body | | `plainTextBody` | string | Plain text version of the email | | `replyToAddresses` | array | Reply-to email addresses | | `contactStructureId` | string | Contact structure for recipient selection | | `lists` | array | List IDs to send to | | `allowDuplicates` | boolean | Allow creating a campaign with a duplicate name (default: `false`) | ### 4. Update a campaign Update an existing draft campaign. Include the `__v` version field for optimistic concurrency control. ```bash theme={null} curl -X PATCH "/api/email/campaign/66f1a6e4698f1bca60426003" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "subject": "April Product Update - Don'\''t Miss Out!", "previewText": "New features and improvements", "lists": [ "66f1a6e4698f1bca60425001", "66f1a6e4698f1bca60425002" ], "__v": 0 }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60426003", "name": "April Product Update", "from": "news@mybusiness.com", "fromName": "My Business", "subject": "April Product Update - Don't Miss Out!", "previewText": "New features and improvements", "status": "draft", "createdAt": "2026-03-28T15:00:00.000Z", "updatedAt": "2026-03-28T15:30:00.000Z", "__v": 1 } ``` ### 5. Duplicate a campaign Create a copy of an existing campaign. Useful for creating variations or reusing a previous campaign as a template. ```bash theme={null} curl -X POST "/api/email/campaign/66f1a6e4698f1bca60426002/duplicate" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Spring Sale Follow-Up" }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60426004", "name": "Spring Sale Follow-Up", "from": "offers@mybusiness.com", "subject": "Spring Sale - 30% Off Everything", "status": "draft", "createdAt": "2026-03-28T16:00:00.000Z", "updatedAt": "2026-03-28T16:00:00.000Z" } ``` The duplicated campaign starts in `draft` status regardless of the original campaign's status. ### 6. Delete a campaign ```bash theme={null} curl -X DELETE "/api/email/campaign/66f1a6e4698f1bca60426003" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`204 No Content`): Empty body. ### 7. Browse email templates List available email templates to use as starting points for your campaigns. Templates are read-only via API key. ```bash theme={null} curl "/api/email/template?lang=en" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "total": 24, "records": [ { "id": "welcome-email-001", "name": "Welcome Email", "subject": "Welcome to Our Community", "imageUrl": "https://cdn.benchmarkemail.com/templates/welcome-email-001.png", "unlayerId": 12345, "attributeIds": ["onboarding", "welcome"], "lang": "en" }, { "id": "monthly-newsletter-002", "name": "Monthly Newsletter", "subject": "Your Monthly Update", "imageUrl": "https://cdn.benchmarkemail.com/templates/monthly-newsletter-002.png", "unlayerId": 12346, "attributeIds": ["newsletter"], "lang": "en" } ] } ``` Get a specific template by ID: ```bash theme={null} curl "/api/email/template/66f1a6e4698f1bca60427001?lang=en" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` You can also list template categories: ```bash theme={null} curl "/api/email/template/categories?lang=en" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` ## Common Errors | Status | Error | Cause | Fix | | ------ | -------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------- | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` — missing scope | Key lacks `campaigns:write` or `campaigns:read` scope | Create or upgrade your key with the required scope | | `403` | `ForbiddenError` — send blocked | Attempted to schedule, cancel, or test-send via API key | These operations are not available via API key. Use the web application | | `400` | `ValidationError` | Invalid campaign data (e.g., unverified from address) | Check the request body. The `from` address must belong to a verified domain | | `404` | `RecordNotFound` | Campaign ID does not exist | Verify the campaign ID | | `400` | `DuplicateFieldError` | Duplicate campaign name | Set `allowDuplicates: true` in the request body, or use a unique name | | `429` | `TooManyRequestsError` | Rate limit exceeded | Wait for the `Retry-After` period. See [Rate Limits](/rate-limits) | ## Next Steps * [View Reports](/scenarios/view-reports) — check performance metrics for sent campaigns * [Manage Lists](/scenarios/manage-lists) — create and manage the lists your campaigns send to * [Manage Contacts](/scenarios/manage-contacts) — manage the contacts on those lists * [Migration from Legacy](/scenarios/migration-from-legacy) — set up your complete email program via API # Manage Contacts Source: https://developers.benchmarkemail.io/scenarios/manage-contacts Create, read, update, and delete contacts using the API. Create, read, update, and delete contacts in your Benchmark Email account using the API. ## Goal By the end of this guide you will be able to perform full CRUD operations on contacts: retrieve your contact structure (to get field IDs), create new contacts, retrieve them individually, update their fields or status, and delete them. ## Prerequisites * An API key with **`contacts:write`** scope (for full CRUD) or **`contacts:read`** scope (for read-only access) * Your API base URL (found on the Account Settings > API Keys page) ## About contact status Every contact has a `status` field that tracks whether they're reachable. The shape depends on the contact's state. **Active contacts** have only a primary status: ```json theme={null} "status": { "primary": "Active" } ``` **Inactive contacts** have both a primary status and a secondary status. The secondary explains *why* the contact is inactive: ```json theme={null} "status": { "primary": "Inactive", "secondary": "Unsubscribe" } ``` A few rules to know: * **Status values are case-sensitive.** `Active`, `Inactive`, and `Unsubscribe` must use exact capitalization. Lowercase values such as `"active"` are rejected with a validation error. * **Customers can only set `Unsubscribe` as the secondary value via the API.** This is the only customer-initiated reason for inactivating a contact. Other secondary values appear on contacts the system has inactivated automatically based on email events, and cannot be set through API calls. The values you may see on contacts the system has inactivated include: * `Bounce` — the contact's email address hard-bounced * `Complaint-FBL` — the contact's mailbox provider reported a spam complaint via the Feedback Loop (FBL) mechanism * **When transitioning a contact to Inactive, both `primary` and `secondary` are required.** Sending only `primary: "Inactive"` returns a validation error. * **Once a contact is Inactive, it cannot be reactivated via the API.** This is a deliberate compliance protection — customers who unsubscribe or hard-bounce should not be re-emailed. To re-add a contact, they must opt in again through your sign-up flow. ## Steps ### 1. Fetch your contact structure Before creating contacts, retrieve your contact structure to get the field IDs you will need. Every contact belongs to a contact structure, and field values are set by referencing each field's `_id`. ```bash theme={null} curl "/api/contact-structure" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} [ { "_id": "64a1b2c3d4e5f6a7b8c9d0e1", "label": "Default Contacts", "keyName": "Email", "keyType": "email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "predefinedField": "lastName" } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300", "label": "VIP" } ] } ] ``` Save the contact structure `_id` and the field `_id` values. You will use these when creating and updating contacts. See [Manage Custom Fields](./manage-custom-fields.md) for more details on contact structures. ### 2. Create a contact Send a `POST` request with the contact's email address (`key`), the contact structure it belongs to, and optionally any custom field values and list assignments. ```bash theme={null} curl -X POST "/api/contact" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" } ] }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" } ], "tags": [], "status": { "primary": "Active" }, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 0 } ``` Responses also include `accountId`, `createdBy`, and `modifiedBy` fields, which are auto-populated by the system and read-only. They're omitted from the samples here for clarity. **Key points:** * `key` is the contact's email address (required) * `contactStructureId` is required — it determines which custom fields are available * `fields` uses the field `_id` from your contact structure (see Step 1) * `lists` optionally assigns the contact to one or more lists at creation time * New contacts start with `__v: 0`. You'll need this version value when updating the contact via PUT. ### 3. Get a contact by ID Retrieve a single contact using its `_id`. ```bash theme={null} curl "/api/contact/66f1a6e4698f1bca60424901" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" } ], "tags": [], "status": { "primary": "Active" }, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 3 } ``` The `__v` field is the contact's current version. You'll need this exact value when updating the contact via PUT (see Step 5). ### 4. Update a contact's status Use `PATCH` to update a contact's status. The most common case is unsubscribing a contact: ```bash theme={null} curl -X PATCH "/api/contact/66f1a6e4698f1bca60424901" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "status": { "primary": "Inactive", "secondary": "Unsubscribe" } }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" } ], "tags": [], "status": { "primary": "Inactive", "secondary": "Unsubscribe" }, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T15:10:00.000Z", "__v": 4 } ``` **Notes:** * `PATCH` only accepts the `status` field. To update other fields (email, custom fields, lists, tags), use `PUT` (see Step 5). * Both `primary` and `secondary` are required when transitioning a contact to Inactive. * Inactive contacts cannot be reactivated via the API. See [About contact status](#about-contact-status) above. ### 5. Replace a contact (full update) Use `PUT` to update any field on a contact: email, custom fields, lists, tags, or status. Unlike `PATCH`, `PUT` accepts the full contact body. **Important:** PUT replaces the entire contact. Any field you omit from the request body will be removed from the contact's stored data. To avoid accidentally losing data, follow these three steps: 1. (If needed) Find the contact's `_id` via [Search Contacts](./search-contacts.md). 2. Send a `GET` request to retrieve the contact's full data, including its current `__v` version. 3. Send a `PUT` request using the GET response as your starting body. Change only the fields you want to update, and include `__v` exactly as you received it from the GET. ```bash theme={null} curl -X PUT "/api/contact/66f1a6e4698f1bca60424901" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith-Jones" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" }, { "_id": "64a1b2c3d4e5f6a7b8c9d201" } ], "tags": [], "status": { "primary": "Active" }, "__v": 3 }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith-Jones" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" }, { "_id": "64a1b2c3d4e5f6a7b8c9d201" } ], "tags": [], "status": { "primary": "Active" }, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T16:00:00.000Z", "__v": 4 } ``` **Notes on `__v`:** * `__v` is required on every PUT request. It's the contact's optimistic-concurrency version, which prevents one update from silently overwriting another. * Always use the `__v` from a *fresh* GET — not a value cached from earlier. Successful PATCH or PUT calls increment `__v`, so a stale value will be rejected. * If `__v` does not match the contact's current version, the API returns a `400 ConcurrencyError`. Re-run the GET, rebuild your PUT body, and retry. **Notes on status in PUT:** * Status follows the same rules as PATCH: capitalized values, both `primary` and `secondary` required for Inactive. * For an Active contact, send `"status": { "primary": "Active" }` (no `secondary`). * PUT cannot change an Inactive contact back to Active — see [About contact status](#about-contact-status). ### 6. Delete a contact ```bash theme={null} curl -X DELETE "/api/contact/66f1a6e4698f1bca60424901" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): Returns the deleted contact object. ### 7. List all contacts Retrieve all contacts for your account. This endpoint returns all matching contacts in a single response (no pagination). For large contact databases, use [Search Contacts](./search-contacts.md) instead, which supports pagination and filtering. ```bash theme={null} curl "/api/contact" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} [ { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "lists": [ { "_id": "64a1b2c3d4e5f6a7b8c9d200" } ], "tags": [], "status": { "primary": "Active" }, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 0 } ] ``` ## Common errors | Status | Error | Cause | Fix | | ------ | ---------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401` | `UnauthorizedError` | Invalid, expired, or inactive API key | Verify your key is correct and active in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks the required scope | Use a key with `contacts:write` for create/update/delete operations | | `400` | `RequiredFieldError` | A required field is missing from the request body | Check the error's `field` and `message` for the specific field name | | `400` | `ModelValidationError` | A field value fails validation (wrong type, invalid enum value, missing case-paired field, blocked transition) | Read the `message` and `debug` for specifics. Common cases: lowercase status values, transitioning Inactive → Active, missing `secondary` when setting `primary: "Inactive"` | | `400` | `ConcurrencyError` | The `__v` value in your PUT body does not match the contact's current version | Run a fresh `GET`, rebuild your PUT body using the new `__v`, and retry | | `400` | `DuplicateFieldError` | A contact with the same email already exists in this structure | Use [Search Contacts](./search-contacts.md) to look up the existing contact, then update it instead of creating a new one | | `404` | `RecordNotFound` | Contact ID does not exist | Verify the contact ID; it may have been deleted | | `429` | `TooManyRequestsError` | Rate limit or monthly quota exceeded | Wait for the `Retry-After` period and retry. See [Rate Limits](../rate-limits.md) | ## Next steps * [Search Contacts](./search-contacts.md) — find contacts by email or custom field values * [Manage Lists](./manage-lists.md) — organize contacts into lists * [Manage Custom Fields](./manage-custom-fields.md) — define the fields available on your contacts * [Migration from Legacy](./migration-from-legacy.md) — end-to-end workflow for importing your data # Manage Custom Fields Source: https://developers.benchmarkemail.io/scenarios/manage-custom-fields View and update the contact structure that defines fields on your contacts. View and update the contact structure that determines which fields are available on your contacts. ## Goal By the end of this guide you will be able to retrieve your contact structure with its fields and tags, and update it to add or modify custom fields. ## Prerequisites * An API key with **`contacts:write`** scope (for updating the structure) or **`contacts:read`** scope (for read-only access) * Your API base URL (found on the Settings > API Keys page) ## Background A **contact structure** defines the schema for your contacts. It includes: * A **key name** and **key type** — the primary identifier for contacts (typically "Email" of type "email") * **Fields** — custom fields like First Name, Last Name, Company, Phone, etc. * **Tags** — label definitions that can be assigned to contacts Every contact belongs to exactly one contact structure. Your account has a default contact structure that was created when the account was set up. **Note:** Creating new contact structures via API key is not supported. Use the Benchmark Email web application to create additional structures if needed. API keys can read and update existing structures. ## Steps ### 1. List all contact structures Retrieve all contact structures in your account. ```bash theme={null} curl "/api/contact-structure" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} [ { "_id": "64a1b2c3d4e5f6a7b8c9d0e1", "label": "Default Contacts", "keyName": "Email", "keyType": "email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "label": "Company", "dataType": "text", "required": false } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300", "label": "VIP" }, { "_id": "64a1b2c3d4e5f6a7b8c9d301", "label": "Prospect" } ] } ] ``` **Key takeaway:** The `_id` values of the fields array are what you use when creating or updating contacts and when searching by custom field values. ### 2. Get a specific contact structure Retrieve a single contact structure by its ID. ```bash theme={null} curl "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "_id": "64a1b2c3d4e5f6a7b8c9d0e1", "label": "Default Contacts", "keyName": "Email", "keyType": "email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "label": "Company", "dataType": "text", "required": false } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300", "label": "VIP" }, { "_id": "64a1b2c3d4e5f6a7b8c9d301", "label": "Prospect" } ], "__v": 3 } ``` ### 3. Update a contact structure Use `PUT` to update a contact structure. This replaces the full structure, so include all existing fields plus any changes. Include the current `__v` value for optimistic concurrency control. Adding a new "Loyalty Tier" field to an existing structure: ```bash theme={null} curl -X PUT "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "label": "Default Contacts", "keyName": "Email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "label": "Company", "dataType": "text", "required": false }, { "label": "Loyalty Tier", "dataType": "text", "required": false } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300", "label": "VIP" }, { "_id": "64a1b2c3d4e5f6a7b8c9d301", "label": "Prospect" } ], "__v": 3 }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "64a1b2c3d4e5f6a7b8c9d0e1", "label": "Default Contacts", "keyName": "Email", "keyType": "email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "label": "Company", "dataType": "text", "required": false }, { "_id": "64a1b2c3d4e5f6a7b8c9d103", "label": "Loyalty Tier", "dataType": "text", "required": false } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300", "label": "VIP" }, { "_id": "64a1b2c3d4e5f6a7b8c9d301", "label": "Prospect" } ], "__v": 4 } ``` **Supported field data types:** * `text` — free-text string * `number` — numeric value * `date` — date value * `boolean` — true/false * `recurrent_date` — recurring date (month/day) **Predefined fields:** Use `predefinedField` to map standard fields like `firstName`, `lastName`. These enable features like merge tags in email campaigns. **Important notes:** * `PUT` is a full replacement. Include all existing fields you want to keep, or they will be removed. * When adding a new field, omit `_id` — the server generates it. * When keeping existing fields, include their `_id` to preserve them. * Include the current `__v` value for optimistic concurrency control. * Removing a field that is referenced by a published signup form will return a `400` error with details about which forms are affected. ## Common Errors | Status | Error | Cause | Fix | | ------ | ------------------------ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks `contacts:write` scope | Update operations require `contacts:write` | | `403` | `ForbiddenError` | Attempted to create a new contact structure via API key | Creating contact structures is not available via API key. Use the web application | | `400` | `ValidationError` | Missing required properties (`label`, `keyName`), or invalid field `dataType` | Check request body against the schema requirements | | `400` | `ContactFieldInUseError` | Tried to remove a field used by a signup form | Update or unpublish the affected form first. The error response includes `debug.affectedForms` with form IDs and names | | `404` | `RecordNotFound` | Contact structure ID does not exist | Verify the structure ID | | `429` | `TooManyRequestsError` | Rate limit exceeded | Wait for the `Retry-After` period. See [Rate Limits](/rate-limits) | ## Next Steps * [Manage Contacts](/scenarios/manage-contacts) — create contacts using the field IDs from your structure * [Manage Lists](/scenarios/manage-lists) — create lists within a contact structure * [Search Contacts](/scenarios/search-contacts) — search by custom field values * [Migration from Legacy](/scenarios/migration-from-legacy) — set up your structure before importing data # Manage Lists Source: https://developers.benchmarkemail.io/scenarios/manage-lists Create and manage contact lists to segment your audience. Create, organize, and manage contact lists to segment your audience for targeted email campaigns. ## Goal By the end of this guide you will be able to create lists within a contact structure, list all existing lists, update list names, delete lists, duplicate a list, and merge multiple lists into one. ## Prerequisites * An API key with **`contacts:write`** scope (for full list management) or **`contacts:read`** scope (for read-only access) * Your API base URL (found on the Settings > API Keys page) * A **contact structure ID** — lists belong to a contact structure. Retrieve yours with `GET /api/contact-structure` (see [Manage Custom Fields](/scenarios/manage-custom-fields)) ## Steps ### 1. Create a list Create a new list within a contact structure. The only required field is `name`. ```bash theme={null} curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter Subscribers" }' ``` **Response** (`201 Created`): ```json theme={null} { "_id": "66f1a6e4698f1bca60425001", "name": "Newsletter Subscribers", "type": "static", "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 0 } ``` **Notes:** * List type defaults to `"static"` (the only supported type currently) * List names can be up to 1,000 characters ### 2. List all lists (paginated) Retrieve lists with pagination, sorting, and search support. ```bash theme={null} curl "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists?page=1&size=25&sort=name:asc" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "total": 2, "records": [ { "_id": "66f1a6e4698f1bca60425001", "name": "Newsletter Subscribers", "type": "static", "totalContacts": 1250, "totalCampaigns": 3, "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 0 }, { "_id": "66f1a6e4698f1bca60425002", "name": "VIP Customers", "type": "static", "totalContacts": 85, "totalCampaigns": 7, "createdAt": "2026-03-20T10:00:00.000Z", "updatedAt": "2026-03-25T16:45:00.000Z", "__v": 1 } ] } ``` **Query parameters:** | Parameter | Type | Description | | ---------- | ------- | ----------------------------------------------------------- | | `page` | integer | Page number (1-indexed) | | `size` | integer | Number of results per page | | `sort` | string | Sort field and direction, e.g. `name:asc`, `createdAt:desc` | | `criteria` | string | Search filter text to match list names | To retrieve all lists without pagination (returns `_id`, `name`, and `type` only): ```bash theme={null} curl "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/all" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` ### 3. Get a single list ```bash theme={null} curl "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/66f1a6e4698f1bca60425001" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60425001", "name": "Newsletter Subscribers", "type": "static", "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "__v": 0 } ``` ### 4. Update a list Rename a list by sending a `PATCH` request. You must include the current `__v` (version) value for optimistic concurrency control. ```bash theme={null} curl -X PATCH "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/66f1a6e4698f1bca60425001" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Monthly Newsletter", "__v": 0 }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60425001", "name": "Monthly Newsletter", "type": "static", "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T15:00:00.000Z", "__v": 1 } ``` **Important:** The `__v` field prevents conflicts when multiple clients update the same list. Always send the current `__v` value from your last GET response. If another update occurred in the meantime, you will receive a `400` error with a `ConcurrencyError` type. ### 5. Delete a list Delete a single list by ID: ```bash theme={null} curl -X DELETE "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/66f1a6e4698f1bca60425001" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`204 No Content`): Empty body. To delete multiple lists at once, send a `DELETE` to the lists collection endpoint: ```bash theme={null} curl -X DELETE "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "listIds": [ "66f1a6e4698f1bca60425001", "66f1a6e4698f1bca60425002" ] }' ``` **Response** (`204 No Content`): Empty body. ### 6. Duplicate a list Create a copy of an existing list (contacts are copied to the new list). ```bash theme={null} curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/66f1a6e4698f1bca60425001/duplicate" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter Subscribers (Copy)" }' ``` **Response** (`201 Created`): ```json theme={null} { "_id": "66f1a6e4698f1bca60425003", "name": "Newsletter Subscribers (Copy)", "type": "static", "createdAt": "2026-03-28T16:00:00.000Z", "updatedAt": "2026-03-28T16:00:00.000Z", "__v": 0 } ``` ### 7. Merge lists Combine two or more lists into a new list. Contacts from all source lists are added to the new list (duplicates are handled automatically). ```bash theme={null} curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists/merge" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "All Subscribers (Merged)", "listIds": [ "66f1a6e4698f1bca60425001", "66f1a6e4698f1bca60425002" ] }' ``` **Response** (`200 OK`): ```json theme={null} { "_id": "66f1a6e4698f1bca60425004", "name": "All Subscribers (Merged)", "type": "static", "createdAt": "2026-03-28T16:30:00.000Z", "updatedAt": "2026-03-28T16:30:00.000Z", "__v": 0 } ``` **Notes:** * The source lists are not deleted; they remain intact * The merged list is a new list containing all unique contacts from the source lists * At least one `listId` is required ## Common Errors | Status | Error | Cause | Fix | | ------ | ---------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks `contacts:write` scope | Upgrade the key's scopes or create a new key with `contacts:write` | | `400` | `ValidationError` | Missing required fields (e.g., `name`) | Check request body against the schema requirements | | `400` | `ConcurrencyError` | `__v` mismatch on update — another update occurred since your last read | Fetch the latest version with GET and retry with the current `__v` | | `404` | `RecordNotFound` | Contact structure ID or list ID does not exist | Verify IDs match existing resources | | `429` | `TooManyRequestsError` | Rate limit or monthly quota exceeded | Wait for the `Retry-After` period. See [Rate Limits](/rate-limits) | ## Next Steps * [Manage Contacts](/scenarios/manage-contacts) — create contacts and assign them to lists * [Search Contacts](/scenarios/search-contacts) — find contacts within a specific list * [Manage Custom Fields](/scenarios/manage-custom-fields) — define the fields on your contact structure * [Migration from Legacy](/scenarios/migration-from-legacy) — end-to-end data migration workflow # Migration from Legacy Source: https://developers.benchmarkemail.io/scenarios/migration-from-legacy Migrate contact data into Benchmark Email: contact structure, lists, imports, and verification. Migrate your contact data into Benchmark Email using the API. This guide walks through the complete end-to-end workflow: reviewing your contact structure, creating lists, importing contacts, and verifying the results. ## Goal By the end of this guide you will have migrated your contact data into Benchmark Email by adding custom fields to your contact structure, creating lists, creating contacts with all their field values, assigning contacts to lists, and verifying the migration was successful. ## Prerequisites * An API key with **`contacts:write`** scope (includes read access) * Your API base URL (found on the Settings > API Keys page) * Your source data (contacts, field definitions, list assignments) exported from your legacy system * Familiarity with [API authentication](/authentication) ## Overview The migration follows this order: ``` 1. Review and update contact structure (custom fields) | 2. Create lists | 3. Create contacts (with field values and list assignments) | 4. Verify the migration ``` Each step depends on the previous one: contacts reference field IDs from the structure, and list IDs for assignments. Plan your migration before writing code. ## Steps ### Step 1: Review and update your contact structure First, retrieve your existing contact structure to see what fields are already defined. ```bash theme={null} curl "/api/contact-structure" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` Your account has a default contact structure. If you need to add custom fields to match your legacy data, use `PUT` to update the structure. For example, to add Company, Phone, City, State, and Signup Source fields: ```bash theme={null} curl -X PUT "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "label": "Contacts", "keyName": "Email", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" }, { "label": "Company", "dataType": "text", "required": false }, { "label": "Phone", "dataType": "text", "required": false }, { "label": "City", "dataType": "text", "required": false }, { "label": "State", "dataType": "text", "required": false }, { "label": "Signup Source", "dataType": "text", "required": false } ], "tags": [ { "label": "Customer" }, { "label": "Prospect" }, { "label": "VIP" } ], "__v": 0 }' ``` Save the response. You will need: * The contact structure `_id` (e.g., `64a1b2c3d4e5f6a7b8c9d0e1`) * Each field's `_id` to map your data to the correct fields * Each tag's `_id` if you plan to assign tags See [Manage Custom Fields](/scenarios/manage-custom-fields) for details. ### Step 2: Create lists Create lists that match your legacy system's segmentation. You will need the contact structure ID from Step 1. ```bash theme={null} # Create first list curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Newsletter Subscribers" }' # Create second list curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "Product Updates" }' # Create third list curl -X POST "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "name": "VIP Customers" }' ``` Save each list's `_id` from the responses. You will need these when creating contacts. See [Manage Lists](/scenarios/manage-lists) for details. ### Step 3: Create contacts Now create contacts one at a time, mapping your legacy data to the field IDs from your contact structure and assigning list memberships. ```bash theme={null} curl -X POST "/api/contact" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "key": "jane.smith@example.com", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "value": "Acme Corp" }, { "_id": "64a1b2c3d4e5f6a7b8c9d103", "value": "555-0123" }, { "_id": "64a1b2c3d4e5f6a7b8c9d104", "value": "San Francisco" }, { "_id": "64a1b2c3d4e5f6a7b8c9d105", "value": "CA" }, { "_id": "64a1b2c3d4e5f6a7b8c9d106", "value": "website-form" } ], "lists": [ { "_id": "66f1a6e4698f1bca60425001" }, { "_id": "66f1a6e4698f1bca60425003" } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300" }, { "_id": "64a1b2c3d4e5f6a7b8c9d302" } ] }' ``` **Bulk migration script pattern (pseudocode):** ```javascript theme={null} const API_BASE = ""; const API_KEY = "bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v"; const STRUCTURE_ID = "64a1b2c3d4e5f6a7b8c9d0e1"; // Map your legacy field names to Benchmark field IDs const fieldMap = { "first_name": "64a1b2c3d4e5f6a7b8c9d100", "last_name": "64a1b2c3d4e5f6a7b8c9d101", "company": "64a1b2c3d4e5f6a7b8c9d102", "phone": "64a1b2c3d4e5f6a7b8c9d103", "city": "64a1b2c3d4e5f6a7b8c9d104", "state": "64a1b2c3d4e5f6a7b8c9d105", "source": "64a1b2c3d4e5f6a7b8c9d106", }; // Map your legacy list names to Benchmark list IDs const listMap = { "newsletter": "66f1a6e4698f1bca60425001", "product_updates": "66f1a6e4698f1bca60425002", "vip": "66f1a6e4698f1bca60425003", }; async function migrateContact(legacyContact) { const fields = Object.entries(fieldMap) .filter(([legacyName]) => legacyContact[legacyName]) .map(([legacyName, bmeFieldId]) => ({ _id: bmeFieldId, value: legacyContact[legacyName], })); const lists = (legacyContact.lists || []) .filter((name) => listMap[name]) .map((name) => ({ _id: listMap[name] })); const response = await fetch(`${API_BASE}/api/contact`, { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ key: legacyContact.email, contactStructureId: STRUCTURE_ID, fields, lists, }), }); return { email: legacyContact.email, status: response.status }; } ``` ### Step 4: Handle errors and retries Bulk migrations will encounter transient errors. Implement retry logic with exponential backoff. **Rate limit handling:** The API allows 3,600 requests per hour (\~1 request/second sustained). For large migrations, pace your requests accordingly. ```javascript theme={null} async function migrateWithRetry(contact, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { const result = await migrateContact(contact); if (result.status === 200) { return { success: true, email: contact.email }; } if (result.status === 429) { // Rate limited — wait for the Retry-After period const retryAfter = parseInt(result.headers?.get("Retry-After") || "60", 10); console.log(`Rate limited. Waiting ${retryAfter}s before retry...`); await sleep(retryAfter * 1000); continue; } if (result.status === 400) { // Could be a duplicate contact — check the error type const body = await result.json(); const errorType = body?.errors?.[0]?.errorType; if (errorType === "DuplicateFieldError") { console.log(`Duplicate: ${contact.email} — skipping`); return { success: true, email: contact.email, skipped: true }; } } if (result.status >= 500) { // Server error — retry with backoff const backoff = Math.pow(2, attempt) * 1000; console.log(`Server error (${result.status}). Retrying in ${backoff}ms...`); await sleep(backoff); continue; } // Client error (403, etc.) — do not retry console.error(`Failed: ${contact.email} — ${result.status}`); return { success: false, email: contact.email, status: result.status }; } return { success: false, email: contact.email, error: "max retries exceeded" }; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } ``` **Dedup before creating:** To avoid duplicate errors for contacts that already exist, search before creating: ```javascript theme={null} async function dedup(email, structureId) { const response = await fetch(`${API_BASE}/api/contact/search`, { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ contactStructureId: structureId, page: 1, pageSize: 1, source: ["_id", "key"], contactSpecification: { filters: [ { criterias: [ { columnToFilter: "KEY", operator: "EQ", values: [email] }, ], }, ], }, }), }); const data = await response.json(); return data.totalRecords > 0 ? data.contacts[0] : null; } ``` See [Search Contacts](/scenarios/search-contacts) for more search patterns. ### Step 5: Verify the migration After all contacts are created, verify the migration by checking counts and spot-checking individual contacts. **Check total contact count:** ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 1, "source": ["_id"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "CONTACT_STATUS", "operator": "EQ", "values": ["active"] } ] } ] } }' ``` Check the `totalRecords` value in the response against your expected count. **Check list membership counts:** ```bash theme={null} curl "/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists?page=1&size=100" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` Each list in the response includes a `totalContacts` count. **Spot-check a specific contact:** ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 1, "source": ["_id", "key", "fields", "tags", "lists"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "KEY", "operator": "EQ", "values": ["jane.smith@example.com"] } ] } ] } }' ``` Verify that the returned fields, tags, and list assignments match your source data. ## Migration Checklist * [ ] Export all contacts from your legacy system * [ ] Map legacy fields to Benchmark field types * [ ] Update the contact structure with all required fields * [ ] Record the field ID mapping (legacy field name to Benchmark `_id`) * [ ] Create all lists and record their IDs * [ ] Run a small test batch (10-50 contacts) to validate the mapping * [ ] Run the full migration with retry logic * [ ] Verify total contact count matches expectations * [ ] Verify list membership counts * [ ] Spot-check 5-10 individual contacts for field accuracy ## Tips for Large Migrations 1. **Pace your requests.** The API rate limit is 3,600 requests/hour. For 10,000 contacts, expect the migration to take approximately 3 hours. 2. **Log everything.** Keep a log of each contact created (email, status code, contact ID) so you can identify and retry failures. 3. **Use dedup checks sparingly.** Each dedup search counts against your rate limit. If you are confident your source data has no duplicates, skip the dedup step and handle duplicate errors (`400 DuplicateFieldError`) instead. 4. **Batch your field mapping once.** Fetch the contact structure once at the start, build your field map, and reuse it for every contact. Do not fetch the structure for each contact. 5. **Monitor your monthly quota.** Each API request counts toward your monthly quota (`contactLimit x 10` on free plans, `contactLimit x 100` on paid plans). Check remaining quota via the `X-Monthly-Remaining` response header. ## Common Errors | Status | Error | Cause | Fix | | ------ | ---------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks `contacts:write` scope | Migration requires `contacts:write` | | `400` | `ValidationError` | Missing `key` or `contactStructureId`, or invalid field ID | Verify your field mapping matches the contact structure | | `400` | `DuplicateFieldError` | Contact with this email already exists | Use search to dedup, or update the existing contact instead | | `429` | `TooManyRequestsError` | Rate limit or monthly quota exceeded | Implement backoff and respect the `Retry-After` header. See [Rate Limits](/rate-limits) | ## Next Steps * [Search Contacts](/scenarios/search-contacts) — verify imported contacts and run quality checks * [Manage Lists](/scenarios/manage-lists) — refine list organization post-migration * [Manage Campaigns](/scenarios/manage-campaigns) — start creating campaigns for your migrated contacts * [View Reports](/scenarios/view-reports) — track campaign performance # Search Contacts Source: https://developers.benchmarkemail.io/scenarios/search-contacts Find contacts by email, custom field values, or other criteria. Find contacts by email address, custom field values, or other criteria using the search API. ## Goal By the end of this guide you will be able to search for contacts by email (useful for dedup checks), filter by custom field values, and paginate through large result sets. ## Prerequisites * An API key with **`contacts:read`** scope * Your API base URL (found on the Settings > API Keys page) * A **contact structure ID** — search operates within a single contact structure. Retrieve yours with `GET /api/contact-structure` (see [Manage Custom Fields](/scenarios/manage-custom-fields)) **Scope note:** Contact search uses `POST` but is semantically a read operation. It requires only `contacts:read` scope (not `contacts:write`). ## How search requests work Every search request has two main parts: a **filter** that decides which contacts match, and a **source** array that decides which fields to return for each matching contact. ### The `source` array `source` is a required projection that tells the API which fields to return in each contact. Pass an array containing one or more of these case-sensitive values: * `_id`: the contact's unique identifier * `key`: the contact's email address * `fields`: custom field values * `tags`: tag assignments * `lists`: list memberships * `contactStatus`: primary status (`Active`, `Inactive`, etc.) * `contactSubStatus`: secondary status * `createdAt`: creation timestamp * `updatedAt`: last-update timestamp Rules: * `source` is required and must contain at least one value. * Values are case-sensitive. `_id` works; `_ID` returns a validation error. * `_id` is always returned, even when you don't include it in `source`. * Any unknown value rejects the entire request. There's no partial success. ## Steps ### 1. Search by email address This is the most common search pattern, useful for deduplication before creating a contact. ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 10, "source": ["_id", "key", "fields", "createdAt", "updatedAt"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "KEY", "operator": "EQ", "values": ["jane.smith@example.com"] } ] } ] } }' ``` **Response** (`200 OK`): ```json theme={null} { "contacts": [ { "_id": "66f1a6e4698f1bca60424901", "key": "jane.smith@example.com", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" } ], "createdAt": "2026-03-28T14:30:00.000Z", "updatedAt": "2026-03-28T14:30:00.000Z", "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1" } ], "totalRecords": 1 } ``` **Dedup pattern:** Before creating a contact, search by email using `"operator": "EQ"`. If `totalRecords` is 0, the email is not in use and you can safely create the contact. ### 2. Search by email domain Find all contacts from a specific email domain. ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 25, "source": ["_id", "key", "fields"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "EMAIL_DOMAIN", "operator": "EQ", "values": ["example.com"] } ] } ] } }' ``` ### 3. Search by custom field values Filter contacts based on custom field values. Use the field's `_id` from your contact structure. ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 25, "source": ["_id", "key", "fields", "tags"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "FIELD_ID", "id": "64a1b2c3d4e5f6a7b8c9d102", "operator": "EQ", "values": ["Enterprise"] } ] } ] } }' ``` **Response** (`200 OK`): ```json theme={null} { "contacts": [ { "_id": "66f1a6e4698f1bca60424905", "key": "alice@enterprise-corp.com", "fields": [ { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Alice" }, { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Chen" }, { "_id": "64a1b2c3d4e5f6a7b8c9d102", "value": "Enterprise" } ], "tags": [ { "_id": "64a1b2c3d4e5f6a7b8c9d300" } ], "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1" } ], "totalRecords": 1 } ``` ### 4. Combine multiple criteria Use multiple criteria objects to build complex filters. Criteria within the same group are AND-ed; separate groups are OR-ed. ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 25, "source": ["_id", "key", "fields", "createdAt"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "FIELD_ID", "id": "64a1b2c3d4e5f6a7b8c9d102", "operator": "EQ", "values": ["Enterprise"] }, { "columnToFilter": "CREATED_AT", "operator": "GT", "values": ["2026-01-01T00:00:00.000Z"] } ] } ] } }' ``` This searches for contacts where the custom field equals "Enterprise" **AND** the contact was created after January 1, 2026. ### 5. Search within a specific list Filter contacts that belong to a specific list. ```bash theme={null} curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 25, "source": ["_id", "key", "fields"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "LIST_ID", "operator": "IN", "values": ["66f1a6e4698f1bca60425001"] } ] } ] } }' ``` ### 6. Paginate through results For large result sets, increment the `page` parameter to retrieve subsequent pages. Pages are 1-indexed. ```bash theme={null} # Page 1 (first 50 results) curl -X POST "/api/contact/search" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" \ -H "Content-Type: application/json" \ -d '{ "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1", "page": 1, "pageSize": 50, "source": ["_id", "key"], "contactSpecification": { "filters": [ { "criterias": [ { "columnToFilter": "CONTACT_STATUS", "operator": "EQ", "values": ["active"] } ] } ] }, "sortField": [ { "column": "CREATED_AT", "order": "desc" } ] }' ``` **Response** (`200 OK`): ```json theme={null} { "contacts": [ { "_id": "66f1a6e4698f1bca60424950", "key": "newest@example.com" }, { "_id": "66f1a6e4698f1bca60424949", "key": "second@example.com" } ], "totalRecords": 3847 } ``` Use `totalRecords` to calculate the total number of pages: `Math.ceil(totalRecords / pageSize)`. Then loop through pages 1 to N. ### Request body reference | Field | Required | Description | | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `contactStructureId` | Yes | The contact structure to search within | | `page` | Yes | Page number (1-indexed) | | `pageSize` | Yes | Results per page | | `source` | Yes | Field projection. Pass an array of one or more case-sensitive values from: `_id`, `key`, `fields`, `tags`, `lists`, `contactStatus`, `contactSubStatus`, `createdAt`, `updatedAt`. | | `contactSpecification` | Yes | Object with a `filters` array of filter groups (see operators below) | | `sortField` | No | Array of sort criteria | | `showFieldIds` | No | Limit which field IDs appear in `fields` array | ### Available filter operators | Operator | Description | Example use | | ------------- | ---------------------- | ------------------------------- | | `EQ` | Equals | Exact email match | | `NEQ` | Not equals | Exclude a specific value | | `SW` | Starts with | Email prefix search | | `CONTAINS` | Contains substring | Partial name match | | `GT` | Greater than | Created after a date | | `LT` | Less than | Created before a date | | `BETWEEN` | Between two values | Date range | | `NOT_BETWEEN` | Not between two values | Exclude a date range | | `IS_EMPTY` | Field is empty | Find contacts with missing data | | `NOT_EMPTY` | Field is not empty | Find contacts with data present | | `IN` | In a set of values | Match any of several list IDs | | `NIN` | Not in a set | Exclude specific values | ### Available filter columns | Column | Description | | -------------------- | ------------------------------------------------------------------- | | `KEY` | Email address | | `EMAIL_DOMAIN` | Email domain (e.g., `example.com`) | | `FIELD_ID` | Custom field value (requires `id` parameter with the field's `_id`) | | `TAG_ID` | Tag assignment | | `LIST_ID` | List membership | | `CONTACT_STATUS` | Primary status (`active`, `inactive`, etc.) | | `CONTACT_SUB_STATUS` | Secondary status | | `CREATED_AT` | Creation timestamp | | `UPDATED_AT` | Last update timestamp | ## Common errors | Status | Error | Cause | Fix | | ------ | ---------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks `contacts:read` scope | Search requires `contacts:read` scope | | `400` | `RequiredFieldError` | A required body field is missing | Ensure `contactStructureId`, `page`, `pageSize`, `source`, and `contactSpecification` are all in the request body | | `400` | `ModelValidationError` | A field's value fails schema validation | Check the error's `field` and `message`. For `source`, confirm it's a non-empty array of valid, case-sensitive values | | `400` | `ValidationError` | Other validation failure (e.g., bad filter operator) | Read the `message` for specifics | | `429` | `TooManyRequestsError` | Rate limit exceeded | Wait for the `Retry-After` period. See [Rate Limits](/rate-limits) | ## Next steps * [Manage Contacts](/scenarios/manage-contacts) — create, update, or delete the contacts you find * [Manage Lists](/scenarios/manage-lists) — organize contacts into lists * [Manage Custom Fields](/scenarios/manage-custom-fields) — understand your contact structure's field IDs * [Migration from Legacy](/scenarios/migration-from-legacy) — use search for dedup during bulk import # View Reports Source: https://developers.benchmarkemail.io/scenarios/view-reports Retrieve dashboard summaries, email performance metrics, and engagement data. Access dashboard summaries, email performance metrics, and campaign engagement data using the API. ## Goal By the end of this guide you will be able to retrieve your dashboard summary statistics, view overall email performance, get time-series histograms, pull detailed reports for individual campaigns, and access engagement breakdowns. ## Prerequisites * An API key with **`reports:read`** scope * Your API base URL (found on the Settings > API Keys page) * At least one sent campaign (for meaningful report data) ## Steps ### 1. Get dashboard summary Retrieve a high-level snapshot of your email and contact performance over a specified time period. ```bash theme={null} curl "/api/reports/dashboard?pastDays=30&timezone=America/New_York" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Query parameters:** | Parameter | Required | Description | | ---------- | -------- | --------------------------------------------------------------------------------- | | `pastDays` | Yes | Number of days to look back (1-90) | | `timezone` | No | IANA timezone code (default: `UTC`). Example: `America/New_York`, `Europe/London` | **Response** (`200 OK`): ```json theme={null} { "campaign": { "sent": { "count": 2379, "diff": 0.76 }, "delivered": { "count": 2203, "diff": 11.3 }, "uniqueOpens": { "count": { "value": 733, "diff": -23.41 }, "rate": { "value": 33.27, "diff": -22.41 } }, "uniqueClicks": { "count": { "value": 569, "diff": -21.52 }, "rate": { "value": 25.83, "diff": 20.47 } } }, "contact": { "count": { "value": 5900, "diff": 29.55 }, "histogram": { "start": "2026-02-28T00:00:00.000Z", "end": "2026-03-30T00:00:00.000Z", "histogram": [ { "date": "2026-02-28T00:00:00.000Z", "total": 5420 }, { "date": "2026-03-07T00:00:00.000Z", "total": 5580 }, { "date": "2026-03-14T00:00:00.000Z", "total": 5710 }, { "date": "2026-03-21T00:00:00.000Z", "total": 5850 }, { "date": "2026-03-28T00:00:00.000Z", "total": 5900 } ], "peak": { "date": "2026-03-28T00:00:00.000Z", "total": 5900 } } } } ``` **Understanding the `diff` field:** The `diff` value is a percentage change compared to the equivalent preceding time period. For example, with `pastDays=30`, the diff compares the last 30 days to the 30 days before that. A positive value means an increase; negative means a decrease. ### 2. Get email performance overview Retrieve aggregate email performance statistics across all campaigns. ```bash theme={null} curl "/api/reports/email/overall?pastDays=30&timezone=America/New_York" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "sent": 8450, "delivered": 8120, "opens": { "unique": 2890, "rate": 35.59 }, "clicks": { "unique": 1150, "rate": 14.16 }, "bounces": { "total": 330, "hard": 145, "soft": 185, "rate": 3.91 }, "unsubscribes": 42, "complaints": 3 } ``` ### 3. Get email performance histogram Retrieve a time-series breakdown of email performance for chart visualization. ```bash theme={null} curl "/api/reports/email/overall/histogram?pastDays=30&timezone=America/New_York" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "start": "2026-02-28T00:00:00.000Z", "end": "2026-03-30T00:00:00.000Z", "histogram": [ { "date": "2026-03-01T00:00:00.000Z", "sent": 280, "delivered": 268, "opens": 95, "clicks": 38 }, { "date": "2026-03-08T00:00:00.000Z", "sent": 1250, "delivered": 1198, "opens": 425, "clicks": 170 }, { "date": "2026-03-15T00:00:00.000Z", "sent": 3400, "delivered": 3265, "opens": 1160, "clicks": 465 } ] } ``` ### 4. Get campaign-specific report Retrieve detailed performance metrics for a single campaign. You will need the campaign ID (from `GET /api/email/campaign`). ```bash theme={null} curl "/api/reports/email/66f1a6e4698f1bca60426002" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Response** (`200 OK`): ```json theme={null} { "stats": { "sent": 697, "delivered": 655, "opens": { "unique": 210, "last": "2026-03-27T17:33:45.464Z", "rate": 32.06 }, "clicks": { "unique": 169, "last": "2026-03-27T18:28:40.575Z", "rate": 25.8 }, "totalBounces": { "count": 42, "rate": 6.87 }, "hardBounces": { "count": 25, "rate": 3.81 }, "softBounces": { "count": 17, "rate": 2.59 }, "unsubscribes": 0, "complaints": 0 }, "linkActivity": [ { "id": "66f1a6e4698f1bca60424808", "path": "https://mybusiness.com/spring-sale", "clicks": { "total": 145, "unique": 120 } }, { "id": "66f1a6e4698f1bca60424809", "path": "https://instagram.com/mybusiness", "clicks": { "total": 38, "unique": 32 } } ] } ``` **Key metrics explained:** * **sent** — Total emails dispatched * **delivered** — Emails that reached the recipient's mail server * **opens.unique** — Number of unique recipients who opened the email * **opens.rate** — Open rate as a percentage of delivered emails * **clicks.unique** — Number of unique recipients who clicked a link * **clicks.rate** — Click rate as a percentage of delivered emails * **totalBounces** — Hard + soft bounces combined * **hardBounces** — Permanent delivery failures (invalid addresses) * **softBounces** — Temporary delivery failures (full mailbox, server issues) * **linkActivity** — Per-link click breakdown ### 5. Get campaign engagement details Retrieve a time-series engagement histogram for a specific campaign, useful for understanding when recipients interact with your email. ```bash theme={null} curl "/api/reports/email/66f1a6e4698f1bca60426002/engagement?period=week&timezone=America/New_York" \ -H "X-API-Key: bme_us_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" ``` **Query parameters:** | Parameter | Required | Description | | ---------- | -------- | ---------------------------------------------------------- | | `period` | No | Engagement window: `24hours` (default), `week`, or `month` | | `timezone` | No | IANA timezone code (default: `UTC`) | **Response** (`200 OK`): ```json theme={null} { "start": "2026-03-21T00:00:00.000Z", "end": "2026-03-28T00:00:00.000Z", "histogram": [ { "date": "2026-03-21T00:00:00.000Z", "opens": 145, "clicks": 58 }, { "date": "2026-03-22T00:00:00.000Z", "opens": 32, "clicks": 12 }, { "date": "2026-03-23T00:00:00.000Z", "opens": 18, "clicks": 7 }, { "date": "2026-03-24T00:00:00.000Z", "opens": 8, "clicks": 3 }, { "date": "2026-03-25T00:00:00.000Z", "opens": 4, "clicks": 1 }, { "date": "2026-03-26T00:00:00.000Z", "opens": 2, "clicks": 0 }, { "date": "2026-03-27T00:00:00.000Z", "opens": 1, "clicks": 0 } ] } ``` ## Endpoint Summary | Endpoint | Method | Description | | -------------------------------------- | ------ | ----------------------------------------- | | `/api/reports/dashboard` | GET | Dashboard snapshot (campaigns + contacts) | | `/api/reports/email/overall` | GET | Aggregate email performance | | `/api/reports/email/overall/histogram` | GET | Email performance time series | | `/api/reports/email/:id` | GET | Individual campaign report | | `/api/reports/email/:id/engagement` | GET | Campaign engagement time series | All reports endpoints require `reports:read` scope and accept `pastDays` and `timezone` query parameters (except per-campaign endpoints which use `period`). ## Common Errors | Status | Error | Cause | Fix | | ------ | ---------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | | `401` | `UnauthorizedError` | Invalid or inactive API key | Verify your key in Settings > API Keys | | `403` | `ForbiddenError` | Key lacks `reports:read` scope | Create or upgrade your key with `reports:read` | | `400` | `ValidationError` | Invalid `pastDays` value (must be 1-90) or invalid `period` | Check query parameters | | `404` | `RecordNotFound` | Campaign ID does not exist | Verify the campaign ID from `GET /api/email/campaign` | | `429` | `TooManyRequestsError` | Rate limit exceeded | Wait for the `Retry-After` period. See [Rate Limits](/rate-limits) | ## Next Steps * [Manage Campaigns](/scenarios/manage-campaigns) — create and manage the campaigns you are reporting on * [Search Contacts](/scenarios/search-contacts) — find contacts for targeted campaign analysis * [Manage Lists](/scenarios/manage-lists) — manage the lists your campaigns send to