---
updatedAt: 2026-06-28T20:00:54.000Z
agentTools:
  projectIndex: https://developers.campaignrefinery.com/llms.txt
---

# REST API Migration Guide

# REST API Migration Guide

The legacy REST API on `app.campaignrefinery.com` is being replaced by the new REST API on `api.campaignrefinery.com`. This document maps every legacy endpoint to its new location and describes any changes to authentication, request format, or response structure.

## Authentication Change

**Before**: API key passed as `?key={api_key}` query parameter.

**After**: API key passed as `Authorization: Bearer {api_key}` header. The key itself is unchanged -- only the transport method changes.

```
# Before
GET https://app.campaignrefinery.com/rest/contacts/get-contact?key=cr_xxx&id=...

# After
GET https://api.campaignrefinery.com/rest/contacts/get_contact?contact_uuid=...
Authorization: Bearer cr_xxx
```

## Base URL

Root: `https://api.campaignrefinery.com` (was `https://app.campaignrefinery.com`).

Each endpoint table below shows the full path -- prepend the root and you have the URL.

## Response Format Change

All responses now follow a consistent JSON structure:

```json
{
  "success": true,
  "message": "Description of result",
  "data": { ... }
}
```

Error responses:

```json
{
  "success": false,
  "message": "What went wrong",
  "data": { ... }
}
```

The legacy API returned inconsistent formats -- some endpoints returned raw data, some wrapped in `status`/`message` objects. The new API always uses the structure above.

***

## Contacts

