Introduction
This documentation aims to provide all the information you need to work with our API.
Idempotency
Any POST endpoint that creates a resource or triggers a side effect
(sending mail, creating a subscription, recording engagement, etc.) accepts
an optional Idempotency-Key request header. Look for the header listed on
an endpoint's page to confirm support.
When you supply a key, the API remembers the response for 24 hours,
scoped to your team and to that specific endpoint. Replays return the
original status and body along with an Idempotent-Replay: true response
header, so it's safe to retry after network failures, timeouts, or worker
restarts.
Rules
- Keys are opaque strings up to 255 characters (UUIDs work well).
- Replaying the same key with a different request body returns
409 Conflictwitherror_code: idempotency_key_conflict. Generate a new key when the payload changes. - Only
2xxand4xxresponses are cached.5xxresponses are not stored, so retries after a server error will hit fresh execution. - Requests without an
Idempotency-Keyheader are not deduplicated.
POST /api/v1/lists/1/subscribers HTTP/1.1
Authorization: Bearer {YOUR_TOKEN}
Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11
Content-Type: application/json
{"email": "subscriber@example.com"}
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer {YOUR_API_KEY}".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
You can retrieve your token by visiting your Team's dashboard and clicking Generate API token.
Brands
APIs for managing the sending brands attached to a team.
A brand is the customer-facing identity SallyJo uses when it sends email, SMS, or push on behalf of your team. It bundles together the display name, website URL, colors, logo/header imagery, social links, and the default From email address & phone number to use.
Every email list, SMS list, and push list can be linked to a brand. When a list is linked to a brand, SallyJo automatically applies that brand's imagery to hosted subscription/preference pages and uses the brand's From identifiers when the list itself does not override them.
Default brand
Each team has at most one default brand (is_default: true). Newly
created lists inherit the default brand. Setting is_default: true on
any brand automatically unsets the flag on every other brand in the
team in the same request — you never need to make a separate call to
"unset" the old default.
Images
Every image field on a brand has two accepted forms:
logo_image/logo_square_image/header_image/footer_image/social_share_image— a public https URL that SallyJo will store as-is. Use this when the image is already hosted somewhere permanent (your CDN, an existing marketing site, etc.).logo_image_path/logo_square_image_path/header_image_path/footer_image_path/social_share_image_path— a path in the team's private file library (fromPOST /v1/files). SallyJo copies the private file into the public library underbrands/and stores the resulting permanent public URL on the brand. Use this when you uploaded the asset via the Files API.
If both are provided for the same slot, *_path wins. Uploading raw
multipart binary data directly to this endpoint is not supported.
Auto-configure from a website
If you already have a marketing site, POST /v1/brands/auto-configure
will inspect the page and return suggested name, short_description,
primary_color, logo_image, social_share_image, and social_links
values you can pass straight into POST /v1/brands to create the
brand. This is useful for AI clients that only know the customer's
website URL.
List brands
requires authentication
Returns every brand belonging to the authenticated team, ordered by
name. The default brand (if any) is included in this list — check
the is_default flag on each row.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/brands" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/brands"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/brands',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 12,
"team_id": 4,
"name": "Acme Newsletter",
"short_description": "Weekly product updates.",
"website_url": "https://acme.example.com",
"primary_color": "#FF5733",
"secondary_color": "#004E89",
"is_default": true,
"logo_image": "https://cdn.example.com/acme/logo.png",
"logo_square_image": null,
"header_image": null,
"footer_image": null,
"social_share_image": null,
"email_address_id": null,
"phone_id": null,
"social_links": {
"twitter": "https://x.com/acme"
},
"created_at": "2026-06-01T10:30:00.000000Z",
"updated_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a brand
requires authentication
Create a new brand for the authenticated team. All fields except
name are optional. Set is_default: true to make this the team's
default brand — any previously-default brand is automatically
unset in the same request.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/brands" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Acme Newsletter\",
\"short_description\": \"Weekly product updates and how-to guides.\",
\"website_url\": \"https:\\/\\/acme.example.com\",
\"primary_color\": \"#FF5733\",
\"secondary_color\": \"#004E89\",
\"is_default\": false,
\"logo_image\": \"https:\\/\\/cdn.example.com\\/acme\\/logo.png\",
\"logo_square_image\": \"https:\\/\\/cdn.example.com\\/acme\\/logo-square.png\",
\"header_image\": \"https:\\/\\/cdn.example.com\\/acme\\/header.png\",
\"footer_image\": \"https:\\/\\/cdn.example.com\\/acme\\/footer.png\",
\"social_share_image\": \"https:\\/\\/cdn.example.com\\/acme\\/og.png\",
\"logo_image_path\": \"brands\\/acme\\/logo.png\",
\"logo_square_image_path\": \"brands\\/acme\\/logo-square.png\",
\"header_image_path\": \"brands\\/acme\\/header.png\",
\"footer_image_path\": \"brands\\/acme\\/footer.png\",
\"social_share_image_path\": \"brands\\/acme\\/og.png\",
\"social_links\": [
\"http:\\/\\/hagenes.com\\/nihil-accusantium-corporis-atque-omnis-quod\"
],
\"tags\": [
\"jvppotgefgnpymqsahq\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/brands"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Acme Newsletter",
"short_description": "Weekly product updates and how-to guides.",
"website_url": "https:\/\/acme.example.com",
"primary_color": "#FF5733",
"secondary_color": "#004E89",
"is_default": false,
"logo_image": "https:\/\/cdn.example.com\/acme\/logo.png",
"logo_square_image": "https:\/\/cdn.example.com\/acme\/logo-square.png",
"header_image": "https:\/\/cdn.example.com\/acme\/header.png",
"footer_image": "https:\/\/cdn.example.com\/acme\/footer.png",
"social_share_image": "https:\/\/cdn.example.com\/acme\/og.png",
"logo_image_path": "brands\/acme\/logo.png",
"logo_square_image_path": "brands\/acme\/logo-square.png",
"header_image_path": "brands\/acme\/header.png",
"footer_image_path": "brands\/acme\/footer.png",
"social_share_image_path": "brands\/acme\/og.png",
"social_links": [
"http:\/\/hagenes.com\/nihil-accusantium-corporis-atque-omnis-quod"
],
"tags": [
"jvppotgefgnpymqsahq"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands'
payload = {
"name": "Acme Newsletter",
"short_description": "Weekly product updates and how-to guides.",
"website_url": "https:\/\/acme.example.com",
"primary_color": "#FF5733",
"secondary_color": "#004E89",
"is_default": false,
"logo_image": "https:\/\/cdn.example.com\/acme\/logo.png",
"logo_square_image": "https:\/\/cdn.example.com\/acme\/logo-square.png",
"header_image": "https:\/\/cdn.example.com\/acme\/header.png",
"footer_image": "https:\/\/cdn.example.com\/acme\/footer.png",
"social_share_image": "https:\/\/cdn.example.com\/acme\/og.png",
"logo_image_path": "brands\/acme\/logo.png",
"logo_square_image_path": "brands\/acme\/logo-square.png",
"header_image_path": "brands\/acme\/header.png",
"footer_image_path": "brands\/acme\/footer.png",
"social_share_image_path": "brands\/acme\/og.png",
"social_links": [
"http:\/\/hagenes.com\/nihil-accusantium-corporis-atque-omnis-quod"
],
"tags": [
"jvppotgefgnpymqsahq"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/brands',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Acme Newsletter',
'short_description' => 'Weekly product updates and how-to guides.',
'website_url' => 'https://acme.example.com',
'primary_color' => '#FF5733',
'secondary_color' => '#004E89',
'is_default' => false,
'logo_image' => 'https://cdn.example.com/acme/logo.png',
'logo_square_image' => 'https://cdn.example.com/acme/logo-square.png',
'header_image' => 'https://cdn.example.com/acme/header.png',
'footer_image' => 'https://cdn.example.com/acme/footer.png',
'social_share_image' => 'https://cdn.example.com/acme/og.png',
'logo_image_path' => 'brands/acme/logo.png',
'logo_square_image_path' => 'brands/acme/logo-square.png',
'header_image_path' => 'brands/acme/header.png',
'footer_image_path' => 'brands/acme/footer.png',
'social_share_image_path' => 'brands/acme/og.png',
'social_links' => [
'http://hagenes.com/nihil-accusantium-corporis-atque-omnis-quod',
],
'tags' => [
'jvppotgefgnpymqsahq',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Acme Newsletter"), "name");
data.Add(new StringContent("Weekly product updates and how-to guides."), "short_description");
data.Add(new StringContent("https://acme.example.com"), "website_url");
data.Add(new StringContent("#FF5733"), "primary_color");
data.Add(new StringContent("#004E89"), "secondary_color");
data.Add(new StringContent(""), "is_default");
data.Add(new StringContent("https://cdn.example.com/acme/logo.png"), "logo_image");
data.Add(new StringContent("https://cdn.example.com/acme/logo-square.png"), "logo_square_image");
data.Add(new StringContent("https://cdn.example.com/acme/header.png"), "header_image");
data.Add(new StringContent("https://cdn.example.com/acme/footer.png"), "footer_image");
data.Add(new StringContent("https://cdn.example.com/acme/og.png"), "social_share_image");
data.Add(new StringContent("brands/acme/logo.png"), "logo_image_path");
data.Add(new StringContent("brands/acme/logo-square.png"), "logo_square_image_path");
data.Add(new StringContent("brands/acme/header.png"), "header_image_path");
data.Add(new StringContent("brands/acme/footer.png"), "footer_image_path");
data.Add(new StringContent("brands/acme/og.png"), "social_share_image_path");
data.Add(new StringContent("http://hagenes.com/nihil-accusantium-corporis-atque-omnis-quod"), "social_links[]");
data.Add(new StringContent("jvppotgefgnpymqsahq"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Created):
{
"id": 12,
"team_id": 4,
"name": "Acme Newsletter",
"short_description": "Weekly product updates.",
"website_url": "https://acme.example.com",
"primary_color": "#FF5733",
"is_default": true,
"logo_image": null,
"social_links": null
}
Example response (422, Validation error):
{
"message": "The name field is required."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Auto-configure a brand from a website
requires authentication
Fetches the given URL, parses metadata (title, description, theme
color, Open Graph image, social profile links, logo hints), and
returns a set of suggested field values you can post to
POST /v1/brands to create the brand. This endpoint does not
create anything — it only inspects and returns suggestions.
Great for AI clients: given only a website URL, discover the likely brand shape in one call.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/brands/auto-configure" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"https:\\/\\/acme.example.com\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/brands/auto-configure"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "https:\/\/acme.example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands/auto-configure'
payload = {
"url": "https:\/\/acme.example.com"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/brands/auto-configure',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'url' => 'https://acme.example.com',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("https://acme.example.com"), "url");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands/auto-configure"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"suggestions": {
"website_url": "https://acme.example.com",
"name": "Acme",
"short_description": "Beautifully simple product updates.",
"primary_color": "#FF5733",
"secondary_color": null,
"logo_image": "https://acme.example.com/logo.png",
"logo_square_image": null,
"header_image": null,
"footer_image": null,
"social_share_image": "https://acme.example.com/og.png",
"social_links": {
"twitter": "https://x.com/acme",
"linkedin": "https://linkedin.com/company/acme"
}
}
}
Example response (422, Fetch failed):
{
"message": "Could not inspect that website: HTTP 404"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a brand
requires authentication
Retrieve details for a single brand by id.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/brands/3" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/brands/3"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands/3'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/brands/3',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands/3"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 12,
"team_id": 4,
"name": "Acme Newsletter",
"is_default": true,
"primary_color": "#FF5733",
"logo_image": "https://cdn.example.com/acme/logo.png"
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (404, Not found):
{
"message": "No query results for model [Brand]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a brand
requires authentication
Update one or more brand fields. Only supplied fields are modified;
omitted fields are left untouched. Pass null on an image field
(e.g. logo_image: null) to clear a previously-set image URL.
Setting is_default: true automatically unsets the flag on every
other brand in the team.
Example request:
curl --request PUT \
"https://www.sallyjo.com/api/v1/brands/17" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Acme Weekly\",
\"short_description\": \"Weekly product updates.\",
\"website_url\": \"https:\\/\\/acme.example.com\",
\"primary_color\": \"#FF5733\",
\"secondary_color\": \"#004E89\",
\"is_default\": true,
\"logo_image\": \"https:\\/\\/cdn.example.com\\/acme\\/logo.png\",
\"logo_square_image\": \"https:\\/\\/cdn.example.com\\/acme\\/logo-square.png\",
\"header_image\": \"https:\\/\\/cdn.example.com\\/acme\\/header.png\",
\"footer_image\": \"https:\\/\\/cdn.example.com\\/acme\\/footer.png\",
\"social_share_image\": \"https:\\/\\/cdn.example.com\\/acme\\/og.png\",
\"logo_image_path\": \"brands\\/acme\\/logo.png\",
\"logo_square_image_path\": \"brands\\/acme\\/logo-square.png\",
\"header_image_path\": \"brands\\/acme\\/header.png\",
\"footer_image_path\": \"brands\\/acme\\/footer.png\",
\"social_share_image_path\": \"brands\\/acme\\/og.png\",
\"social_links\": [
\"http:\\/\\/zulauf.com\\/et-laboriosam-odio-vel\"
],
\"tags\": [
\"djbacdeeivsjyzifogcs\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/brands/17"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Acme Weekly",
"short_description": "Weekly product updates.",
"website_url": "https:\/\/acme.example.com",
"primary_color": "#FF5733",
"secondary_color": "#004E89",
"is_default": true,
"logo_image": "https:\/\/cdn.example.com\/acme\/logo.png",
"logo_square_image": "https:\/\/cdn.example.com\/acme\/logo-square.png",
"header_image": "https:\/\/cdn.example.com\/acme\/header.png",
"footer_image": "https:\/\/cdn.example.com\/acme\/footer.png",
"social_share_image": "https:\/\/cdn.example.com\/acme\/og.png",
"logo_image_path": "brands\/acme\/logo.png",
"logo_square_image_path": "brands\/acme\/logo-square.png",
"header_image_path": "brands\/acme\/header.png",
"footer_image_path": "brands\/acme\/footer.png",
"social_share_image_path": "brands\/acme\/og.png",
"social_links": [
"http:\/\/zulauf.com\/et-laboriosam-odio-vel"
],
"tags": [
"djbacdeeivsjyzifogcs"
]
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands/17'
payload = {
"name": "Acme Weekly",
"short_description": "Weekly product updates.",
"website_url": "https:\/\/acme.example.com",
"primary_color": "#FF5733",
"secondary_color": "#004E89",
"is_default": true,
"logo_image": "https:\/\/cdn.example.com\/acme\/logo.png",
"logo_square_image": "https:\/\/cdn.example.com\/acme\/logo-square.png",
"header_image": "https:\/\/cdn.example.com\/acme\/header.png",
"footer_image": "https:\/\/cdn.example.com\/acme\/footer.png",
"social_share_image": "https:\/\/cdn.example.com\/acme\/og.png",
"logo_image_path": "brands\/acme\/logo.png",
"logo_square_image_path": "brands\/acme\/logo-square.png",
"header_image_path": "brands\/acme\/header.png",
"footer_image_path": "brands\/acme\/footer.png",
"social_share_image_path": "brands\/acme\/og.png",
"social_links": [
"http:\/\/zulauf.com\/et-laboriosam-odio-vel"
],
"tags": [
"djbacdeeivsjyzifogcs"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->put(
'https://www.sallyjo.com/api/v1/brands/17',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Acme Weekly',
'short_description' => 'Weekly product updates.',
'website_url' => 'https://acme.example.com',
'primary_color' => '#FF5733',
'secondary_color' => '#004E89',
'is_default' => true,
'logo_image' => 'https://cdn.example.com/acme/logo.png',
'logo_square_image' => 'https://cdn.example.com/acme/logo-square.png',
'header_image' => 'https://cdn.example.com/acme/header.png',
'footer_image' => 'https://cdn.example.com/acme/footer.png',
'social_share_image' => 'https://cdn.example.com/acme/og.png',
'logo_image_path' => 'brands/acme/logo.png',
'logo_square_image_path' => 'brands/acme/logo-square.png',
'header_image_path' => 'brands/acme/header.png',
'footer_image_path' => 'brands/acme/footer.png',
'social_share_image_path' => 'brands/acme/og.png',
'social_links' => [
'http://zulauf.com/et-laboriosam-odio-vel',
],
'tags' => [
'djbacdeeivsjyzifogcs',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Acme Weekly"), "name");
data.Add(new StringContent("Weekly product updates."), "short_description");
data.Add(new StringContent("https://acme.example.com"), "website_url");
data.Add(new StringContent("#FF5733"), "primary_color");
data.Add(new StringContent("#004E89"), "secondary_color");
data.Add(new StringContent("1"), "is_default");
data.Add(new StringContent("https://cdn.example.com/acme/logo.png"), "logo_image");
data.Add(new StringContent("https://cdn.example.com/acme/logo-square.png"), "logo_square_image");
data.Add(new StringContent("https://cdn.example.com/acme/header.png"), "header_image");
data.Add(new StringContent("https://cdn.example.com/acme/footer.png"), "footer_image");
data.Add(new StringContent("https://cdn.example.com/acme/og.png"), "social_share_image");
data.Add(new StringContent("brands/acme/logo.png"), "logo_image_path");
data.Add(new StringContent("brands/acme/logo-square.png"), "logo_square_image_path");
data.Add(new StringContent("brands/acme/header.png"), "header_image_path");
data.Add(new StringContent("brands/acme/footer.png"), "footer_image_path");
data.Add(new StringContent("brands/acme/og.png"), "social_share_image_path");
data.Add(new StringContent("http://zulauf.com/et-laboriosam-odio-vel"), "social_links[]");
data.Add(new StringContent("djbacdeeivsjyzifogcs"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands/17"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Updated):
{
"id": 12,
"team_id": 4,
"name": "Acme Weekly",
"primary_color": "#004E89",
"is_default": true
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (422, Validation error):
{
"message": "The primary color field must not be greater than 7 characters."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a brand
requires authentication
Permanently delete a brand. Email, SMS, and push lists that
referenced the brand are not deleted — their brand_id is set
to null so they fall back to team defaults.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/brands/16" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/brands/16"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/brands/16'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/brands/16',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/brands/16"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Deleted):
{
"message": "Brand deleted successfully."
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
CMS Pages
Read-only APIs for CMS pages.
A page is a hosted HTML document belonging to a team's Site. Pages
are used for landing pages, thank-you / confirmation pages after form
submissions, and general marketing content. Each page can be wired to
downstream integrations (e.g. Facebook Conversions API page_display
mappings) using its numeric id.
List CMS pages
requires authentication
Returns every CMS page belonging to the authenticated team, ordered alphabetically by name. Each row is scoped through the team's sites.
Filters
site_id— restrict to a single Site id (int) or an array of site ids (site_id[]=1&site_id[]=2).
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/pages?site_id=cumque" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/pages"
);
const params = {
"site_id": "cumque",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/pages'
params = {
'site_id': 'cumque',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/pages',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'site_id' => 'cumque',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/pages?site_id=cumque"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 42,
"site_id": 4,
"site_name": "Main Site",
"name": "Thank You",
"url": "/thank-you",
"full_url": "https://example.com/thank-you",
"created_at": "2026-06-01T10:30:00.000000Z",
"updated_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Contact Fields
APIs for managing contact fields.
Contact fields define the data structure for your contacts. Each team has its own set of fields that determine what information can be stored on contacts.
Field Types
| Type | Description | Example Value |
|---|---|---|
text |
Plain text string | "John Doe" |
email |
Email address (validated) | "john@example.com" |
phone |
Phone number (normalized to E.164) | "+14155552671" |
number |
Numeric value | 42 or 3.14 |
date |
Date value | "2024-01-15" |
datetime |
Date and time | "2024-01-15T10:30:00Z" |
boolean |
True/false value | true or false |
url |
Web URL | "https://example.com" |
pick_list |
Selection from predefined options | "option_a" |
related |
Reference to another contact | Contact ID |
Field Tags (Merge Tags)
Each field has a unique tag identifier (also called a merge tag) that you use when:
- Creating or updating contacts via the API
- Personalizing email templates with contact data
- Searching for contacts by field values
Tags should be lowercase, use underscores for spaces, and be descriptive (e.g., first_name, company_name, membership_level).
Contact Types
Fields are scoped to a contact type:
person- Fields for individual contactsorganization- Fields for company/organization contacts
Integration Fields
Some fields may be created by third-party integrations (like ChamberMaster or Mailchimp).
These fields have an integration_id set and cannot be modified or deleted through the API.
List all contact fields
requires authentication
Get all contact fields for your team. Use the contact_type query parameter to filter
by person or organization fields.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contact-fields?contact_type=person" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields"
);
const params = {
"contact_type": "person",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields'
params = {
'contact_type': 'person',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contact-fields',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'contact_type' => 'person',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields?contact_type=person"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
[
{
"id": 1,
"name": "First Name",
"tag": "first_name",
"type": "text",
"contact_type": "person",
"max_length": 255,
"integration_id": null
},
{
"id": 2,
"name": "Email",
"tag": "email",
"type": "email",
"contact_type": "person",
"max_length": 255,
"integration_id": null
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get available field types
requires authentication
Returns a list of all available contact field types with their display names.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contact-fields/types" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields/types"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields/types'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contact-fields/types',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields/types"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
[
{
"value": "text",
"name": "Text"
},
{
"value": "email",
"name": "Email"
},
{
"value": "phone",
"name": "Phone"
},
{
"value": "number",
"name": "Number"
},
{
"value": "date",
"name": "Date"
},
{
"value": "datetime",
"name": "Datetime"
},
{
"value": "boolean",
"name": "True / False"
},
{
"value": "url",
"name": "URL"
},
{
"value": "pick_list",
"name": "Pick List"
},
{
"value": "related",
"name": "Related Contact/Organization"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a contact field
requires authentication
Create a new contact field for your team.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/contact-fields" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Membership Level\",
\"tag\": \"membership_level\",
\"type\": \"pick_list\",
\"contact_type\": \"person\",
\"max_length\": 100,
\"options\": [
\"gold\",
\"silver\",
\"bronze\"
],
\"allow_multiple\": false
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Membership Level",
"tag": "membership_level",
"type": "pick_list",
"contact_type": "person",
"max_length": 100,
"options": [
"gold",
"silver",
"bronze"
],
"allow_multiple": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields'
payload = {
"name": "Membership Level",
"tag": "membership_level",
"type": "pick_list",
"contact_type": "person",
"max_length": 100,
"options": [
"gold",
"silver",
"bronze"
],
"allow_multiple": false
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/contact-fields',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Membership Level',
'tag' => 'membership_level',
'type' => 'pick_list',
'contact_type' => 'person',
'max_length' => 100,
'options' => [
'gold',
'silver',
'bronze',
],
'allow_multiple' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Membership Level"), "name");
data.Add(new StringContent("membership_level"), "tag");
data.Add(new StringContent("pick_list"), "type");
data.Add(new StringContent("person"), "contact_type");
data.Add(new StringContent("100"), "max_length");
data.Add(new StringContent("gold"), "options[]");
data.Add(new StringContent(""), "allow_multiple");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201):
{
"id": 10,
"name": "Membership Level",
"tag": "membership_level",
"type": "pick_list",
"contact_type": "person",
"max_length": null,
"options": [
"gold",
"silver",
"bronze"
],
"allow_multiple": false,
"integration_id": null
}
Example response (422):
{
"message": "The tag has already been taken."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a contact field
requires authentication
Retrieve details for a specific contact field.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contact-fields/5" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields/5"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields/5'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contact-fields/5',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields/5"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"id": 1,
"name": "First Name",
"tag": "first_name",
"type": "text",
"contact_type": "person",
"max_length": 255,
"options": null,
"allow_multiple": false,
"integration_id": null
}
Example response (403):
{
"message": "This action is unauthorized."
}
Example response (404):
{
"message": "No query results for model [ContactField]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a contact field
requires authentication
Update an existing contact field. Integration fields cannot be updated.
Example request:
curl --request PUT \
"https://www.sallyjo.com/api/v1/contact-fields/11" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Full Name\",
\"tag\": \"full_name\",
\"type\": \"text\",
\"max_length\": 150,
\"options\": [
\"premium\",
\"standard\",
\"basic\"
],
\"allow_multiple\": true
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields/11"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Full Name",
"tag": "full_name",
"type": "text",
"max_length": 150,
"options": [
"premium",
"standard",
"basic"
],
"allow_multiple": true
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields/11'
payload = {
"name": "Full Name",
"tag": "full_name",
"type": "text",
"max_length": 150,
"options": [
"premium",
"standard",
"basic"
],
"allow_multiple": true
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->put(
'https://www.sallyjo.com/api/v1/contact-fields/11',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Full Name',
'tag' => 'full_name',
'type' => 'text',
'max_length' => 150,
'options' => [
'premium',
'standard',
'basic',
],
'allow_multiple' => true,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Full Name"), "name");
data.Add(new StringContent("full_name"), "tag");
data.Add(new StringContent("text"), "type");
data.Add(new StringContent("150"), "max_length");
data.Add(new StringContent("premium"), "options[]");
data.Add(new StringContent("1"), "allow_multiple");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields/11"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"id": 1,
"name": "Full Name",
"tag": "full_name",
"type": "text",
"contact_type": "person",
"max_length": 150,
"options": null,
"allow_multiple": false,
"integration_id": null
}
Example response (403):
{
"message": "This field is managed by an integration and cannot be modified."
}
Example response (422):
{
"message": "The tag has already been taken."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a contact field
requires authentication
Delete a contact field. Integration fields cannot be deleted.
Warning: Deleting a field will remove the field definition, but existing contact data stored in that field will remain in the database (orphaned). Consider exporting contact data before deleting fields.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/contact-fields/17" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contact-fields/17"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contact-fields/17'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/contact-fields/17',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contact-fields/17"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"message": "Contact field deleted successfully."
}
Example response (403):
{
"message": "This field is managed by an integration and cannot be deleted."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Contact management
APIs for managing contacts.
Understanding Contact Fields
Contacts in SallyJo use a flexible, team-specific field system. Each team can define their own custom fields (like "first_name", "company", "membership_level", etc.) to store contact data.
How Contact Fields Work
- Dynamic Fields: Each team has its own set of contact fields. Use the Contact Fields API to list your team's available fields.
- Field Tags: When creating or updating contacts, use the field's
tag(merge tag identifier) as the key. For example, if you have a field with tagfirst_name, pass{"first_name": "John"}. - Field Types: Fields can be
text,email,phone,number,date,datetime,boolean,url,pick_list, orrelated. - Contact Types: Fields are scoped to either "person" or "organization" contact types. A "person" contact uses person fields, while an "organization" uses organization fields.
Example Workflow
- First, fetch your team's contact fields using
GET /api/v1/contact-fields - Use the field
tagvalues as keys when creating/updating contacts - The field
typetells you what data format to use (e.g., email fields expect valid email addresses)
Note: The bodyParam examples below use default team fields (first_name, last_name, email, phone, address).
Your team may have different or additional custom fields. Always check GET /api/v1/contact-fields for your actual fields.
Get contact list
requires authentication
Returns a paginated list of contacts belonging to the authenticated
team. Response shape matches GET /api/v1/contacts/search — the
data[] array is transformed via Contact::jsonSerialize() so each
row exposes both raw attributes (keyed by field id) and flattened
field-tag keys (e.g. first_name, email) alongside the base model
columns.
For filtered / searchable results use GET /api/v1/contacts/search.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contacts?page=1&per_page=15" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"page\": 17,
\"per_page\": 6
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/contacts"
);
const params = {
"page": "1",
"per_page": "15",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"page": 17,
"per_page": 6
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts'
payload = {
"page": 17,
"per_page": 6
}
params = {
'page': '1',
'per_page': '15',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, json=payload, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contacts',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'page' => '1',
'per_page' => '15',
],
'json' => [
'page' => 17,
'per_page' => 6,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts?page=1&per_page=15"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"current_page": 1,
"data": [
{
"id": 1,
"team_id": 1,
"type": "person",
"attributes": {
"1": "John",
"2": "Doe",
"3": "john@example.com"
},
"uuid_id": "01H8ZM6E4Q1A8X7ZG9K2P5S3RT",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"tags": [
{
"slug": "customer",
"name": "Customer"
}
],
"created_at": "2024-06-01T10:30:00.000000Z",
"updated_at": "2026-01-15T14:45:00.000000Z"
}
],
"per_page": 15,
"total": 42,
"last_page": 3,
"first_page_url": "https://app.sallyjo.com/api/v1/contacts?page=1",
"last_page_url": "https://app.sallyjo.com/api/v1/contacts?page=3",
"next_page_url": "https://app.sallyjo.com/api/v1/contacts?page=2",
"prev_page_url": null,
"path": "https://app.sallyjo.com/api/v1/contacts",
"from": 1,
"to": 15
}
Example response (401, Missing or invalid token):
{
"message": "Unauthenticated."
}
Example response (422, Invalid pagination parameters):
{
"message": "The per page field must not be greater than 100.",
"errors": {
"per_page": [
"The per page field must not be greater than 100."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Search for a contact
requires authentication
Filters the team's contacts by any combination of built-in fields
(order_by, page, per_page, tags, searchTerm) and
arbitrary contact-field tags — any key that matches a tag from
GET /api/v1/contact-fields is treated as an exact-value filter on
that field's JSON attribute. Unknown keys return 422.
Special filters
searchTerm— space-separated tokens, each substring-matched against the JSON blob of contact attributes (case-insensitive).tags— array of tag slugs; contacts must have ALL supplied tags.- Email/phone field values are normalized before comparison (E.164 for phones, RFC-clean for emails).
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contacts/search?order_by=last_name&page=1&per_page=15&searchTerm=john+doe&tags[]=customer&tags[]=vip&first_name=John" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contacts/search"
);
const params = {
"order_by": "last_name",
"page": "1",
"per_page": "15",
"searchTerm": "john doe",
"tags[0]": "customer",
"tags[1]": "vip",
"first_name": "John",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts/search'
params = {
'order_by': 'last_name',
'page': '1',
'per_page': '15',
'searchTerm': 'john doe',
'tags[0]': 'customer',
'tags[1]': 'vip',
'first_name': 'John',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contacts/search',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'order_by' => 'last_name',
'page' => '1',
'per_page' => '15',
'searchTerm' => 'john doe',
'tags[0]' => 'customer',
'tags[1]' => 'vip',
'first_name' => 'John',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts/search?order_by=last_name&page=1&per_page=15&searchTerm=john+doe&tags[]=customer&tags[]=vip&first_name=John"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"current_page": 1,
"data": [
{
"id": 1,
"team_id": 1,
"type": "person",
"attributes": {
"1": "John",
"2": "Doe",
"3": "john@example.com"
},
"uuid_id": "01H8ZM6E4Q1A8X7ZG9K2P5S3RT"
}
],
"per_page": 15,
"total": 1,
"last_page": 1
}
Example response (422, Unknown field tag supplied):
{
"message": "The unknown_field filter is not supported for this team.",
"errors": {
"unknown_field": [
"The unknown_field filter is not supported for this team."
]
}
}
Example response (422, Invalid email in email-typed field):
{
"message": "The email must be a valid email address.",
"errors": {
"email": [
"The email must be a valid email address."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a new contact
requires authentication
This endpoint allows you to create a new contact. Contact fields are team-specific and use the field's "tag" (merge tag identifier) as the key.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/contacts/create" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"type\": \"person\",
\"first_name\": \"John\",
\"last_name\": \"Doe\",
\"email\": \"john@example.com\",
\"phone\": \"+14155552671\",
\"birthday\": \"1990-05-15\",
\"address\": \"123 Main Street\",
\"city\": \"San Francisco\",
\"state\": \"CA\",
\"zipcode\": \"94102\",
\"tags\": [
\"customer\",
\"vip\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/contacts/create"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"type": "person",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+14155552671",
"birthday": "1990-05-15",
"address": "123 Main Street",
"city": "San Francisco",
"state": "CA",
"zipcode": "94102",
"tags": [
"customer",
"vip"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts/create'
payload = {
"type": "person",
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+14155552671",
"birthday": "1990-05-15",
"address": "123 Main Street",
"city": "San Francisco",
"state": "CA",
"zipcode": "94102",
"tags": [
"customer",
"vip"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/contacts/create',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'type' => 'person',
'first_name' => 'John',
'last_name' => 'Doe',
'email' => 'john@example.com',
'phone' => '+14155552671',
'birthday' => '1990-05-15',
'address' => '123 Main Street',
'city' => 'San Francisco',
'state' => 'CA',
'zipcode' => '94102',
'tags' => [
'customer',
'vip',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("person"), "type");
data.Add(new StringContent("John"), "first_name");
data.Add(new StringContent("Doe"), "last_name");
data.Add(new StringContent("john@example.com"), "email");
data.Add(new StringContent("+14155552671"), "phone");
data.Add(new StringContent("1990-05-15"), "birthday");
data.Add(new StringContent("123 Main Street"), "address");
data.Add(new StringContent("San Francisco"), "city");
data.Add(new StringContent("CA"), "state");
data.Add(new StringContent("94102"), "zipcode");
data.Add(new StringContent("customer"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts/create"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"id": 1,
"team_id": 1,
"type": "person",
"attributes": {
"1": "John",
"2": "Doe",
"3": "john@example.com"
},
"tags": [
{
"id": 1,
"slug": "customer"
}
]
}
Example response (403):
{
"message": "This action is unauthorized."
}
Example response (422):
{
"message": "Tags not found: [found:1][passed:2][missing:invalid-tag]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a contact by id
requires authentication
Returns a single contact by ID. The contact must belong to the authenticated team or the request is rejected with 403.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/contacts/16" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contacts/16"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts/16'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/contacts/16',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts/16"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 1,
"team_id": 1,
"type": "person",
"attributes": {
"1": "John",
"2": "Doe",
"3": "john@example.com"
},
"uuid_id": "01H8ZM6E4Q1A8X7ZG9K2P5S3RT",
"created_at": "2024-06-01T10:30:00.000000Z",
"updated_at": "2026-01-15T14:45:00.000000Z"
}
Example response (403, Contact belongs to another team):
{
"message": "This action is unauthorized."
}
Example response (404, Contact not found):
{
"message": "No query results for model [Contact]."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update an existing contact
requires authentication
This endpoint allows you to update an existing contact. Contact fields are team-specific and use the field's "tag" (merge tag identifier) as the key.
Example request:
curl --request PUT \
"https://www.sallyjo.com/api/v1/contacts/6/update" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"first_name\": \"Jane\",
\"last_name\": \"Smith\",
\"email\": \"jane@example.com\",
\"phone\": \"+14155552672\",
\"birthday\": \"1985-08-22\",
\"address\": \"456 Oak Avenue\",
\"city\": \"Los Angeles\",
\"state\": \"CA\",
\"zipcode\": \"90210\",
\"tags\": [
\"customer\",
\"newsletter\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/contacts/6/update"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"first_name": "Jane",
"last_name": "Smith",
"email": "jane@example.com",
"phone": "+14155552672",
"birthday": "1985-08-22",
"address": "456 Oak Avenue",
"city": "Los Angeles",
"state": "CA",
"zipcode": "90210",
"tags": [
"customer",
"newsletter"
]
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts/6/update'
payload = {
"first_name": "Jane",
"last_name": "Smith",
"email": "jane@example.com",
"phone": "+14155552672",
"birthday": "1985-08-22",
"address": "456 Oak Avenue",
"city": "Los Angeles",
"state": "CA",
"zipcode": "90210",
"tags": [
"customer",
"newsletter"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->put(
'https://www.sallyjo.com/api/v1/contacts/6/update',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'first_name' => 'Jane',
'last_name' => 'Smith',
'email' => 'jane@example.com',
'phone' => '+14155552672',
'birthday' => '1985-08-22',
'address' => '456 Oak Avenue',
'city' => 'Los Angeles',
'state' => 'CA',
'zipcode' => '90210',
'tags' => [
'customer',
'newsletter',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Jane"), "first_name");
data.Add(new StringContent("Smith"), "last_name");
data.Add(new StringContent("jane@example.com"), "email");
data.Add(new StringContent("+14155552672"), "phone");
data.Add(new StringContent("1985-08-22"), "birthday");
data.Add(new StringContent("456 Oak Avenue"), "address");
data.Add(new StringContent("Los Angeles"), "city");
data.Add(new StringContent("CA"), "state");
data.Add(new StringContent("90210"), "zipcode");
data.Add(new StringContent("customer"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts/6/update"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"id": 1,
"team_id": 1,
"type": "person",
"attributes": {
"1": "Jane",
"2": "Smith",
"3": "jane@example.com"
},
"tags": [
{
"id": 1,
"slug": "customer"
}
]
}
Example response (403):
{
"message": "This action is unauthorized."
}
Example response (422):
{
"message": "Tags not found: [found:1][passed:2][missing:invalid-tag]"
}
Example response (422):
{
"message": "Field with tag 'unknown_field' not found"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a contact
requires authentication
Permanently deletes a contact and all associated CRM data (notes,
tasks, tag assignments). Subscription rows are cascaded via the
database. Returns true on success. The contact must belong to the
authenticated team or the request is rejected with 403.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/contacts/16" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/contacts/16"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/contacts/16'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/contacts/16',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/contacts/16"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
true
Example response (403, Contact belongs to another team):
{
"message": "This action is unauthorized."
}
Example response (404, Contact not found):
{
"message": "No query results for model [Contact]."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Domain Short-Link Hosts
APIs for managing tracking / short-link hostnames on a team domain.
A short-link host is a CloudFront-fronted subdomain (e.g.
l.example.com) that SallyJo uses to rewrite links inside outgoing
email so opens and clicks can be tracked. Every email list that has
link tracking wired up points at exactly one short-link host via
tracking_team_domain_site_id.
Lifecycle
POST /v1/domains/{domain}/short-links— attach a hostname. Ifhostnameis omitted, defaults tol.<domain>. The endpoint immediately provisions an ACM certificate and a CloudFront distribution (both take several minutes to reach a ready state) and returns the required DNS records — a certificate-validation TXT and a CNAME from the hostname to the CloudFront distribution.- Publish the DNS records at your DNS provider.
POST /v1/domains/{domain}/short-links/{site}/refresh— safe to call repeatedly. Re-checks the ACM cert and attaches the CNAME alias to CloudFront once the cert is issued. Once CloudFront and the CNAME are both healthy,verified_atis set and the site becomes usable for tracking.PUT /v1/lists/{list}withtracking_team_domain_site_id— wire the short-link host up to any email list on the same team.
Deletion (DELETE) tears down the CloudFront distribution and
removes the certificate.
List every short-link host on the team
requires authentication
Returns every short-link host across every domain owned by the
authenticated team, ordered by hostname. Each row includes the
parent team_domain_id so the caller can group by domain
without a second call. Use GET /v1/domains/{domain}/short-links
when you only want the hosts for one domain.
Filters
team_domain_id— restrict to a single parent domain id.active— whentrue, only return hosts whose CloudFront distribution + CNAME are both healthy (is_active === true).
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/short-link-sites?team_domain_id=9&active=" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/short-link-sites"
);
const params = {
"team_domain_id": "9",
"active": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/short-link-sites'
params = {
'team_domain_id': '9',
'active': '0',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/short-link-sites',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'team_domain_id' => '9',
'active' => '0',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/short-link-sites?team_domain_id=9&active="),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 18,
"team_domain_id": 10,
"hostname": "l.example.com",
"purpose": "short_links",
"provider": "cloudfront",
"verified_at": "2026-06-01T10:30:00.000000Z",
"disabled_at": null,
"is_active": true,
"status": "active",
"created_at": "2026-06-01T10:30:00.000000Z",
"updated_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List short-link hosts on a domain
requires authentication
Returns every short-link host attached to the given team domain, ordered by hostname.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/domains/id/short-links" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/id/short-links"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/id/short-links'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/domains/id/short-links',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/id/short-links"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 18,
"team_domain_id": 10,
"hostname": "l.example.com",
"purpose": "short_links",
"provider": "cloudfront",
"verified_at": "2026-06-01T10:30:00.000000Z",
"disabled_at": null,
"is_active": true,
"status": "active",
"created_at": "2026-06-01T10:30:00.000000Z",
"updated_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a short-link host
requires authentication
Attach a tracking hostname to the given team domain. When
hostname is omitted, defaults to l.<domain>. Kicks off
ACM certificate + CloudFront distribution provisioning in AWS,
and returns the DNS records the caller must publish.
The response is returned immediately; the CloudFront distribution
typically needs several minutes to finish rolling out. Poll
POST /v1/domains/{domain}/short-links/{site}/refresh until
is_active is true.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains/accusamus/short-links" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"hostname\": \"l.example.com\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/domains/accusamus/short-links"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"hostname": "l.example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/accusamus/short-links'
payload = {
"hostname": "l.example.com"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains/accusamus/short-links',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'hostname' => 'l.example.com',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("l.example.com"), "hostname");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/accusamus/short-links"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Created):
{
"id": 18,
"team_domain_id": 10,
"hostname": "l.example.com",
"purpose": "short_links",
"provider": "cloudfront",
"verified_at": null,
"is_active": false,
"status": "pending_certificate",
"dns_records": [
{
"type": "https_cert_validation",
"name": "_abc123.l.example.com",
"value": "_xyz789.acm-validations.aws",
"expected_value": "_xyz789.acm-validations.aws",
"status": "missing",
"required": true,
"description": "Proves domain ownership so we can issue the HTTPS certificate for l.example.com."
},
{
"type": "host_alias",
"name": "l.example.com",
"value": "d123abc.cloudfront.net",
"expected_value": "d123abc.cloudfront.net",
"status": "missing",
"required": true,
"description": "Points l.example.com at Sally Jo so browser traffic is served by our edge."
}
]
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (422, Duplicate hostname):
{
"message": "That hostname is already configured on this domain.",
"errors": {
"hostname": [
"That hostname is already configured on this domain."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a short-link host
requires authentication
Returns the current state of the short-link host including its required DNS records with each record's current status.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/domains/a/short-links/esse" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/a/short-links/esse"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/a/short-links/esse'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/domains/a/short-links/esse',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/a/short-links/esse"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 18,
"team_domain_id": 10,
"hostname": "l.example.com",
"purpose": "short_links",
"provider": "cloudfront",
"verified_at": "2026-06-01T10:30:00.000000Z",
"is_active": true,
"status": "active",
"dns_records": [
{
"type": "host_alias",
"name": "l.example.com",
"value": "d123abc.cloudfront.net",
"expected_value": "d123abc.cloudfront.net",
"status": "pass",
"required": true,
"description": "Points l.example.com at Sally Jo so browser traffic is served by our edge."
}
]
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Re-check a short-link host
requires authentication
Re-runs the CloudFront + ACM provisioning check. Once the ACM
cert reaches ISSUED, this call attaches the alias to the
CloudFront distribution. Once the CNAME resolves to CloudFront
and the distribution is deployed, verified_at is set and the
site becomes usable for tracking. Idempotent — safe to call
repeatedly while waiting for AWS.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains/ut/short-links/placeat/refresh" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/ut/short-links/placeat/refresh"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/ut/short-links/placeat/refresh'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains/ut/short-links/placeat/refresh',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/ut/short-links/placeat/refresh"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 18,
"team_domain_id": 10,
"hostname": "l.example.com",
"is_active": true,
"status": "active",
"dns_records": [...]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a short-link host
requires authentication
Deprovisions the CloudFront distribution and deletes the
short-link host row. Any lists still pointing at this site will
lose tracking; set tracking_team_domain_site_id to null on
those lists before or after deletion.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/domains/hic/short-links/tempore" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/hic/short-links/tempore"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/hic/short-links/tempore'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/domains/hic/short-links/tempore',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/hic/short-links/tempore"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Deleted):
{
"message": "Short-link host deleted successfully."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Domains
APIs for managing the sending domains attached to a team.
A team domain is an apex hostname (e.g. acme.example.com) that
the team has attached to SallyJo for sending email. Attaching a
domain is required before that team can send email through SES using
its own DKIM signing identity.
Lifecycle
POST /v1/domains— attach the apex hostname. This creates theTeamDomainrow but does not by itself register the identity with Amazon SES. Passemail_enabled: trueto also initiate SES verification (DKIM signing tokens are then returned in the DNS records payload).POST /v1/domains/{domain}/enable-email— turn on email for the domain later. Initiates SES verification if the domain is not yet verified.GET /v1/domains/{domain}— poll the domain. Thedns_recordspayload includes every required + recommended DNS record with its current status (Success,Pending,Failed, etc). Publish these records at your DNS provider.POST /v1/domains/{domain}/verify— after DNS records are published, call this to have SallyJo re-check both its own ownership TXT record and the SES verification status. Returns the updated verification state.POST /v1/domains/{domain}/health-check— runs a full DNS health check (SPF, DKIM, DMARC, MX, BIMI, MTA-STS…) and returns the resulting score and per-record diagnostics. Results are cached server-side for one hour unlessforce: trueis passed.
Deleting a domain
DELETE /v1/domains/{domain} removes the domain from SallyJo. Any
verified email identities on that domain will stop being usable for
sending; suppression lists and previously-sent messages are preserved.
List domains
requires authentication
Returns every domain attached to the authenticated team, ordered
alphabetically. The response contains lifecycle flags but not the
full DNS records payload — call GET /v1/domains/{domain} for
that.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/domains" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/domains',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 3,
"team_id": 4,
"domain": "acme.example.com",
"email_enabled": true,
"dns_mode": "external",
"verified": true,
"verified_at": "2026-05-01T12:00:00.000000Z",
"health_score": 92,
"has_critical_issues": false,
"health_checked_at": "2026-06-15T09:00:00.000000Z",
"disabled_at": null,
"created_at": "2026-04-30T18:22:00.000000Z",
"updated_at": "2026-06-15T09:00:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Attach a domain
requires authentication
Attaches an apex hostname to the authenticated team. Uniqueness is enforced per-team, so the same hostname can (in theory) be attached by different teams.
Pass email_enabled: true to immediately initiate SES verification
— the response then includes DKIM CNAME records you should publish
at your DNS provider. Otherwise the domain is registered as
"external / email disabled" and you can turn email on later with
POST /v1/domains/{domain}/enable-email.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"domain\": \"acme.example.com\",
\"email_enabled\": false,
\"mail_from_subdomain\": \"sj\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/domains"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"domain": "acme.example.com",
"email_enabled": false,
"mail_from_subdomain": "sj"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains'
payload = {
"domain": "acme.example.com",
"email_enabled": false,
"mail_from_subdomain": "sj"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'domain' => 'acme.example.com',
'email_enabled' => false,
'mail_from_subdomain' => 'sj',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("acme.example.com"), "domain");
data.Add(new StringContent(""), "email_enabled");
data.Add(new StringContent("sj"), "mail_from_subdomain");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Created, email disabled):
{
"id": 3,
"team_id": 4,
"domain": "acme.example.com",
"email_enabled": false,
"dns_mode": "external",
"verified": false,
"verified_at": null,
"dns_records": [
{
"type": "TXT",
"name": "_sallyjo-verify.acme.example.com",
"value": "sallyjo-verify=…",
"purpose": "Domain Ownership Verification",
"status": "Pending",
"required": true
}
]
}
Example response (422, Duplicate):
{
"message": "That domain is already attached to this team."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a domain
requires authentication
Retrieve a single domain along with the full set of DNS records
the team must publish (or has already published) for it. The
dns_records array covers ownership verification, SES DKIM, and
any per-site records; each entry includes its live DNS lookup
status so you can render a "green check" UI without a second call.
When a custom MAIL FROM subdomain has been configured on the
domain, the response also includes mail_from_domain and
mail_from_domain_status (fetched live from SES). Both are
null when no custom MAIL FROM has been set or when the live
SES call fails.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/domains/at" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/at"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/at'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/domains/at',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/at"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 3,
"team_id": 4,
"domain": "acme.example.com",
"email_enabled": true,
"verified": true,
"verified_at": "2026-05-01T12:00:00.000000Z",
"health_score": 92,
"has_critical_issues": false,
"mail_from_domain": "sj.acme.example.com",
"mail_from_domain_status": "Success",
"dns_records": [
{
"type": "TXT",
"name": "_sallyjo-verify.acme.example.com",
"value": "sallyjo-verify=…",
"purpose": "Domain Ownership Verification",
"status": "Success",
"required": true
},
{
"type": "CNAME",
"name": "abc123._domainkey.acme.example.com",
"value": "abc123.dkim.amazonses.com",
"purpose": "SES DKIM",
"status": "Success",
"required": true
}
]
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (404, Not found):
{
"message": "No query results for model [TeamDomain]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Enable email for a domain
requires authentication
Turns email on for a domain that was previously attached with
email disabled. Initiates SES identity + DKIM verification if the
domain is not yet verified. Optionally configures a custom SMTP
MAIL FROM subdomain (envelope sender) when mail_from_subdomain
is included in the body — e.g. "sj" produces MAIL FROM
sj.example.com. Omit the field to leave the MAIL FROM
configuration untouched. Idempotent — safe to call repeatedly.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains/totam/enable-email" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"mail_from_subdomain\": \"sj\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/domains/totam/enable-email"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"mail_from_subdomain": "sj"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/totam/enable-email'
payload = {
"mail_from_subdomain": "sj"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains/totam/enable-email',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'mail_from_subdomain' => 'sj',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("sj"), "mail_from_subdomain");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/totam/enable-email"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 3, "domain": "acme.example.com",
"email_enabled": true, "verified": false,
"dns_records": [...]
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update the MAIL FROM (envelope sender) subdomain
requires authentication
Sets — or removes — the custom SMTP MAIL FROM subdomain used as
the envelope sender for messages from this domain. Pass e.g.
{"mail_from_subdomain": "sj"} to configure sj.example.com, or
{"mail_from_subdomain": null} to remove the custom MAIL FROM and
fall back to the SES default *.amazonses.com.
Publishing the returned MX + SPF records for the subdomain is required before SES will treat the MAIL FROM domain as verified.
Example request:
curl --request PATCH \
"https://www.sallyjo.com/api/v1/domains/qui/mail-from" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"mail_from_subdomain\": \"sj\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/domains/qui/mail-from"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"mail_from_subdomain": "sj"
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/qui/mail-from'
payload = {
"mail_from_subdomain": "sj"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PATCH', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->patch(
'https://www.sallyjo.com/api/v1/domains/qui/mail-from',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'mail_from_subdomain' => 'sj',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("sj"), "mail_from_subdomain");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/qui/mail-from"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Configured):
{
"id": 3, "domain": "acme.example.com",
"mail_from_domain": "sj.acme.example.com",
"mail_from_domain_status": "Pending",
"dns_records": [...]
}
Example response (200, Removed):
{
"id": 3, "domain": "acme.example.com",
"mail_from_domain": null,
"mail_from_domain_status": null,
"dns_records": [...]
}
Example response (422, Update failed):
{
"message": "Failed to update MAIL FROM domain at SES."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Re-check domain verification
requires authentication
Polls the SallyJo ownership TXT record and SES verification status
for the domain. If both succeed the domain is marked verified: true and verified_at is set. Returns the updated domain state.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains/eaque/verify" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/eaque/verify"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/eaque/verify'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains/eaque/verify',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/eaque/verify"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Now verified):
{
"id": 3,
"domain": "acme.example.com",
"verified": true,
"verified_at": "2026-06-15T09:00:00.000000Z",
"email_enabled": true
}
Example response (200, Still pending):
{
"id": 3,
"domain": "acme.example.com",
"verified": false,
"verified_at": null
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Run a DNS health check
requires authentication
Runs a fresh DNS health check for the domain (SPF, DKIM, DMARC,
MX, BIMI, MTA-STS, feedback loops). Results are cached for one
hour; pass force: true to bypass the cache.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/domains/deleniti/health-check" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"force\": false
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/domains/deleniti/health-check"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"force": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/deleniti/health-check'
payload = {
"force": false
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/domains/deleniti/health-check',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'force' => false,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent(""), "force");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/deleniti/health-check"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 3,
"domain": "acme.example.com",
"health_score": 92,
"has_critical_issues": false,
"health_checked_at": "2026-06-15T09:00:00.000000Z",
"health_check_results": {
"score": 92,
"records": [
{
"type": "TXT",
"name": "acme.example.com",
"purpose": "SPF",
"status": "Success"
},
{
"type": "CNAME",
"name": "abc123._domainkey.acme.example.com",
"purpose": "SES DKIM",
"status": "Success"
}
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Detach a domain
requires authentication
Removes the domain from SallyJo. Verified email identities on the domain will stop being usable for sending; suppression lists and previously-sent messages are preserved.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/domains/voluptatum" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/domains/voluptatum"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/domains/voluptatum'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/domains/voluptatum',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/domains/voluptatum"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Deleted):
{
"message": "Domain deleted successfully."
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Email Campaigns
APIs for reading the email campaigns attached to a team.
A campaign is a single send (or an automated sequence of sends). There are three types:
regular— a one-off blast to the whole list.ab_test— an A/B test blast.automated— a triggered sequence (e.g. the welcome series that is automatically created for a list when the list is created).
Every campaign is scoped to a team_id and (usually) an
email_list_id. The messages relationship is the list of
CampaignMessage rows that make up the sequence — for a regular
campaign there is one row, for an automated series there is one row
per stage of the sequence.
These endpoints are currently read-only. Use the SallyJo UI or an existing action (e.g. creating a list creates the welcome series) to create campaigns.
List email campaigns
requires authentication
Returns every email campaign belonging to the authenticated team, ordered by newest first. Optional filters narrow the result.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/email-campaigns?email_list_id=71&type=automated&status=sending" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/email-campaigns"
);
const params = {
"email_list_id": "71",
"type": "automated",
"status": "sending",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email-campaigns'
params = {
'email_list_id': '71',
'type': 'automated',
'status': 'sending',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/email-campaigns',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'email_list_id' => '71',
'type' => 'automated',
'status' => 'sending',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email-campaigns?email_list_id=71&type=automated&status=sending"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 42,
"team_id": 4,
"email_list_id": 71,
"name": "Welcome Series - Welcome Default",
"type": "automated",
"status": "sending",
"enabled": true,
"trigger": "signup",
"start_time": "2026-06-15T09:00:00.000000Z",
"end_time": null,
"created_at": "2026-06-15T09:00:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get an email campaign
requires authentication
Retrieve details for a single campaign, including its
messages (the CampaignMessage rows that make up the sequence).
Each message carries the email_message_id that points at the
baked MJML/HTML creative, plus its trigger and delay settings.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/email-campaigns/7" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/email-campaigns/7"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email-campaigns/7'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/email-campaigns/7',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email-campaigns/7"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 42,
"team_id": 4,
"email_list_id": 71,
"name": "Welcome Series - Welcome Default",
"type": "automated",
"status": "sending",
"enabled": true,
"trigger": "signup",
"messages": [
{
"id": 1002,
"email_campaign_id": 42,
"email_message_id": 5501,
"from_address_id": 433556,
"friendly_from": "AllFreeCrochet",
"trigger": "signup",
"trigger_offset": 0
}
]
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (404, Not found):
{
"message": "No query results for model [Campaign]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Email List management
APIs for managing email lists and subscriptions.
An email list is a team-owned collection of subscribers with its own signup URL, opt-in configuration, brand association, and default From address. Each list has:
- A
slugused in hosted signup / preference URLs. - An opt-in message auto-created at list creation, used for the double opt-in confirmation email (even if DOI is off at creation time — it can be enabled later without recreating the list).
- Optional
brand_id— the brand supplies default logo, colors, and From address for hosted pages and outbound messages.
Slugs
Slugs are lowercase alphanumeric with single hyphens between
segments (e.g. weekly-newsletter, product-updates-2026). They
must be unique per team. If you omit slug on create, one is
generated from name.
Deleting a list
Deleting a list removes it, its subscriptions, and its opt-in message. Sent messages that were addressed to the list remain in the archive.
List email lists
requires authentication
Returns every email list belonging to the authenticated team, ordered alphabetically by name.
Filters
brand_id— restrict to lists on a single brand (int) or an array of brand ids (brand_id[]=12&brand_id[]=13).exclude_ids— array of list ids to omit (e.g. an internal Master File / catch-all list you don't want to include in a bulk fan-out).exclude_ids[]=44&exclude_ids[]=68.fields— comma-separated column list. When supplied the response only contains those columns (idis always included). Reduces payload size for scripting.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/lists?brand_id=eum&exclude_ids[]=16&fields=et" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/lists"
);
const params = {
"brand_id": "eum",
"exclude_ids[0]": "16",
"fields": "et",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists'
params = {
'brand_id': 'eum',
'exclude_ids[0]': '16',
'fields': 'et',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/lists',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'brand_id' => 'eum',
'exclude_ids[0]' => '16',
'fields' => 'et',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists?brand_id=eum&exclude_ids[]=16&fields=et"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 1,
"team_id": 4,
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"brand_id": 12,
"require_double_optin": true,
"utm_tracking_enabled": false,
"default_from_address_id": 3,
"default_friendly_from": "Acme Newsletter",
"created_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create an email list
requires authentication
Create a new email list for the authenticated team. Only name
is required. An opt-in confirmation message is automatically
generated for the new list using the team's default opt-in MJML
template (baked with the brand's colors and logo if brand_id
is provided).
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/lists" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Weekly Newsletter\",
\"slug\": \"weekly-newsletter\",
\"default_friendly_from\": \"Acme Newsletter\",
\"require_double_optin\": true,
\"utm_tracking_enabled\": false,
\"redirect_url\": \"https:\\/\\/acme.example.com\\/welcome\",
\"can_spam\": \"Acme, Inc. 123 Main St, Anytown USA 00000\",
\"create_welcome\": true,
\"tags\": [
\"ftlwzn\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/lists"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"default_friendly_from": "Acme Newsletter",
"require_double_optin": true,
"utm_tracking_enabled": false,
"redirect_url": "https:\/\/acme.example.com\/welcome",
"can_spam": "Acme, Inc. 123 Main St, Anytown USA 00000",
"create_welcome": true,
"tags": [
"ftlwzn"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists'
payload = {
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"default_friendly_from": "Acme Newsletter",
"require_double_optin": true,
"utm_tracking_enabled": false,
"redirect_url": "https:\/\/acme.example.com\/welcome",
"can_spam": "Acme, Inc. 123 Main St, Anytown USA 00000",
"create_welcome": true,
"tags": [
"ftlwzn"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/lists',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Weekly Newsletter',
'slug' => 'weekly-newsletter',
'default_friendly_from' => 'Acme Newsletter',
'require_double_optin' => true,
'utm_tracking_enabled' => false,
'redirect_url' => 'https://acme.example.com/welcome',
'can_spam' => 'Acme, Inc. 123 Main St, Anytown USA 00000',
'create_welcome' => true,
'tags' => [
'ftlwzn',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Weekly Newsletter"), "name");
data.Add(new StringContent("weekly-newsletter"), "slug");
data.Add(new StringContent("Acme Newsletter"), "default_friendly_from");
data.Add(new StringContent("1"), "require_double_optin");
data.Add(new StringContent(""), "utm_tracking_enabled");
data.Add(new StringContent("https://acme.example.com/welcome"), "redirect_url");
data.Add(new StringContent("Acme, Inc. 123 Main St, Anytown USA 00000"), "can_spam");
data.Add(new StringContent("1"), "create_welcome");
data.Add(new StringContent("ftlwzn"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Created):
{
"id": 1,
"team_id": 4,
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"brand_id": 12,
"require_double_optin": true,
"utm_tracking_enabled": false,
"default_from_address_id": 3,
"default_friendly_from": "Acme Newsletter"
}
Example response (422, Validation error):
{
"message": "Another list on this team already uses that slug."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get an email list
requires authentication
Retrieve details for a single email list by id.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/lists/14" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/lists/14"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/14'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/lists/14',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/14"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 1,
"team_id": 4,
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"brand_id": 12,
"require_double_optin": true,
"utm_tracking_enabled": false
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (404, Not found):
{
"message": "No query results for model [EmailList]"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update an email list
requires authentication
Update one or more fields on an email list. Only supplied fields are modified; omitted fields are left untouched.
Example request:
curl --request PUT \
"https://www.sallyjo.com/api/v1/lists/14" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"name\": \"Weekly Newsletter\",
\"slug\": \"weekly-newsletter\",
\"default_friendly_from\": \"Acme Newsletter\",
\"require_double_optin\": true,
\"utm_tracking_enabled\": false,
\"redirect_url\": \"https:\\/\\/acme.example.com\\/welcome\",
\"can_spam\": \"Acme, Inc. 123 Main St, Anytown USA 00000\",
\"tags\": [
\"m\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/lists/14"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"default_friendly_from": "Acme Newsletter",
"require_double_optin": true,
"utm_tracking_enabled": false,
"redirect_url": "https:\/\/acme.example.com\/welcome",
"can_spam": "Acme, Inc. 123 Main St, Anytown USA 00000",
"tags": [
"m"
]
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/14'
payload = {
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"default_friendly_from": "Acme Newsletter",
"require_double_optin": true,
"utm_tracking_enabled": false,
"redirect_url": "https:\/\/acme.example.com\/welcome",
"can_spam": "Acme, Inc. 123 Main St, Anytown USA 00000",
"tags": [
"m"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->put(
'https://www.sallyjo.com/api/v1/lists/14',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'name' => 'Weekly Newsletter',
'slug' => 'weekly-newsletter',
'default_friendly_from' => 'Acme Newsletter',
'require_double_optin' => true,
'utm_tracking_enabled' => false,
'redirect_url' => 'https://acme.example.com/welcome',
'can_spam' => 'Acme, Inc. 123 Main St, Anytown USA 00000',
'tags' => [
'm',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Weekly Newsletter"), "name");
data.Add(new StringContent("weekly-newsletter"), "slug");
data.Add(new StringContent("Acme Newsletter"), "default_friendly_from");
data.Add(new StringContent("1"), "require_double_optin");
data.Add(new StringContent(""), "utm_tracking_enabled");
data.Add(new StringContent("https://acme.example.com/welcome"), "redirect_url");
data.Add(new StringContent("Acme, Inc. 123 Main St, Anytown USA 00000"), "can_spam");
data.Add(new StringContent("m"), "tags[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/14"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Updated):
{
"id": 1,
"team_id": 4,
"name": "Weekly Newsletter",
"slug": "weekly-newsletter",
"brand_id": 12,
"require_double_optin": true,
"utm_tracking_enabled": true
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Example response (422, Validation error):
{
"message": "Another list on this team already uses that slug."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete an email list
requires authentication
Permanently delete an email list, its subscriptions, and its opt-in message. Sent messages that were addressed to the list remain in the archive.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/lists/18" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/lists/18"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/18'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/lists/18',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/18"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Deleted):
{
"message": "Email list deleted successfully."
}
Example response (403, Wrong team):
{
"message": "This action is unauthorized."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get list subscribers
requires authentication
Get all subscriptions for an email list, with optional filtering.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/lists/15/subscribers?limit=19&order_by=last_name&page=20&email=subscriber%40example.com&status=active" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/lists/15/subscribers"
);
const params = {
"limit": "19",
"order_by": "last_name",
"page": "20",
"email": "subscriber@example.com",
"status": "active",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/15/subscribers'
params = {
'limit': '19',
'order_by': 'last_name',
'page': '20',
'email': 'subscriber@example.com',
'status': 'active',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/lists/15/subscribers',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'limit' => '19',
'order_by' => 'last_name',
'page' => '20',
'email' => 'subscriber@example.com',
'status' => 'active',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/15/subscribers?limit=19&order_by=last_name&page=20&email=subscriber%40example.com&status=active"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
[
{
"id": 1,
"email_address_id": 5,
"email_list_id": 1,
"contact_id": 123,
"status": "active",
"address": {
"id": 5,
"email": "subscriber@example.com"
}
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a subscription
requires authentication
Two modes:
signup(default): standard signup flow — runs banned/XVerify checks, honors the list's double opt-in setting, sends welcome or confirmation email, and reactivates inactive rows whenreactivateis true.raw: caller setsstatus,opted_in_at,on_conflictdirectly. Skips XVerify and welcome; still blocks banned addresses.require_double_optin: truestill forces a confirmation email when status resolves toactive.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/lists/17/subscribers?mode=raw&email=hrodriguez%40example.net&contact_id=12&send_welcome=1&reactivate=1&require_double_optin=&status=inactive&opted_in_at=2026-09-02T23%3A52%3A04&subscribed_at=1996-12-25&on_conflict=ignore" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"mode\": \"signup\",
\"email\": \"subscriber@example.com\",
\"contact_id\": 123,
\"send_welcome\": false,
\"reactivate\": true,
\"require_double_optin\": true,
\"status\": \"repellendus\",
\"opted_in_at\": \"quas\",
\"subscribed_at\": \"dicta\",
\"on_conflict\": \"repellendus\",
\"tracking\": []
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/lists/17/subscribers"
);
const params = {
"mode": "raw",
"email": "hrodriguez@example.net",
"contact_id": "12",
"send_welcome": "1",
"reactivate": "1",
"require_double_optin": "0",
"status": "inactive",
"opted_in_at": "2026-09-02T23:52:04",
"subscribed_at": "1996-12-25",
"on_conflict": "ignore",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"mode": "signup",
"email": "subscriber@example.com",
"contact_id": 123,
"send_welcome": false,
"reactivate": true,
"require_double_optin": true,
"status": "repellendus",
"opted_in_at": "quas",
"subscribed_at": "dicta",
"on_conflict": "repellendus",
"tracking": []
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/17/subscribers'
payload = {
"mode": "signup",
"email": "subscriber@example.com",
"contact_id": 123,
"send_welcome": false,
"reactivate": true,
"require_double_optin": true,
"status": "repellendus",
"opted_in_at": "quas",
"subscribed_at": "dicta",
"on_conflict": "repellendus",
"tracking": []
}
params = {
'mode': 'raw',
'email': 'hrodriguez@example.net',
'contact_id': '12',
'send_welcome': '1',
'reactivate': '1',
'require_double_optin': '0',
'status': 'inactive',
'opted_in_at': '2026-09-02T23:52:04',
'subscribed_at': '1996-12-25',
'on_conflict': 'ignore',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/lists/17/subscribers',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'mode' => 'raw',
'email' => 'hrodriguez@example.net',
'contact_id' => '12',
'send_welcome' => '1',
'reactivate' => '1',
'require_double_optin' => '0',
'status' => 'inactive',
'opted_in_at' => '2026-09-02T23:52:04',
'subscribed_at' => '1996-12-25',
'on_conflict' => 'ignore',
],
'json' => [
'mode' => 'signup',
'email' => 'subscriber@example.com',
'contact_id' => 123,
'send_welcome' => false,
'reactivate' => true,
'require_double_optin' => true,
'status' => 'repellendus',
'opted_in_at' => 'quas',
'subscribed_at' => 'dicta',
'on_conflict' => 'repellendus',
'tracking' => [],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("signup"), "mode");
data.Add(new StringContent("subscriber@example.com"), "email");
data.Add(new StringContent("123"), "contact_id");
data.Add(new StringContent(""), "send_welcome");
data.Add(new StringContent("1"), "reactivate");
data.Add(new StringContent("1"), "require_double_optin");
data.Add(new StringContent("repellendus"), "status");
data.Add(new StringContent("quas"), "opted_in_at");
data.Add(new StringContent("dicta"), "subscribed_at");
data.Add(new StringContent("repellendus"), "on_conflict");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/17/subscribers?mode=raw&email=hrodriguez%40example.net&contact_id=12&send_welcome=1&reactivate=1&require_double_optin=&status=inactive&opted_in_at=2026-09-02T23%3A52%3A04&subscribed_at=1996-12-25&on_conflict=ignore"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"subscription": {
"id": 1,
"status": "active"
},
"outcome": "already_subscribed",
"created": false
}
Example response (201):
{
"subscription": {
"id": 1,
"status": "active"
},
"outcome": "created",
"created": true
}
Example response (409):
{
"message": "Subscription already exists.",
"error_code": "already_subscribed"
}
Example response (422):
{
"message": "This email address is banned...",
"error_code": "email_banned"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Bulk create subscriptions
requires authentication
Accepts up to 200 rows per request. Each row follows the same shape as
the single-create endpoint. defaults shallow-merges into every row
(row values win). tracking is merged one level deep so per-row keys
extend defaults without clobbering shared ones.
The batch always returns 200. Per-row failures are surfaced in
results[] with ok: false and an error_code.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/lists/15/subscribers/bulk" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"mode\": \"quidem\",
\"defaults\": [],
\"rows\": [
\"magnam\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/lists/15/subscribers/bulk"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"mode": "quidem",
"defaults": [],
"rows": [
"magnam"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/lists/15/subscribers/bulk'
payload = {
"mode": "quidem",
"defaults": [],
"rows": [
"magnam"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/lists/15/subscribers/bulk',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'mode' => 'quidem',
'defaults' => [],
'rows' => [
'magnam',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("quidem"), "mode");
data.Add(new StringContent("magnam"), "rows[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/lists/15/subscribers/bulk"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"total": 2,
"succeeded": 1,
"failed": 1,
"results": [
{
"index": 0,
"email": "a@x.com",
"ok": true,
"outcome": "created",
"created": true,
"subscription_id": 991
},
{
"index": 1,
"email": "bad",
"ok": false,
"error_code": "invalid_email",
"message": "Failed to process the email address."
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update a subscription
requires authentication
Update the status or contact association of an existing subscription.
Example request:
curl --request PUT \
"https://www.sallyjo.com/api/v1/subscriptions/14/update" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"contact_id\": 123,
\"status\": \"active\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/subscriptions/14/update"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"contact_id": 123,
"status": "active"
};
fetch(url, {
method: "PUT",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/subscriptions/14/update'
payload = {
"contact_id": 123,
"status": "active"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('PUT', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->put(
'https://www.sallyjo.com/api/v1/subscriptions/14/update',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'contact_id' => 123,
'status' => 'active',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("123"), "contact_id");
data.Add(new StringContent("active"), "status");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/subscriptions/14/update"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"id": 1,
"email_address_id": 5,
"email_list_id": 1,
"contact_id": 123,
"status": "active"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Record engagement events for a subscriber
requires authentication
Record one or more open, click, or stylesheet_pixel events against
a subscriber on a list. Each event becomes a row in short_url_visits
tagged with the subscription id and joined to a specific
email_sents row via sent_uuid. sent_uuid is required — create
one first via POST /v1/email/sents. Events without a matching send
cannot be attributed on the subscriber timeline or in per-message
reports, so we refuse them at the door.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/subscriptions/14/events" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"events\": [
\"sequi\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/subscriptions/14/events"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"events": [
"sequi"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/subscriptions/14/events'
payload = {
"events": [
"sequi"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/subscriptions/14/events',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'events' => [
'sequi',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("sequi"), "events[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/subscriptions/14/events"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"recorded": 2,
"events": [
{
"id": 991,
"type": "open",
"visited_at": "2026-08-03T12:34:56Z"
}
]
}
Example response (422, Unknown sent_uuid):
{
"message": "sent_uuid does not belong to this subscription.",
"errors": {
"events.0.sent_uuid": [
"sent_uuid does not belong to this subscription."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Email management
APIs for managing emails
Send an email
requires authentication
Sends a transactional email via the team's configured mailer (SES).
You must supply either raw content (html / plaintext / subject)
or a saved-message reference (message_id). Sending from an address
that is not on the team's verified identity list returns 422.
Content vs saved message
- Raw content: pass
subjectand at least one ofhtml/plaintext. The endpoint will create (or reuse) anEmail\Messagerecord before sending. - Saved message: pass
message_id.subject/html/plaintextare ignored.
Verified sender addresses are listed under
GET /api/v1/verified-identities/emails.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/send" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "to=subscriber@example.com"\
--form "from=hello@acme.com"\
--form "subject=Your weekly digest"\
--form "html=<h1>Hi!</h1><p>Welcome.</p>"\
--form "plaintext=ipsam"\
--form "message_id=174"\
--form "merge_data={"first_name":"Alex","promo_code":"WELCOME10"}"\
--form "cc[]=copy@example.com"\
--form "bcc[]=archive@example.com"\
--form "campaign_id=12"\
--form "attachments[]=@/tmp/phpq6irvvlqesco12tdFzp" const url = new URL(
"https://www.sallyjo.com/api/v1/email/send"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('to', 'subscriber@example.com');
body.append('from', 'hello@acme.com');
body.append('subject', 'Your weekly digest');
body.append('html', '<h1>Hi!</h1><p>Welcome.</p>');
body.append('plaintext', 'ipsam');
body.append('message_id', '174');
body.append('merge_data', '{"first_name":"Alex","promo_code":"WELCOME10"}');
body.append('cc[]', 'copy@example.com');
body.append('bcc[]', 'archive@example.com');
body.append('campaign_id', '12');
body.append('attachments[]', document.querySelector('input[name="attachments[]"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/send'
files = {
'to': (None, 'subscriber@example.com'),
'from': (None, 'hello@acme.com'),
'subject': (None, 'Your weekly digest'),
'html': (None, '<h1>Hi!</h1><p>Welcome.</p>'),
'plaintext': (None, 'ipsam'),
'message_id': (None, '174'),
'merge_data': (None, '{"first_name":"Alex","promo_code":"WELCOME10"}'),
'cc[]': (None, 'copy@example.com'),
'bcc[]': (None, 'archive@example.com'),
'campaign_id': (None, '12'),
'attachments[]': open('/tmp/phpq6irvvlqesco12tdFzp', 'rb')}
payload = {
"to": "subscriber@example.com",
"from": "hello@acme.com",
"subject": "Your weekly digest",
"html": "<h1>Hi!<\/h1><p>Welcome.<\/p>",
"plaintext": "ipsam",
"message_id": 174,
"merge_data": "{\"first_name\":\"Alex\",\"promo_code\":\"WELCOME10\"}",
"cc": [
"copy@example.com"
],
"bcc": [
"archive@example.com"
],
"campaign_id": 12
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'multipart/form-data',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, files=files)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/send',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'to',
'contents' => 'subscriber@example.com'
],
[
'name' => 'from',
'contents' => 'hello@acme.com'
],
[
'name' => 'subject',
'contents' => 'Your weekly digest'
],
[
'name' => 'html',
'contents' => '<h1>Hi!</h1><p>Welcome.</p>'
],
[
'name' => 'plaintext',
'contents' => 'ipsam'
],
[
'name' => 'message_id',
'contents' => '174'
],
[
'name' => 'merge_data',
'contents' => '{"first_name":"Alex","promo_code":"WELCOME10"}'
],
[
'name' => 'cc[]',
'contents' => 'copy@example.com'
],
[
'name' => 'bcc[]',
'contents' => 'archive@example.com'
],
[
'name' => 'campaign_id',
'contents' => '12'
],
[
'name' => 'attachments[]',
'contents' => fopen('/tmp/phpq6irvvlqesco12tdFzp', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("subscriber@example.com"), "to");
data.Add(new StringContent("hello@acme.com"), "from");
data.Add(new StringContent("Your weekly digest"), "subject");
data.Add(new StringContent("<h1>Hi!</h1><p>Welcome.</p>"), "html");
data.Add(new StringContent("ipsam"), "plaintext");
data.Add(new StringContent("174"), "message_id");
data.Add(new StringContent("{"first_name":"Alex","promo_code":"WELCOME10"}"), "merge_data");
data.Add(new StringContent("copy@example.com"), "cc[]");
data.Add(new StringContent("archive@example.com"), "bcc[]");
data.Add(new StringContent("12"), "campaign_id");
var file = new ByteArrayContent(System.IO.File.ReadAllBytes("/tmp/phpq6irvvlqesco12tdFzp"));
data.Add(file, "attachments[]", "test.png");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/send"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success — the saved Sent record):
{
"id": 98765,
"uuid_id": "01H8ZM6E4Q1A8X7ZG9K2P5S3RT",
"team_id": 1,
"email_message_id": 174,
"email_campaign_id": null,
"from_address_id": 5,
"email_subscription_id": null,
"service": "ses",
"status": null,
"created_at": "2026-07-15T16:20:00.000000Z"
}
Example response (404, message_id not found on team):
{
"message": "Message not found"
}
Example response (422, Sender not verified):
{
"message": "The from field must be a verified identity for this team.",
"errors": {
"from": [
"The from field must be a verified identity for this team."
]
}
}
Example response (422, Recipient invalid or banned):
{
"message": "The to field must be a valid, non-banned email address.",
"errors": {
"to": [
"The to field must be a valid, non-banned email address."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Import an HTML creative
requires authentication
Converts the supplied HTML into the editor's MJML + slate JSON
representation and creates a new email creative on the authenticated
team. If any rule marked required matches zero times the whole
import is rejected with 422 and nothing is persisted — iterate against
/import/dry-run first to make sure your rules match cleanly.
Rule shape
Each entry in rules[] looks like:
{
"id": "kebab-case-only",
"stage": "dom",
"required": true,
"max": 1,
"find": { "type": "xpath", "pattern": "//tr[.//p[contains(., 'Tell a Friend')]]" },
"action": { "type": "remove" }
}
See docs/html-import-transform-plan.md (§6.1) for the full field
catalog and imports/{main,second,whale}/rules.json for reference
rulesets. find.type may be css | xpath | regex | text; action.type
may be remove | replace | set-attr | remove-attr | set-text | wrap | unwrap | insert-adjacent.
Data mappings
The optional data_mappings[] payload wires merge-tag chips in the
inserted markup (e.g. {{ spotlight.title }}) to content-source
bindings resolved at send time. See docs/email-import-data-mappings.md
for the binding schema and namespace rules.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/messages/import" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"subject\": \"Weekly Newsletter — Imported\",
\"html\": \"<html><body>…<\\/body><\\/html>\",
\"rules\": [
{
\"id\": \"strip-tell-a-friend\",
\"find\": {
\"type\": \"xpath\",
\"pattern\": \"\\/\\/tr[.\\/\\/p[contains(., \'Tell a Friend\')]]\",
\"scope\": \"body\",
\"flags\": \"i\"
},
\"action\": {
\"type\": \"remove\",
\"with\": \"<img src=\\\"https:\\/\\/placehold.co\\/650x180\\/eeeeee\\/666666?text=Ad+Slot+{{counter}}\\\"\\/>\",
\"attr\": \"src\",
\"position\": \"before\"
},
\"stage\": \"dom\",
\"enabled\": true,
\"required\": true,
\"max\": 1,
\"notes\": \"Drops the address-book \\/ date row.\"
}
],
\"data_mappings\": [
{
\"name\": \"Featured sites\",
\"default\": false,
\"dedupe\": {
\"key\": \"url\",
\"scope\": \"per_source\"
},
\"bindings\": [
{
\"category\": \"featured\",
\"source\": \"content_source\",
\"content_source_id\": 3,
\"count\": 3,
\"sort\": \"newest\",
\"require_image\": true,
\"literal\": [],
\"path\": \"item_1\"
}
]
}
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/email/messages/import"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"subject": "Weekly Newsletter — Imported",
"html": "<html><body>…<\/body><\/html>",
"rules": [
{
"id": "strip-tell-a-friend",
"find": {
"type": "xpath",
"pattern": "\/\/tr[.\/\/p[contains(., 'Tell a Friend')]]",
"scope": "body",
"flags": "i"
},
"action": {
"type": "remove",
"with": "<img src=\"https:\/\/placehold.co\/650x180\/eeeeee\/666666?text=Ad+Slot+{{counter}}\"\/>",
"attr": "src",
"position": "before"
},
"stage": "dom",
"enabled": true,
"required": true,
"max": 1,
"notes": "Drops the address-book \/ date row."
}
],
"data_mappings": [
{
"name": "Featured sites",
"default": false,
"dedupe": {
"key": "url",
"scope": "per_source"
},
"bindings": [
{
"category": "featured",
"source": "content_source",
"content_source_id": 3,
"count": 3,
"sort": "newest",
"require_image": true,
"literal": [],
"path": "item_1"
}
]
}
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/messages/import'
payload = {
"subject": "Weekly Newsletter — Imported",
"html": "<html><body>…<\/body><\/html>",
"rules": [
{
"id": "strip-tell-a-friend",
"find": {
"type": "xpath",
"pattern": "\/\/tr[.\/\/p[contains(., 'Tell a Friend')]]",
"scope": "body",
"flags": "i"
},
"action": {
"type": "remove",
"with": "<img src=\"https:\/\/placehold.co\/650x180\/eeeeee\/666666?text=Ad+Slot+{{counter}}\"\/>",
"attr": "src",
"position": "before"
},
"stage": "dom",
"enabled": true,
"required": true,
"max": 1,
"notes": "Drops the address-book \/ date row."
}
],
"data_mappings": [
{
"name": "Featured sites",
"default": false,
"dedupe": {
"key": "url",
"scope": "per_source"
},
"bindings": [
{
"category": "featured",
"source": "content_source",
"content_source_id": 3,
"count": 3,
"sort": "newest",
"require_image": true,
"literal": [],
"path": "item_1"
}
]
}
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/messages/import',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'subject' => 'Weekly Newsletter — Imported',
'html' => '<html><body>…</body></html>',
'rules' => [
[
'id' => 'strip-tell-a-friend',
'find' => [
'type' => 'xpath',
'pattern' => '//tr[.//p[contains(., \'Tell a Friend\')]]',
'scope' => 'body',
'flags' => 'i',
],
'action' => [
'type' => 'remove',
'with' => '<img src="https://placehold.co/650x180/eeeeee/666666?text=Ad+Slot+{{counter}}"/>',
'attr' => 'src',
'position' => 'before',
],
'stage' => 'dom',
'enabled' => true,
'required' => true,
'max' => 1,
'notes' => 'Drops the address-book / date row.',
],
],
'data_mappings' => [
[
'name' => 'Featured sites',
'default' => false,
'dedupe' => [
'key' => 'url',
'scope' => 'per_source',
],
'bindings' => [
[
'category' => 'featured',
'source' => 'content_source',
'content_source_id' => 3,
'count' => 3,
'sort' => 'newest',
'require_image' => true,
'literal' => [],
'path' => 'item_1',
],
],
],
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Weekly Newsletter — Imported"), "subject");
data.Add(new StringContent("<html><body>…</body></html>"), "html");
data.Add(new StringContent("strip-tell-a-friend"), "rules[][id]");
data.Add(new StringContent("xpath"), "rules[][find][type]");
data.Add(new StringContent("//tr[.//p[contains(., 'Tell a Friend')]]"), "rules[][find][pattern]");
data.Add(new StringContent("body"), "rules[][find][scope]");
data.Add(new StringContent("i"), "rules[][find][flags]");
data.Add(new StringContent("remove"), "rules[][action][type]");
data.Add(new StringContent("<img src="https://placehold.co/650x180/eeeeee/666666?text=Ad+Slot+{{counter}}"/>"), "rules[][action][with]");
data.Add(new StringContent("src"), "rules[][action][attr]");
data.Add(new StringContent("before"), "rules[][action][position]");
data.Add(new StringContent("dom"), "rules[][stage]");
data.Add(new StringContent("1"), "rules[][enabled]");
data.Add(new StringContent("1"), "rules[][required]");
data.Add(new StringContent("1"), "rules[][max]");
data.Add(new StringContent("Drops the address-book / date row."), "rules[][notes]");
data.Add(new StringContent("Featured sites"), "data_mappings[][name]");
data.Add(new StringContent(""), "data_mappings[][default]");
data.Add(new StringContent("url"), "data_mappings[][dedupe][key]");
data.Add(new StringContent("per_source"), "data_mappings[][dedupe][scope]");
data.Add(new StringContent("featured"), "data_mappings[][bindings][][category]");
data.Add(new StringContent("content_source"), "data_mappings[][bindings][][source]");
data.Add(new StringContent("3"), "data_mappings[][bindings][][content_source_id]");
data.Add(new StringContent("3"), "data_mappings[][bindings][][count]");
data.Add(new StringContent("newest"), "data_mappings[][bindings][][sort]");
data.Add(new StringContent("1"), "data_mappings[][bindings][][require_image]");
data.Add(new StringContent("item_1"), "data_mappings[][bindings][][path]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/messages/import"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Success):
{
"id": 174,
"subject": "Weekly Newsletter — Imported",
"edit_url": "https://app.sallyjo.com/email/message/174/edit/content",
"mjml_url": "https://app.sallyjo.com/email/message/174/mjml",
"rule_results": [
{
"id": "strip-tell-a-friend",
"matched": 1,
"applied": 1,
"error": null
},
{
"id": "replace-liveintent-ads",
"matched": 4,
"applied": 4,
"error": null
}
],
"warnings": [],
"data_mappings": [],
"default_mapping_id": null
}
Example response (422, A required rule matched zero elements — nothing was saved):
{
"message": "One or more rules failed; nothing was saved.",
"rule_results": [
{
"id": "insert-header-universal-element-marker",
"matched": 0,
"applied": 0,
"error": "required rule matched 0 nodes"
}
],
"warnings": []
}
Example response (422, Validation error):
{
"message": "The subject field is required.",
"errors": {
"subject": [
"The subject field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Dry-run an HTML import
requires authentication
Runs the same conversion (and any rules[] / data_mappings[]) as
POST /email/messages/import but persists nothing. Returns the
mjml_json the editor would receive along with per-rule results and
warnings so you can iterate on source HTML and rule sets without
littering the team with throwaway creatives.
The request body is identical to POST /email/messages/import; see
that endpoint's docs for the full field catalog. This endpoint never
returns 422 for "required rule matched 0 nodes" — instead the failure
shows up in rule_results[].error so you can inspect exactly which
rules missed and iterate on them.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/messages/import/dry-run" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"subject\": \"Weekly Newsletter — Imported\",
\"html\": \"<html><body>…<\\/body><\\/html>\",
\"rules\": [
{
\"id\": \"a\",
\"find\": {
\"type\": \"xpath\",
\"pattern\": \"enim\",
\"scope\": \"document\",
\"flags\": \"repellat\"
},
\"action\": {
\"type\": \"insert-adjacent\",
\"with\": \"et\",
\"attr\": \"et\",
\"position\": \"after\"
},
\"stage\": \"dom\"
}
],
\"data_mappings\": [
{
\"name\": \"lbjperozcksy\",
\"default\": false,
\"dedupe\": {
\"key\": \"title\",
\"scope\": \"across_sources\"
},
\"bindings\": [
{
\"category\": \"iqwvqhhtbtrwgaqlycdi\",
\"source\": \"literal\",
\"content_source_id\": 32,
\"count\": 25,
\"sort\": \"oldest\",
\"require_image\": true,
\"path\": \"vujdcvjh\"
}
]
}
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/email/messages/import/dry-run"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"subject": "Weekly Newsletter — Imported",
"html": "<html><body>…<\/body><\/html>",
"rules": [
{
"id": "a",
"find": {
"type": "xpath",
"pattern": "enim",
"scope": "document",
"flags": "repellat"
},
"action": {
"type": "insert-adjacent",
"with": "et",
"attr": "et",
"position": "after"
},
"stage": "dom"
}
],
"data_mappings": [
{
"name": "lbjperozcksy",
"default": false,
"dedupe": {
"key": "title",
"scope": "across_sources"
},
"bindings": [
{
"category": "iqwvqhhtbtrwgaqlycdi",
"source": "literal",
"content_source_id": 32,
"count": 25,
"sort": "oldest",
"require_image": true,
"path": "vujdcvjh"
}
]
}
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/messages/import/dry-run'
payload = {
"subject": "Weekly Newsletter — Imported",
"html": "<html><body>…<\/body><\/html>",
"rules": [
{
"id": "a",
"find": {
"type": "xpath",
"pattern": "enim",
"scope": "document",
"flags": "repellat"
},
"action": {
"type": "insert-adjacent",
"with": "et",
"attr": "et",
"position": "after"
},
"stage": "dom"
}
],
"data_mappings": [
{
"name": "lbjperozcksy",
"default": false,
"dedupe": {
"key": "title",
"scope": "across_sources"
},
"bindings": [
{
"category": "iqwvqhhtbtrwgaqlycdi",
"source": "literal",
"content_source_id": 32,
"count": 25,
"sort": "oldest",
"require_image": true,
"path": "vujdcvjh"
}
]
}
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/messages/import/dry-run',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'subject' => 'Weekly Newsletter — Imported',
'html' => '<html><body>…</body></html>',
'rules' => [
[
'id' => 'a',
'find' => [
'type' => 'xpath',
'pattern' => 'enim',
'scope' => 'document',
'flags' => 'repellat',
],
'action' => [
'type' => 'insert-adjacent',
'with' => 'et',
'attr' => 'et',
'position' => 'after',
],
'stage' => 'dom',
],
],
'data_mappings' => [
[
'name' => 'lbjperozcksy',
'default' => false,
'dedupe' => [
'key' => 'title',
'scope' => 'across_sources',
],
'bindings' => [
[
'category' => 'iqwvqhhtbtrwgaqlycdi',
'source' => 'literal',
'content_source_id' => 32,
'count' => 25,
'sort' => 'oldest',
'require_image' => true,
'path' => 'vujdcvjh',
],
],
],
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Weekly Newsletter — Imported"), "subject");
data.Add(new StringContent("<html><body>…</body></html>"), "html");
data.Add(new StringContent("a"), "rules[][id]");
data.Add(new StringContent("xpath"), "rules[][find][type]");
data.Add(new StringContent("enim"), "rules[][find][pattern]");
data.Add(new StringContent("document"), "rules[][find][scope]");
data.Add(new StringContent("repellat"), "rules[][find][flags]");
data.Add(new StringContent("insert-adjacent"), "rules[][action][type]");
data.Add(new StringContent("et"), "rules[][action][with]");
data.Add(new StringContent("et"), "rules[][action][attr]");
data.Add(new StringContent("after"), "rules[][action][position]");
data.Add(new StringContent("dom"), "rules[][stage]");
data.Add(new StringContent("lbjperozcksy"), "data_mappings[][name]");
data.Add(new StringContent(""), "data_mappings[][default]");
data.Add(new StringContent("title"), "data_mappings[][dedupe][key]");
data.Add(new StringContent("across_sources"), "data_mappings[][dedupe][scope]");
data.Add(new StringContent("iqwvqhhtbtrwgaqlycdi"), "data_mappings[][bindings][][category]");
data.Add(new StringContent("literal"), "data_mappings[][bindings][][source]");
data.Add(new StringContent("32"), "data_mappings[][bindings][][content_source_id]");
data.Add(new StringContent("25"), "data_mappings[][bindings][][count]");
data.Add(new StringContent("oldest"), "data_mappings[][bindings][][sort]");
data.Add(new StringContent("1"), "data_mappings[][bindings][][require_image]");
data.Add(new StringContent("vujdcvjh"), "data_mappings[][bindings][][path]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/messages/import/dry-run"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"mjml_json": {
"content": {
"type": "page",
"data": {
"preheader": "AllFreeKnitting for {{ date.current }}"
},
"children": [
{
"type": "standard-section",
"uid": "9",
"title": "KP Header",
"children": [],
"attributes": {}
}
]
}
},
"rule_results": [
{
"id": "insert-header-universal-element-marker",
"matched": 1,
"applied": 1,
"error": null
},
{
"id": "strip-header-topnav-row",
"matched": 1,
"applied": 1,
"error": null
},
{
"id": "override-preheader",
"matched": 1,
"applied": 1,
"error": null
}
],
"warnings": []
}
Example response (422, Validation error):
{
"message": "The html field is required.",
"errors": {
"html": [
"The html field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create a raw HTML email creative
requires authentication
Persists an email_messages row with the supplied HTML as-is. Unlike
POST /email/messages/import this endpoint runs no HTML-to-MJML
conversion — the stored html is exactly what the caller sent. Use
this when the creative was rendered elsewhere (e.g. by a
third-party ESP) and Sally Jo only needs to record it so opens and
clicks can be attributed back to a message via
POST /v1/email/sents.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/messages" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"subject\": \"Weekly Newsletter — 2026-08-27\",
\"html\": \"<html><body>…<\\/body><\\/html>\",
\"plaintext\": \"aut\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/email/messages"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"subject": "Weekly Newsletter — 2026-08-27",
"html": "<html><body>…<\/body><\/html>",
"plaintext": "aut"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/messages'
payload = {
"subject": "Weekly Newsletter — 2026-08-27",
"html": "<html><body>…<\/body><\/html>",
"plaintext": "aut"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/messages',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'subject' => 'Weekly Newsletter — 2026-08-27',
'html' => '<html><body>…</body></html>',
'plaintext' => 'aut',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("Weekly Newsletter — 2026-08-27"), "subject");
data.Add(new StringContent("<html><body>…</body></html>"), "html");
data.Add(new StringContent("aut"), "plaintext");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/messages"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Success):
{
"id": 174,
"subject": "Weekly Newsletter — 2026-08-27"
}
Example response (422, Validation error):
{
"message": "The subject field is required.",
"errors": {
"subject": [
"The subject field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Record an external send
requires authentication
Creates an email_sents row linking a previously-created
email_messages row to a specific email_subscriptions row. The
returned uuid_id is the value callers must pass as sent_uuid when
posting engagement events for this send.
(service, external_id) is unique. Re-posting the same
(service, external_id) returns the existing row instead of
creating a duplicate, so retries are safe.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/sents" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email_message_id\": 174,
\"email_subscription_id\": 323346,
\"from\": \"newsletter@example.com\",
\"external_id\": \"ml-msg-abc123\",
\"service\": \"imported\",
\"sent_at\": \"2026-08-27T12:00:00Z\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/email/sents"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email_message_id": 174,
"email_subscription_id": 323346,
"from": "newsletter@example.com",
"external_id": "ml-msg-abc123",
"service": "imported",
"sent_at": "2026-08-27T12:00:00Z"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/sents'
payload = {
"email_message_id": 174,
"email_subscription_id": 323346,
"from": "newsletter@example.com",
"external_id": "ml-msg-abc123",
"service": "imported",
"sent_at": "2026-08-27T12:00:00Z"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/sents',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'email_message_id' => 174,
'email_subscription_id' => 323346,
'from' => 'newsletter@example.com',
'external_id' => 'ml-msg-abc123',
'service' => 'imported',
'sent_at' => '2026-08-27T12:00:00Z',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("174"), "email_message_id");
data.Add(new StringContent("323346"), "email_subscription_id");
data.Add(new StringContent("newsletter@example.com"), "from");
data.Add(new StringContent("ml-msg-abc123"), "external_id");
data.Add(new StringContent("imported"), "service");
data.Add(new StringContent("2026-08-27T12:00:00Z"), "sent_at");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/sents"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Already existed (idempotent replay)):
{
"uuid_id": "0192b9d0-1a63-7c78-bbcc-1a63a7c78f00",
"id": 12345,
"email_message_id": 174,
"email_subscription_id": 323346,
"service": "imported",
"created_at": "2026-08-28T14:48:34+00:00"
}
Example response (201, Created):
{
"uuid_id": "0192b9d0-1a63-7c78-bbcc-1a63a7c78f00",
"id": 12345,
"email_message_id": 174,
"email_subscription_id": 323346,
"service": "imported",
"created_at": "2026-08-28T14:48:34+00:00"
}
Example response (422, Message does not belong to team):
{
"message": "email_message_id does not belong to this team.",
"errors": {
"email_message_id": [
"email_message_id does not belong to this team."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Bulk record external sends
requires authentication
Accepts up to 200 rows per request. Each row follows the same shape as
the single-create endpoint. defaults shallow-merges into every row
(row values win) so callers can hoist common fields like from or
service out of each row.
The batch always returns 200. Per-row failures are surfaced in
results[] with ok: false and an error_code. Successful rows
report whether the send was newly created or matched an existing
(service, external_id) pair (idempotent replay).
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/email/sents/bulk" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"defaults\": {
\"from\": \"eum\",
\"service\": \"non\",
\"sent_at\": \"2026-09-02T23:52:04\"
},
\"sents\": [
\"eos\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/email/sents/bulk"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"defaults": {
"from": "eum",
"service": "non",
"sent_at": "2026-09-02T23:52:04"
},
"sents": [
"eos"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/email/sents/bulk'
payload = {
"defaults": {
"from": "eum",
"service": "non",
"sent_at": "2026-09-02T23:52:04"
},
"sents": [
"eos"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/email/sents/bulk',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'defaults' => [
'from' => 'eum',
'service' => 'non',
'sent_at' => '2026-09-02T23:52:04',
],
'sents' => [
'eos',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("eum"), "defaults[from]");
data.Add(new StringContent("non"), "defaults[service]");
data.Add(new StringContent("2026-09-02T23:52:04"), "defaults[sent_at]");
data.Add(new StringContent("eos"), "sents[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/email/sents/bulk"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"total": 2,
"succeeded": 2,
"failed": 0,
"results": [
{
"index": 0,
"external_id": "ml-msg-1",
"ok": true,
"created": true,
"uuid_id": "0192b9d0-1a63-7c78-bbcc-1a63a7c78f00",
"id": 12345,
"email_message_id": 174,
"email_subscription_id": 323346,
"service": "imported",
"created_at": "2026-08-28T14:48:34+00:00"
},
{
"index": 1,
"external_id": "ml-msg-2",
"ok": true,
"created": false,
"uuid_id": "0192b9d0-1a63-7c78-bbcc-1a63a7c78f01",
"id": 12346,
"email_message_id": 174,
"email_subscription_id": 323347,
"service": "imported",
"created_at": "2026-08-28T14:48:34+00:00"
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Files
APIs for managing the private file library belonging to a team.
Every team has a private SallyJo file library (rooted at
Team::storage_directory) where uploaded assets — logos, headers,
footers, PDFs, imports — live. Files here are not publicly
readable: every GET/show response includes a short-lived
pre-signed url (30 minutes) that a browser or email preview can
follow to read the file.
SallyJo also maintains a separate public file library used by the message-build pipeline; this API surface only reads and writes the private library.
When a file is actually used for public rendering (a brand logo on a hosted preference page, an inline image in a sent email), SallyJo copies that specific asset into public storage at send-time as part of the message-build pipeline. This API surface is private-only.
Paths
Files are identified by a path relative to the team's file root.
Paths may include subdirectories:
brands/logos/acme.png
imports/2026-june-newsletter.csv
logos/square.png
The path is the only identifier — there is no numeric id, and no
database row. Uploading a file with a path that already exists
overwrites the previous file.
Size limits
- Max upload size: 10 MB
- No mime-type restriction — any file type is accepted
URLs
The url returned by GET responses is a short-lived pre-signed
URL valid for 30 minutes. Do not persist it — request the file
again to get a fresh URL. This URL is safe to embed in a hosted
preview or paste into a browser but should not be pasted into
a live email template; use the SallyJo web asset picker for that,
which will publish a permanent public copy at send-time.
List files
requires authentication
Returns every file in the team's private file library, ordered
by most-recently modified first. Optionally scope the listing to
a subdirectory with the prefix query parameter.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/files?prefix=brands%2Flogos" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/files"
);
const params = {
"prefix": "brands/logos",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/files'
params = {
'prefix': 'brands/logos',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/files',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'prefix' => 'brands/logos',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/files?prefix=brands%2Flogos"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"path": "brands/logos/acme.png",
"name": "acme.png",
"url": "https://files.sallyjo.example/private/files/4/brands/logos/acme.png?signature=…",
"mime_type": "image/png",
"size": 45210,
"last_modified": "2026-08-15T12:34:56+00:00"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Upload a file
requires authentication
Upload a file to the team's private file library. Send as
multipart/form-data with a file field and optional path
field.
If path is provided it is used verbatim as the destination
(subdirectories are created as needed). If path ends with /
or is omitted, the file is stored at that prefix under its
uploaded filename. Uploading to a path that already exists
overwrites the previous file.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/files" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "path=brands/logos/acme.png"\
--form "file=@/tmp/phpspk7hsm03cg2cvocCi8" const url = new URL(
"https://www.sallyjo.com/api/v1/files"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('path', 'brands/logos/acme.png');
body.append('file', document.querySelector('input[name="file"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/files'
files = {
'path': (None, 'brands/logos/acme.png'),
'file': open('/tmp/phpspk7hsm03cg2cvocCi8', 'rb')}
payload = {
"path": "brands\/logos\/acme.png"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'multipart/form-data',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, files=files)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/files',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'path',
'contents' => 'brands/logos/acme.png'
],
[
'name' => 'file',
'contents' => fopen('/tmp/phpspk7hsm03cg2cvocCi8', 'r')
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("brands/logos/acme.png"), "path");
var file = new ByteArrayContent(System.IO.File.ReadAllBytes("/tmp/phpspk7hsm03cg2cvocCi8"));
data.Add(file, "attachments[]", "test.png");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/files"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Uploaded):
{
"path": "brands/logos/acme.png",
"name": "acme.png",
"url": "https://files.sallyjo.example/private/files/4/brands/logos/acme.png?signature=…",
"mime_type": "image/png",
"size": 45210,
"last_modified": "2026-08-15T12:34:56+00:00"
}
Example response (422, Validation error):
{
"message": "The file may not be larger than 10 MB."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get a file
requires authentication
Retrieve metadata (plus a fresh 30-minute pre-signed url) for a
single file by its path.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/files/show?path=brands%2Flogos%2Facme.png" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/files/show"
);
const params = {
"path": "brands/logos/acme.png",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/files/show'
params = {
'path': 'brands/logos/acme.png',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/files/show',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'path' => 'brands/logos/acme.png',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/files/show?path=brands%2Flogos%2Facme.png"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"path": "brands/logos/acme.png",
"name": "acme.png",
"url": "https://files.sallyjo.example/private/files/4/brands/logos/acme.png?signature=…",
"mime_type": "image/png",
"size": 45210,
"last_modified": "2026-08-15T12:34:56+00:00"
}
Example response (404, Not found):
{
"message": "File not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete a file
requires authentication
Permanently delete a file from the team's private file library.
Example request:
curl --request DELETE \
"https://www.sallyjo.com/api/v1/files?path=brands%2Flogos%2Facme.png" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/files"
);
const params = {
"path": "brands/logos/acme.png",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/files'
params = {
'path': 'brands/logos/acme.png',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('DELETE', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->delete(
'https://www.sallyjo.com/api/v1/files',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'path' => 'brands/logos/acme.png',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/files?path=brands%2Flogos%2Facme.png"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Deleted):
{
"message": "File deleted successfully."
}
Example response (404, Not found):
{
"message": "File not found."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Form management
APIs for managing forms
List forms
requires authentication
Returns every form belonging to the authenticated team, ordered alphabetically by name. Useful for wiring form IDs into other resources (e.g. Facebook Conversions API mappings) without copy-pasting from the UI.
Each row includes has_email_field — true when the form schema
contains at least one field with type: 'email' or
subtype: 'email'. Pass ?has_email_field=true to restrict the
response to signup-style forms only.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/forms?has_email_field=" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/forms"
);
const params = {
"has_email_field": "0",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/forms'
params = {
'has_email_field': '0',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/forms',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'has_email_field' => '0',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/forms?has_email_field="),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
[
{
"id": 189,
"uuid_id": "01a0-...-cf",
"team_id": 4,
"name": "ATTN Cobuy Form",
"theme": "sally-jo-silver",
"type": "inline",
"has_email_field": true,
"stats_submissions_count": 3421,
"stats_displays_count": 91824,
"created_at": "2026-06-01T10:30:00.000000Z",
"updated_at": "2026-06-01T10:30:00.000000Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Link management
APIs for managing emails
Create a short link
requires authentication
Creates a team-scoped short URL that permanently 302-redirects to
destination_url. The short key is deterministic per team +
destination pair, so calling this endpoint twice with the same
destination_url returns the same short link (idempotent).
The returned record includes a default_short_url string that is the
fully qualified short URL to hand to end users.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/links/create" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"destination_url\": \"https:\\/\\/www.example.com\\/landing?utm_source=email\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/links/create"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"destination_url": "https:\/\/www.example.com\/landing?utm_source=email"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/links/create'
payload = {
"destination_url": "https:\/\/www.example.com\/landing?utm_source=email"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/links/create',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'destination_url' => 'https://www.example.com/landing?utm_source=email',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("https://www.example.com/landing?utm_source=email"), "destination_url");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/links/create"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 42,
"team_id": 1,
"destination_url": "https://www.example.com/landing?utm_source=email",
"url_key": "aB3xY7q",
"redirect_status_code": 302,
"single_use": false,
"track_visits": true,
"default_short_url": "https://sjo.link/aB3xY7q",
"created_at": "2026-07-15T16:20:00.000000Z",
"updated_at": "2026-07-15T16:20:00.000000Z"
}
Example response (422, Missing or malformed destination):
{
"message": "The destination url field is required.",
"errors": {
"destination_url": [
"The destination url field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Push Notifications
APIs for push notification subscriptions
Subscribe to push notifications (single list)
This endpoint allows a browser to subscribe to push notifications for a specific list. The endpoint is public and does not require authentication.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/push/subscribe" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"list_slug\": \"my-notifications\",
\"endpoint\": \"https:\\/\\/fcm.googleapis.com\\/fcm\\/send\\/...\",
\"p256dh\": \"BNVAPKu...\",
\"auth\": \"abc123...\",
\"expiration_time\": 18,
\"domain\": \"example.com\",
\"browser\": \"Chrome\",
\"platform\": \"Win32\",
\"categories\": [
\"gobspmgnyeiquerhpjlll\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/push/subscribe"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"list_slug": "my-notifications",
"endpoint": "https:\/\/fcm.googleapis.com\/fcm\/send\/...",
"p256dh": "BNVAPKu...",
"auth": "abc123...",
"expiration_time": 18,
"domain": "example.com",
"browser": "Chrome",
"platform": "Win32",
"categories": [
"gobspmgnyeiquerhpjlll"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/push/subscribe'
payload = {
"list_slug": "my-notifications",
"endpoint": "https:\/\/fcm.googleapis.com\/fcm\/send\/...",
"p256dh": "BNVAPKu...",
"auth": "abc123...",
"expiration_time": 18,
"domain": "example.com",
"browser": "Chrome",
"platform": "Win32",
"categories": [
"gobspmgnyeiquerhpjlll"
]
}
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/push/subscribe',
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'list_slug' => 'my-notifications',
'endpoint' => 'https://fcm.googleapis.com/fcm/send/...',
'p256dh' => 'BNVAPKu...',
'auth' => 'abc123...',
'expiration_time' => 18,
'domain' => 'example.com',
'browser' => 'Chrome',
'platform' => 'Win32',
'categories' => [
'gobspmgnyeiquerhpjlll',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("my-notifications"), "list_slug");
data.Add(new StringContent("https://fcm.googleapis.com/fcm/send/..."), "endpoint");
data.Add(new StringContent("BNVAPKu..."), "p256dh");
data.Add(new StringContent("abc123..."), "auth");
data.Add(new StringContent("18"), "expiration_time");
data.Add(new StringContent("example.com"), "domain");
data.Add(new StringContent("Chrome"), "browser");
data.Add(new StringContent("Win32"), "platform");
data.Add(new StringContent("gobspmgnyeiquerhpjlll"), "categories[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/push/subscribe"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201):
{
"success": true,
"subscription_id": "123",
"message": "Successfully subscribed to push notifications"
}
Example response (422):
{
"message": "The given data was invalid.",
"errors": {
"list_slug": [
"The specified push notification list does not exist."
]
}
}
Example response (429):
{
"message": "Too many subscription attempts. Please try again later."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Batch subscribe to push notifications (multiple lists)
Subscribe a browser to multiple push notification lists at once. Each list requires its own push subscription (different VAPID key = different endpoint). Used by prompts that target multiple lists.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/push/batch-subscribe" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"prompt_id\": \"550e8400-e29b-41d4-a716-446655440000\",
\"subscriptions\": [
\"ut\"
],
\"domain\": \"example.com\",
\"browser\": \"Chrome\",
\"platform\": \"Win32\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/push/batch-subscribe"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"subscriptions": [
"ut"
],
"domain": "example.com",
"browser": "Chrome",
"platform": "Win32"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/push/batch-subscribe'
payload = {
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"subscriptions": [
"ut"
],
"domain": "example.com",
"browser": "Chrome",
"platform": "Win32"
}
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/push/batch-subscribe',
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'prompt_id' => '550e8400-e29b-41d4-a716-446655440000',
'subscriptions' => [
'ut',
],
'domain' => 'example.com',
'browser' => 'Chrome',
'platform' => 'Win32',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("550e8400-e29b-41d4-a716-446655440000"), "prompt_id");
data.Add(new StringContent("ut"), "subscriptions[]");
data.Add(new StringContent("example.com"), "domain");
data.Add(new StringContent("Chrome"), "browser");
data.Add(new StringContent("Win32"), "platform");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/push/batch-subscribe"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201):
{
"success": true,
"results": [
{
"list_slug": "news",
"subscription_id": "1",
"status": "created"
},
{
"list_slug": "deals",
"subscription_id": "2",
"status": "created"
}
]
}
Example response (422):
{
"message": "The given data was invalid."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get prompt configuration
Returns the prompt configuration, theme, tracking URLs, and list VAPID keys. Used by the embed script on external sites to render the prompt UI.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/push/prompt/nulla" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/push/prompt/nulla"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/push/prompt/nulla'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/push/prompt/nulla',
[
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/push/prompt/nulla"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"uuid": "550e8400...",
"type": "slide",
"position": "bottom_center",
"lists": [
{
"listId": 1,
"slug": "news",
"publicKey": "BN..."
}
],
"tracking": {
"impression": "https://..."
}
}
Example response (404):
{
"message": "Prompt not found or disabled."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
SMS List management
APIs for managing SMS lists and subscriptions
Get SMS list subscribers
requires authentication
Returns a paginated list of subscribers for the specified SMS list.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/sms/lists/14/subscribers?phone_number=%2B1555&status=active&per_page=15" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/sms/lists/14/subscribers"
);
const params = {
"phone_number": "+1555",
"status": "active",
"per_page": "15",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/sms/lists/14/subscribers'
params = {
'phone_number': '+1555',
'status': 'active',
'per_page': '15',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/sms/lists/14/subscribers',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'phone_number' => '+1555',
'status' => 'active',
'per_page' => '15',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/sms/lists/14/subscribers?phone_number=%2B1555&status=active&per_page=15"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"data": [],
"current_page": 1,
"total": 0
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create SMS list subscription
requires authentication
Add a phone number to an SMS list. Automatically cleans and validates the phone number.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/sms/lists/15/subscribers" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"phone_number\": \"+15551234567\",
\"contact_id\": 42,
\"assume_country_code\": 1
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/sms/lists/15/subscribers"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"phone_number": "+15551234567",
"contact_id": 42,
"assume_country_code": 1
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/sms/lists/15/subscribers'
payload = {
"phone_number": "+15551234567",
"contact_id": 42,
"assume_country_code": 1
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/sms/lists/15/subscribers',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'phone_number' => '+15551234567',
'contact_id' => 42,
'assume_country_code' => 1,
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("+15551234567"), "phone_number");
data.Add(new StringContent("42"), "contact_id");
data.Add(new StringContent("1"), "assume_country_code");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/sms/lists/15/subscribers"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201):
{
"id": 1,
"phone_number": "+15551234567",
"status": "active"
}
Example response (422):
{
"message": "Validation failed",
"errors": {
"phone_number": [
"Invalid phone number"
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
SMS management
APIs for managing text messages
Send an SMS/MMS message
requires authentication
Send a text message or multimedia message via Twilio. Include media_urls to send as MMS.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/sms/send" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"to\": \"+15551234567\",
\"from\": \"+15559876543\",
\"message\": \"Hello from our team!\",
\"media_urls\": [
\"https:\\/\\/example.com\\/image.jpg\"
]
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/sms/send"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"to": "+15551234567",
"from": "+15559876543",
"message": "Hello from our team!",
"media_urls": [
"https:\/\/example.com\/image.jpg"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/sms/send'
payload = {
"to": "+15551234567",
"from": "+15559876543",
"message": "Hello from our team!",
"media_urls": [
"https:\/\/example.com\/image.jpg"
]
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/sms/send',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'to' => '+15551234567',
'from' => '+15559876543',
'message' => 'Hello from our team!',
'media_urls' => [
'https://example.com/image.jpg',
],
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("+15551234567"), "to");
data.Add(new StringContent("+15559876543"), "from");
data.Add(new StringContent("Hello from our team!"), "message");
data.Add(new StringContent("https://example.com/image.jpg"), "media_urls[]");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/sms/send"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"success": true,
"sid": "SM...",
"sent_id": "..."
}
Example response (422):
{
"error": "recipient_unsubscribed",
"message": "Recipient has opted out and cannot receive messages."
}
Example response (422):
{
"error": "not_mobile_number",
"message": "The destination is not a valid mobile number."
}
Example response (422):
{
"message": "The to field is required.",
"errors": {
"to": [
"The to field is required."
]
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Tags
APIs for managing team-scoped tags.
A tag is a lightweight label owned by a single team. Tags can be
attached to brands, email lists, SMS lists, push lists, contacts, and
other taggable resources. Tag identifiers are their slug (a
URL-safe form of name, unique per team). Attach or detach tags on
a resource by passing a tags array of slugs to that resource's
store/update endpoint.
Tag names and slugs are always team-scoped: two teams can each have a
tag named craft and they will not collide.
List tags
requires authentication
Returns every tag belonging to the authenticated team, ordered alphabetically by name.
Create a tag
requires authentication
Create a new tag for the authenticated team. Tag name must be
unique within the team (case-insensitive). The slug is derived
automatically from name if omitted.
Get a tag
requires authentication
Retrieve details for a single tag by id.
Update a tag
requires authentication
Update the name or type of an existing tag. The slug is
re-derived from the new name unless a slug is supplied
explicitly.
Delete a tag
requires authentication
Permanently delete a tag. All resources that were tagged are automatically untagged; the resources themselves are not deleted.
Team management
APIs for managing teams
Get the authenticated team
requires authentication
Returns the team that owns the API token used to make this request.
Note: API auth resolves to a Team model, not a User. If your
client expects a user object, this is where clients usually go wrong —
every $request->user() call in the api/v1/* group returns the team
whose token authenticated the request.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/team" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/team"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/team'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/team',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/team"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200, Success):
{
"id": 1,
"user_id": 42,
"name": "Acme, Inc.",
"personal_team": false,
"current_plan": "growth",
"yearly_billing": false,
"manual_billing": false,
"billing_period_start": "2026-06-01T00:00:00.000000Z",
"billing_period_end": "2026-07-01T00:00:00.000000Z",
"monthly_allowances": {
"emails": 50000,
"sms": 1000
},
"created_at": "2024-01-15T10:30:00.000000Z",
"updated_at": "2026-06-01T00:00:00.000000Z"
}
Example response (401, Missing or invalid token):
{
"message": "Unauthenticated."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Verified Identities
APIs for retrieving verified sender identities.
Before sending emails or SMS messages through the API, you need verified sender identities. These endpoints allow you to retrieve your team's verified email addresses and phone numbers.
Email Verification
Email addresses are verified through AWS SES. When you add a new email address, a verification email is sent to that address. Once verified, you can use it as the "from" address when sending emails.
Phone Verification
Phone numbers are verified through Twilio. These are typically purchased or ported numbers that have been registered with your Twilio account and linked to your team.
Using Verified Identities
- Use
GET /api/v1/verified-identities/emailsto list available email addresses for sending - Use
GET /api/v1/verified-identities/phonesto list available phone numbers for SMS - Pass the email/phone value to the respective send endpoints
List all verified identities
requires authentication
Get all verified identities (emails, phones, and domains) for this team. Useful for getting a complete overview of available sender identities.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/verified-identities?type=email" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/verified-identities"
);
const params = {
"type": "email",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/verified-identities'
params = {
'type': 'email',
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers, params=params)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/verified-identities',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'query' => [
'type' => 'email',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/verified-identities?type=email"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
{
"emails": [
{
"id": 1,
"email": "hello@example.com",
"verified_at": "2024-01-15T10:30:00Z"
}
],
"phones": [
{
"id": 1,
"phone_number": "+14155552671",
"type": "twilio",
"verified_at": "2024-01-15T10:30:00Z"
}
],
"domains": [
{
"id": 1,
"domain": "example.com",
"verified_at": "2024-01-10T09:00:00Z"
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List verified email addresses
requires authentication
Get all verified email addresses that can be used as sender addresses for this team. Only returns emails that have completed verification.
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/verified-identities/emails" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/verified-identities/emails"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/verified-identities/emails'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/verified-identities/emails',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/verified-identities/emails"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
[
{
"id": 1,
"email": "hello@example.com",
"verified_at": "2024-01-15T10:30:00Z"
},
{
"id": 2,
"email": "support@example.com",
"verified_at": "2024-01-20T14:45:00Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Add a verified email address
requires authentication
Registers a new sender email address for the team. If the address's
domain is already an email-enabled, verified team_domain, the
identity is marked verified immediately with no confirmation email
sent. Otherwise SES sends a confirmation email to the address and
the identity remains pending until the recipient clicks the confirm
link.
The Address record itself is shared globally (SES needs a single
canonical row per address) — this endpoint associates that address
with the authenticated team by creating a verified_identities row.
Example request:
curl --request POST \
"https://www.sallyjo.com/api/v1/verified-identities/emails" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Idempotency-Key: f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"email\": \"newsletters@example.com\"
}"
const url = new URL(
"https://www.sallyjo.com/api/v1/verified-identities/emails"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Idempotency-Key": "f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"email": "newsletters@example.com"
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/verified-identities/emails'
payload = {
"email": "newsletters@example.com"
}
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Idempotency-Key': 'f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('POST', url, headers=headers, json=payload)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->post(
'https://www.sallyjo.com/api/v1/verified-identities/emails',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Idempotency-Key' => 'f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => [
'email' => 'newsletters@example.com',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Add("Idempotency-Key","f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var data = new MultipartFormDataContent();
data.Add(new StringContent("newsletters@example.com"), "email");
var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/verified-identities/emails"),
Content = data
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (201, Verified (auto via domain)):
{
"id": 42,
"email": "newsletters@acme.example.com",
"verified": true,
"verified_at": "2026-06-15T09:00:00.000000Z"
}
Example response (202, Pending confirmation email):
{
"id": 42,
"email": "hello@external.example.com",
"verified": false,
"verified_at": null
}
Example response (422, Invalid email):
{
"message": "Enter a valid, deliverable email address."
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List verified phone numbers
requires authentication
Get all verified phone numbers that can be used as sender numbers for SMS messages. Phone numbers are in E.164 format (e.g., +14155552671).
Example request:
curl --request GET \
--get "https://www.sallyjo.com/api/v1/verified-identities/phones" \
--header "Authorization: Bearer {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"https://www.sallyjo.com/api/v1/verified-identities/phones"
);
const headers = {
"Authorization": "Bearer {YOUR_API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());import requests
import json
url = 'https://www.sallyjo.com/api/v1/verified-identities/phones'
headers = {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
response = requests.request('GET', url, headers=headers)
response.json()$client = new \GuzzleHttp\Client();
$response = $client->get(
'https://www.sallyjo.com/api/v1/verified-identities/phones',
[
'headers' => [
'Authorization' => 'Bearer {YOUR_API_KEY}',
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]
);
$body = $response->getBody();
print_r(json_decode((string) $body));
using System.Net.Http.Headers;
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "{YOUR_API_KEY}");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri("https://www.sallyjo.com/api/v1/verified-identities/phones"),
};
using (var response = await client.SendAsync(request))
{
//response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
}Example response (200):
[
{
"id": 1,
"phone_number": "+14155552671",
"type": "twilio",
"verified_at": "2024-01-15T10:30:00Z"
},
{
"id": 2,
"phone_number": "+14155552672",
"type": "twilio",
"verified_at": "2024-01-20T14:45:00Z"
}
]
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.