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:

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

Error responses:

{
  "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 EndpointNew EndpointMethodNotes
/rest/contacts/get-contact?id={uuid}/rest/contacts/get_contactGET or POSTPath 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_contactsGETParam 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_tagsGET or POSTSame 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/subscribePOSTThe 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/subscribePOSTSend create_only: true in the body. Same as before.
/rest/contacts/update-contact/rest/contacts/update_contactPOSTThe 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_formPOSTJSON 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_goalPOSTJSON 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_tagsPOSTJSON 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_tagsPOSTJSON 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 EndpointNew EndpointMethodNotes
/rest/tags/get-tag?id={uuid}/rest/tags/get_tagGET or POSTBody 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_tagsGET or POSTQuery 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_tagPOSTJSON 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}POSTTag 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}POSTTag 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 EndpointNew EndpointMethodNotes
/rest/goals/get-goal?id={uuid}/rest/goals/get_goalGET or POSTParams: 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_goalsGET or POSTOptional 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_goalPOSTBody: 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_goalPOSTBody: 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 EndpointNew EndpointMethodNotes
/rest/forms/get-form?id={uuid}/rest/forms/get_formPOSTVerb 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_formsPOSTJSON 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_formPOSTJSON 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}POSTForm 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}POSTForm UUID is now a URL parameter instead of body field id.

Custom Attributes

Legacy EndpointNew EndpointMethodNotes
/rest/attributes/get-attribute?id={uuid}/rest/attributes/get_attributePOSTTrue 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_attributesPOSTJSON 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_groupsGETNo 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_groupPOSTBody: group_name (was name).
/rest/attributes/create-attribute/rest/attributes/create_attributePOSTJSON 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}POSTAttribute 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 EndpointNew EndpointMethodNotes
/rest/broadcasts/add-from-template/rest/broadcasts/add_from_templatePOSTJSON body. Required: template_id (UUID), schedule (YYYY-MM-DD HH:II:SS, UTC; must be at least 5 minutes in the future -- earlier values return 422), audience ({type: "group"|"segment", id: UUID} -- type is a strict enum, other values return 422). Optional: suppression_id (UUID); tags ({open: {add: [uuid], remove: [uuid]}, click: {add: [uuid], remove: [uuid]}, urls: {url: {add: [uuid], remove: [uuid]}}}) -- every tag ID must be a UUID. Success: 201 {success: true, message: "Broadcast created.", data: {broadcast_uuid}}. Errors: 404 {success: false, message: "Message template not found.", data: []}; 422 {success: false, message: "Validation failed", data: {errors: {field: [msg, ...]}}} on validation error.
/rest/broadcasts/get_broadcast_statsREMOVED--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_statsGETQuery-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 EndpointNew EndpointMethodNotes
/rest/gamification/get-ranked-balance?limit=&with_tags=&without_tags=/rest/gamification/get_ranked_balanceGETPath 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_pointsGETSame 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_balanceGETAll 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 EndpointNew EndpointMethodNotes
/rest/test/auth/api/test/authGET or POSTReturns {status: true, message: "OK"}.
/rest/test/key-check/api/test/key_checkGET or POSTReturns {success: true, message: "OK", data: {account_status: ...}}.

Form Submission

Legacy EndpointNew EndpointNotes
/subscribe/form/id/{form_uuid}/api/forms/submitPublic 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.