| Legacy Endpoint                                           | New Endpoint                      | Method      | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| --------------------------------------------------------- | --------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/contacts/get-contact?id={uuid}`                    | `/rest/contacts/get_contact`      | GET or POST | Path field rename: legacy `id` -> `contact_uuid`. Optional alternative `contact_email`. Contacts are identified by `contact_uuid` in the response. Custom attributes are now opt-in (legacy: `no_custom_attr=1` to opt out; new: `with_attributes=true` to opt in) and **nested under `custom_attributes: {key: value}`** instead of being flattened onto the contact object. Success: `200 {success, message, data: {contact_uuid, contact_email, contact_first_name, contact_last_name, contact_optout, contact_is_deleted, contact_points_balance, contact_add_dts, contact_update_dts, custom_attributes?}}`. Errors: `401`, `404` not found, `422` (canonical envelope) when neither `contact_uuid` nor `contact_email` is supplied.                                                                                                |
| `/rest/contacts/get-contacts?q=&limit=&offset=`           | `/rest/contacts/get_contacts`     | GET         | Param renames: `q` (kept), `offset` removed in favor of `page` + `per_page` (default 25, max 100). `order_by` -> `sort_field` (whitelist: `email`, `name`, `added`). `sort` -> `sort_order` (default `desc`). Date-range filters: `contact_add_start`/`contact_add_end` (unchanged); legacy `contact_update_start`/`contact_update_end` -> `contact_upd_start`/`contact_upd_end`. NEW `with_attributes=true` to nest `custom_attributes` per row (default off). Response shape changes from the legacy `{_metadata: {page, per_page, page_count, total_count}, contacts: [...]}` shape to `{success, message, data: {current_page, data: [...], total, per_page, last_page, first_page_url, last_page_url, next_page_url, prev_page_url, from, to, path}}`. Each contact row uses the same `contact_uuid`-based fields as `get_contact`. |
| `/rest/contacts/get-contact-tags?id={uuid}`               | `/rest/contacts/get_contact_tags` | GET or POST | Same params: `id` (contact UUID) or `email`. Response: `{success, message, data: {tags: [{tag_uuid, tag_name, tag_added_dts}]}}`. Parent tag details are available via `/rest/tags/get_tag`. Errors: `401`, `404` contact not found, `422` when neither identifier is supplied.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `/rest/contacts/subscribe`                                | `/rest/contacts/subscribe`        | POST        | The authenticated API key selects the account. Body fields: `email` (required), `first_name`, `last_name`, `name` (convenience -- split into first/last if those are omitted), `form_id`, `goal_id`, `tags` (comma-separated tag UUIDs), `sequences` (comma-separated sequence UUIDs), `custom_attributes` (array of `{key, type, value}`), `domain` (agency sub-account routing), `create_only` (boolean -- same as `/rest/contacts/create-contact`).                                                                                                                                                                                                                                                                                                                                                                                   |
| `/rest/contacts/create-contact`                           | `/rest/contacts/subscribe`        | POST        | Send `create_only: true` in the body. Same as before.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `/rest/contacts/update-contact`                           | `/rest/contacts/update_contact`   | POST        | The authenticated API key selects the account. Body fields: `id` (required, contact UUID), `email`, `first_name`, `last_name`, `name` (convenience -- split into first/last if those are omitted), `optout` (`0` or `1`), `domain` (agency sub-account routing).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `/rest/contacts/add-form`                                 | `/rest/contacts/add_form`         | POST        | JSON body. Required: `id` (contact UUID), `form_id` (form UUID) -- both must belong to the authenticated account. Success: `200 {success: true, message: "Added new form", data: {id: <form_uuid>}}`. Errors: `401` unauthorized, `404 {success: false, message: "Contact or form not found.", data: []}` when either UUID does not exist in the account, `422` (canonical envelope) on validation failure.                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `/rest/contacts/add-goal`                                 | `/rest/contacts/add_goal`         | POST        | JSON body. Required: `id` (contact UUID), `goal_id` (goal UUID) -- both must belong to the authenticated account. Success: `200 {success: true, message: "Added new goal", data: {id: <mapping_uuid>}}` -- the returned `id` identifies the goal-completion record, NOT the goal UUID. Errors: `401`, `404 {success: false, message: "Contact or goal not found.", data: []}`, `422` canonical envelope.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `/rest/contacts/add_tag`, `/rest/contacts/add_tags`       | `/rest/contacts/add_tags`         | POST        | JSON body. Required: `id` (contact UUID). Either `tag_id` (single tag UUID) or `tag_ids` (comma-separated UUID string) is required -- omitting both returns 422 with errors on both fields. Each UUID in `tag_ids` is individually validated; invalid entries return 422 with field keys `tag_ids.0`, `tag_ids.1`, etc. Success: `200 {success: true, message: "Tags added", data: {added: ["tag-name", ...], skipped: ["uuid", ...]}}` -- response is asymmetric: `added` returns tag **names** for successful matches, `skipped` returns the **UUIDs** that didn't resolve. Errors: `404 {success: false, message: "Contact not found", data: []}`; `422` validation envelope. The legacy singular path `add_tag` is retained as an undocumented alias.                                                                                |
| `/rest/contacts/delete_tag`, `/rest/contacts/delete_tags` | `/rest/contacts/delete_tags`      | POST        | JSON body. Required: `id` (contact UUID). Either `tag_id` (single tag UUID) or `tag_ids` (comma-separated UUID string) is required -- same validation rules as add\_tags. Success: `200 {success: true, message: "Tags removed", data: {removed: ["tag-name", ...], skipped: ["uuid", ...]}}` -- response is asymmetric: `removed` returns tag **names** of tags actually detached; `skipped` returns **UUIDs** that were not removed (either didn't resolve to a tag for the account, or resolved but were not currently attached to the contact -- both cases mean no detach happened). Errors: `404 {success: false, message: "Contact not found", data: []}`; `422` validation envelope. The legacy singular path `delete_tag` is retained as an undocumented alias.                                                                 |

## Tags

| Legacy Endpoint                         | New Endpoint                       | Method      | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| --------------------------------------- | ---------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/tags/get-tag?id={uuid}`          | `/rest/tags/get_tag`               | GET or POST | Body or query: `tag_uuid` (was `id`) or `tag_name` (was `name`); at least one is required. Response: `{success, message, data: {tag_uuid, tag_name, parent_tag_uuid}}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `/rest/tags/get-tags?q=&limit=&offset=` | `/rest/tags/get_tags`              | GET or POST | Query params: `search` (was `q`, substring LIKE on `tag_name`, max 255), `page` (default 1, min 1), `per_page` (default 25, max 100). Response uses the standard paginated envelope wrapped in `{success, message, data}`. Each item: `{tag_uuid, tag_name, parent_tag_uuid}` -- same shape as `get_tag`. Results ordered alphabetically by `tag_name` ascending.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `/rest/tags/create-tag`                 | `/rest/tags/create_tag`            | POST        | JSON body. Required: `tag_name` (max 255). Optional: `parent_tag_uuid` (UUID; account-scoped, unknown UUIDs return 422). **`tag_name` is normalized server-side**: `~` -> space, runs of `,` -> single `-`, Unicode control + format chars (newline, NUL, BOM, etc.) stripped, surrounding whitespace trimmed -- a caller submitting `foo,bar` will see `foo-bar` in the response. A name that is empty after this normalization is rejected with 422. **All responses use the canonical `{success, message, data}` envelope -- no top-level `errors`, no `status`, no other fields.** Success: `201 {success: true, message: "Tag created.", data: {tag_uuid, tag_name, parent_tag_uuid}}`. Errors: `409 {success: false, message: "A tag with the name X already exists.", data: {tag_uuid, tag_name}}` on duplicate (existing tag returned for caller recovery); `401 {success: false, message: "Unauthorized", data: []}`; `422 {success: false, message: "Validation failed", data: {errors: {field: [msg]}}}` for ALL validation failures including missing/oversized `tag_name`, invalid/unknown `parent_tag_uuid`, and empty post-sanitize `tag_name` (`data.errors.tag_name: ["Tag name cannot be empty."]`). |
| `/rest/tags/update-tag`                 | `/rest/tags/update_tag/{tag_uuid}` | POST        | Tag UUID moves from body field `id` to URL path. JSON body. Required: `tag_name` (was `name`, max 255). Optional: `parent_tag_uuid` (UUID; account-scoped; unknown UUIDs and a UUID equal to the tag being updated both return 422). `tag_name` is normalized server-side with the same rules as `create_tag` (`~` -> space, runs of `,` -> single `-`, Unicode control/format chars stripped, whitespace trimmed); empty-after-sanitize is rejected with 422. All responses use the canonical `{success, message, data}` envelope. Success: `200 {success: true, message: "Tag updated.", data: {tag_uuid, tag_name, parent_tag_uuid}}`. Errors: `401 {success: false, message: "Unauthorized", data: []}`; `404 {success: false, message: "Tag not found.", data: []}` if no tag with that UUID exists in the account; `409 {success: false, message: "A tag with the name X already exists.", data: {tag_uuid, tag_name}}` on duplicate (existing tag returned for caller recovery); `422 {success: false, message: "Validation failed", data: {errors: {field: [msg]}}}` for validation failures, plus `422 {success: false, message: "Cannot set parent tag to itself.", data: []}` for self-parent.              |
| *(new -- no legacy equivalent)*         | `/rest/tags/delete_tag/{tag_uuid}` | POST        | Tag UUID is in the URL path; no request body. Deletion is asynchronous; related contact-tag mappings, automation triggers, email-tag actions, event-tag relations, and audience filters are removed in the background and are not returned to the caller. Success: `200 {success: true, message: "Tag scheduled for deletion.", data: []}`. Errors: `403 {success: false, message: "Unauthorized", data: []}`; `404 {success: false, message: "Tag not found.", data: []}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

## Goals

| Legacy Endpoint                  | New Endpoint              | Method      | Notes                                                                                                                                                                                                                                                                                                                                                                                                              |
| -------------------------------- | ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/rest/goals/get-goal?id={uuid}` | `/rest/goals/get_goal`    | GET or POST | Params: `goal_uuid` or `goal_name`. Each goal serialized as `{id, goal_uuid, goal_name, campaign_uuid, goal_match_type, tag_ids}` -- `id` and `goal_uuid` carry the same UUID; `campaign_uuid` is `null` when the goal is not bound to a campaign; `tag_ids` are tag UUID strings. Success: `200 {success, message, data: {<goal>}}`. Errors: `404` goal not found.                                                |
| `/rest/goals/get-goals`          | `/rest/goals/get_goals`   | GET or POST | Optional params: `campaign_uuid` (filter by campaign), `page` (default 1), `per_page` (default 25, max 100). Success: `200 {success, message, data: {current_page, data: [<goal>, ...], total, per_page, last_page, first_page_url, last_page_url, next_page_url, prev_page_url, from, to, path}}`. Each goal item uses the same shape as `get_goal`.                                                              |
| `/rest/goals/create-goal`        | `/rest/goals/create_goal` | POST        | Body: `goal_name`, optional `campaign_uuid`, optional `goal_match_type`, optional `tag_ids` (array of tag UUID strings). Success: `201 {success, message, data: {<goal>}}` -- same shape as `get_goal`.                                                                                                                                                                                                            |
| `/rest/goals/update-goal`        | `/rest/goals/update_goal` | POST        | Body: **required** `goal_uuid` and `goal_name` (must be resent on every update, even when only changing other fields); optional `goal_match_type`; optional `tag_ids` (array of tag UUID strings). Omitting `tag_ids` leaves existing tag mappings unchanged; sending an empty array clears mappings. Success: `200 {success, message, data: {<goal>}}` -- same shape as `get_goal`. Errors: `404` goal not found. |

## Forms

| Legacy Endpoint                           | New Endpoint                          | Method | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ----------------------------------------- | ------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/forms/get-form?id={uuid}`          | `/rest/forms/get_form`                | POST   | Verb changed from GET to POST. Body: `form_uuid` (was `id`) or `form_name` (was `name`); at least one is required. Response: `{success, message, data: {form: {form_uuid, form_name, form_code_*, form_created_dts, form_field_maps:[...]}}}`. `form_field_maps` is a flat list where each entry merges the form-field row with its custom attribute and that attribute's group.                                                                                                                                                                                                        |
| `/rest/forms/get-forms?q=&limit=&offset=` | `/rest/forms/get_forms`               | POST   | JSON body. Optional: `search` (was `q`, max 255), `form_uuid` (account-scoped), `campaign_uuid` (account-scoped), `page` (default 1), `per_page` (1-100, default 25; replaces legacy `limit`, which allowed larger values), `order_by` (`form_name` or `form_created_dts`, default `form_name`), `sort` (`asc`/`desc`, default `asc`). Each form is serialized as `{form_uuid, form_name, form_created_dts, campaign_uuid}`. Response: `200 {success, message, data: {current_page, data: [...], total, per_page, last_page, ...}}`. 422 with canonical envelope on validation failure. |
| `/rest/forms/create-form`                 | `/rest/forms/create_form`             | POST   | JSON body. Required: `form_name` (was `name`, 2-100 chars, unique within account). Optional: `campaign_uuid` (account-scoped), full set of `form_code_*` config fields (boolean flags + URL/key strings). Duplicate names return 422; create does not silently reuse an existing form. Success: `201 {success: true, message: "Form created.", data: {form_uuid, form_name, form_created_dts, campaign_uuid}}`. Errors: `401` unauthorized, `422` validation failure (canonical envelope including duplicate-name and unknown-campaign cases), `500` generic error on failure.          |
| `/rest/forms/update-form`                 | `/rest/forms/update_form/{form_uuid}` | POST   | Form UUID moves from body field `id` to URL path. JSON body. All fields optional. `form_name` (was `name`, 2-100 chars, unique within account excluding the form being updated), `campaign_uuid` (account-scoped UUID; pass `null` to detach), full set of `form_code_*` config fields. Success: `200 {success: true, message: "Form updated.", data: {form_uuid, form_name, form_created_dts, campaign_uuid}}`. Errors: `401` unauthorized, `404` form not found, `422` validation failure, `500` generic error on failure.                                                            |
| `/rest/forms/delete-form`                 | `/rest/forms/delete_form/{form_uuid}` | POST   | Form UUID is now a URL parameter instead of body field `id`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

## Custom Attributes

| Legacy Endpoint                            | New Endpoint                               | Method | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------------------------ | ------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/attributes/get-attribute?id={uuid}` | `/rest/attributes/get_attribute`           | POST   | True single-attribute lookup. JSON body: `custom_attr_uuid` (was legacy `id`) or `custom_attr_key`; at least one is required. Success: `200 {success, message, data: {custom_attr_uuid, custom_attr_display_order, custom_attr_name, custom_attr_key, custom_attr_type, custom_attr_group_uuid, custom_attr_group_name}}`. Errors: `422 {success: false, message: "Validation failed", data: {errors: {field: [msg]}}}` when neither field is supplied, `custom_attr_uuid` is not a UUID, or `custom_attr_key` exceeds 100 chars; `404 {success: false, message: "Custom attribute not found", data: []}` when no attribute matches in the account.                                                                                                       |
| `/rest/attributes/get-attributes`          | `/rest/attributes/get_attributes`          | POST   | JSON body. Optional filters: `custom_attr_group_uuid` (account-scoped), `custom_attr_key` (slug filter, max 100 chars). Optional `order_by` map of `column => direction` (whitelist: `custom_attr_display_order`, `custom_attr_name`, `custom_attr_key`, `custom_attr_type`, `custom_attr_group_name`); default `custom_attr_display_order ASC, custom_attr_name ASC`. Each row serialized as `{custom_attr_uuid, custom_attr_display_order, custom_attr_name, custom_attr_key, custom_attr_type, custom_attr_group_uuid, custom_attr_group_name}`. Success: `200 {success, message, data: [...]}`; `404` envelope when no rows match; `422` on unknown `custom_attr_group_uuid` or invalid `order_by` direction.                                         |
| `/rest/attributes/get-attribute-groups`    | `/rest/attributes/get_attribute_groups`    | GET    | No body. Each group serialized as `{custom_attr_group_uuid, custom_attr_group_name, custom_attr_group_created_dts}`. Ordered alphabetically by group name. Success: `200 {success, message, data: [...]}`; `404` envelope when no groups exist.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `/rest/attributes/create-group`            | `/rest/attributes/create_group`            | POST   | Body: `group_name` (was `name`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `/rest/attributes/create-attribute`        | `/rest/attributes/create_attribute`        | POST   | JSON body. Required: `group_uuid` (was `group`; UUID, account-scoped), `name` (max 50, unique within `(account, group)`; duplicates return 422), `type` (strict enum: `varchar`, `mediumtext`, `int`, `decimal`, `datetime` -- matches the actual storage column types; cannot be changed after create). Success: `201 {success: true, message: "Custom attribute created.", data: {custom_attr_uuid, custom_attr_display_order, custom_attr_name, custom_attr_key, custom_attr_type, custom_attr_group_uuid, custom_attr_group_name}}`. Errors: `401` unauthorized, `422` validation (canonical envelope including duplicate-name and unknown-group cases), `500` generic error on failure.                                                              |
| `/rest/attributes/update-attribute`        | `/rest/attributes/update_attribute/{uuid}` | POST   | Attribute UUID moves from body field `id` to URL path. JSON body. Required: `name` (max 50, unique within `(account, group)` ignoring the attribute being updated). **Only `name` can be changed -- `type` is fixed at creation time.** The returned `custom_attr_key` can change when the name changes; use the key from the update response for future contact-attribute writes. Success: `200 {success: true, message: "Custom attribute updated.", data: {custom_attr_uuid, custom_attr_display_order, custom_attr_name, custom_attr_key, custom_attr_type, custom_attr_group_uuid, custom_attr_group_name}}`. Errors: `401` unauthorized, `404` attribute not found, `422` validation (duplicate name within group), `500` generic error on failure. |

## Broadcasts

| Legacy Endpoint                        | New Endpoint                                 | Method | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| -------------------------------------- | -------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/broadcasts/add-from-template`   | **REMOVED**                                  | --     | The single-call template-to-broadcast endpoint was removed in the rebuild. Compose a broadcast with the lifecycle endpoints instead: `/rest/broadcasts/create_broadcast`, then `/rest/broadcasts/set_broadcast_audience`, then `/rest/broadcasts/schedule_broadcast`. See the Broadcasts reference for the full request shapes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `/rest/broadcasts/get_broadcast_stats` | **REMOVED**                                  | --     | No replacement endpoint. Use `/rest/broadcasts/get_broadcasts_with_stats` with `broadcast_uuid={uuid}` and `with_stats=true` to fetch stats for a single broadcast. The stats payload is identical (`{sent, delivered, opened, clicked, complained, bounced, unsubscribed}`); it is returned at `data.broadcasts[0].stats` instead of `data.broadcast_stats`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `/rest/broadcasts/get_broadcasts`      | `/rest/broadcasts/get_broadcasts_with_stats` | GET    | Query-string params (all optional): `broadcast_uuid` (max 60 chars), `broadcast_name` (substring LIKE match, max 255 chars), `limit` (1-100, default 10; values >100 return 422), `offset` (default 0), `order_by` (whitelist: `broadcast_id`, `broadcast_name`, `broadcast_created_dts`, `broadcast_scheduled_dts`; default `broadcast_id`; other values return 422), `sort` (`asc`\|`desc`, default `desc`), `with_stats` (boolean, default `false`). The authenticated API key selects the account. Success: `{success, message, data: {broadcasts: [...], meta: {total, limit, offset}}}`. Each broadcast item: `{id, name, status, created_at, scheduled_at, queued_at, started_sending_at, completed_sending_at, num_emails, sent_counter, message_name, subject, domain, audience_name, stats}`. `stats` is `null` unless `with_stats=true`; when present it carries `{sent, delivered, opened, clicked, complained, bounced, unsubscribed}`. |

## Gamification

| Legacy Endpoint                                                         | New Endpoint                            | Method | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ----------------------------------------------------------------------- | --------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/gamification/get-ranked-balance?limit=&with_tags=&without_tags=` | `/rest/gamification/get_ranked_balance` | GET    | Path hyphen -> underscore. Bearer token replaces `?key=`. Limit cap stays at 100, default 25. Tag filtering now accepts UUIDs only: `with_tag_uuids` / `without_tag_uuids` (comma-separated UUIDs or JSON array). Each UUID must belong to the authenticated account; unknown UUIDs return 422. Legacy name-based filters (`with_tags`, `without_tags`) should be migrated to UUID filters. Each result row is identified by `contact_uuid`. Response: `{success, message, data: [{contact_uuid, contact_email, contact_points_balance}, ...]}`.                                                                                                                                                                                                                        |
| `/rest/gamification/get-daily-points?date=`                             | `/rest/gamification/get_daily_points`   | GET    | Same date param; invalid dates now return 422 instead of falling back to the current UTC date. Each row is identified by `contact_uuid`. Response: `{success, message, data: [{contact_uuid, contact_email, cumulative_balance, points_in_day, points_out_day}, ...]}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `/rest/gamification/get-points-balance?linebreak=`                      | `/rest/gamification/get_points_balance` | GET    | **All responses now use the canonical JSON envelope.** 200 returns `{success: true, message: "Points balance report ready.", data: {csv: "<csv string>"}}` -- the CSV is a string field inside `data`, NOT a `text/csv` body anymore. 202 returns `{success: true, message: "Report still processing.", data: []}` (was raw text "processing"). 503 returns `{success: false, message: <err>, data: []}` (was raw text "error: <msg>"). Callers wanting a downloadable CSV file must `JSON.parse(response).data.csv` and write it to disk. CSV columns: `"id","email","points balance"`. The `id` column carries the contact UUID. `linebreak` query param is now strict-validated as enum `1`/`2`/`3`; invalid values return 422 instead of silently defaulting to LF. |

## Test / Connectivity

| Legacy Endpoint        | New Endpoint           | Method      | Notes                                                                                                                                                                                                                                  |
| ---------------------- | ---------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/rest/test/auth`      | `/rest/test/auth`      | GET or POST | Path unchanged (host moves to `api.campaignrefinery.com`). Returns `{status: true, message: "OK", account: "Account Name (account_email)"}`. The `account` field is new -- integrations such as Zapier use it to label the connection. |
| `/rest/test/key-check` | `/rest/test/key_check` | GET or POST | Returns `{success: true, message: "OK", data: {account_status: ...}}`.                                                                                                                                                                 |

## Form Submission

| Legacy Endpoint                  | New Endpoint        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| -------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/subscribe/form/id/{form_uuid}` | `/api/forms/submit` | Public endpoint (no API key required). Same POST body: `email`, `first_name`, `last_name`, `g-recaptcha-response`, plus any custom field key/value pairs. The form UUID is now sent as `form_uuid` in the body instead of the URL path. **Already-deployed embedded forms MUST be updated to POST to `https://api.campaignrefinery.com/api/forms/submit` with `form_uuid` in the body** -- the legacy `/subscribe/form/id/{form_uuid}` URL is not served on the new host. Browser submissions receive a 302 redirect to `form_code_success_url` / `form_code_error_url` (or a styled fallback page at `/forms/thank-you` or `/forms/error` when no URL is configured). API callers can send `Accept: application/json` to receive the legacy JSON envelope (`{success, message, data, redirect_url}`) instead of a redirect. |