MENU navbar-image

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

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:

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"
    }
]
 

Request      

GET api/v1/brands

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

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."
}
 

Request      

POST api/v1/brands

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

name   string   

Customer-facing brand name. Shown in emails, on the unsubscribe page, and anywhere SallyJo identifies the sender. Must not be greater than 255 characters. Example: Acme Newsletter

short_description   string  optional  

One-line summary of the brand shown in preferences pages and confirmation emails. Must not be greater than 255 characters. Example: Weekly product updates and how-to guides.

website_url   string  optional  

Fully-qualified https URL for the brand website. Used for logo click-through and social share previews. Must be a valid URL. Must not be greater than 255 characters. Example: https://acme.example.com

email_address_id   integer  optional  

Verified email identifier id (from GET /v1/verified/email) to use as the default From address for this brand. The identifier_id of an existing record in the verified_identities table.

phone_id   integer  optional  

Verified phone identifier id (from GET /v1/verified/phone) to use as the default From number for SMS sent under this brand. The identifier_id of an existing record in the verified_identities table.

primary_color   string  optional  

Primary brand color as a 7-character hex string (leading #). Used for buttons and links in emails. Must not be greater than 7 characters. Example: #FF5733

secondary_color   string  optional  

Secondary brand color as a 7-character hex string (leading #). Must not be greater than 7 characters. Example: #004E89

is_default   boolean  optional  

When true, this brand becomes the team default and any previously-default brand is unset. New lists inherit the default brand. Example: false

logo_image   string  optional  

Public https URL to the horizontal logo (~200x50). Used in email headers and the top navigation of hosted preference pages. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/logo.png

logo_square_image   string  optional  

Public https URL to a square logo (~100x100). Used for social avatars and web push notifications. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/logo-square.png

header_image   string  optional  

Public https URL to an email header banner (~600x200). Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/header.png

footer_image   string  optional  

Public https URL to an email footer banner (~600x100). Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/footer.png

social_share_image   string  optional  

Public https URL to the og:image used when brand links are shared (~1200x630). Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/og.png

logo_image_path   string  optional  

Path in the team's private file library (from POST /v1/files) to publish and use as the horizontal logo. Takes precedence over logo_image. The file is copied to public storage and the resulting permanent URL is stored on the brand. Must not be greater than 1024 characters. Example: brands/acme/logo.png

logo_square_image_path   string  optional  

Same as logo_image_path but for the square logo. Must not be greater than 1024 characters. Example: brands/acme/logo-square.png

header_image_path   string  optional  

Same as logo_image_path but for the email header banner. Must not be greater than 1024 characters. Example: brands/acme/header.png

footer_image_path   string  optional  

Same as logo_image_path but for the email footer banner. Must not be greater than 1024 characters. Example: brands/acme/footer.png

social_share_image_path   string  optional  

Same as logo_image_path but for the og:image. Must not be greater than 1024 characters. Example: brands/acme/og.png

social_links   string[]  optional  

Must be a valid URL.

tags   string[]  optional  

Must not be greater than 255 characters.

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"
}
 

Request      

POST api/v1/brands/auto-configure

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

url   string   

The fully-qualified website URL to inspect. Example: https://acme.example.com

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]"
}
 

Request      

GET api/v1/brands/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the brand. Example: 3

brand   integer   

The brand id. Example: 12

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."
}
 

Request      

PUT api/v1/brands/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the brand. Example: 17

brand   integer   

The brand id. Example: 12

Body Parameters

name   string  optional  

Customer-facing brand name. Must not be greater than 255 characters. Example: Acme Weekly

short_description   string  optional  

One-line summary of the brand. Must not be greater than 255 characters. Example: Weekly product updates.

website_url   string  optional  

Fully-qualified https URL for the brand website. Must be a valid URL. Must not be greater than 255 characters. Example: https://acme.example.com

email_address_id   integer  optional  

Verified email identifier id to use as default From address. The identifier_id of an existing record in the verified_identities table.

phone_id   integer  optional  

Verified phone identifier id to use as default SMS From. The identifier_id of an existing record in the verified_identities table.

primary_color   string  optional  

Primary brand color, 7-character hex. Must not be greater than 7 characters. Example: #FF5733

secondary_color   string  optional  

Secondary brand color, 7-character hex. Must not be greater than 7 characters. Example: #004E89

is_default   boolean  optional  

Set true to make this the team's default brand. Any previously-default brand is unset in the same request. Example: true

logo_image   string  optional  

Public https URL to the horizontal logo. Pass null to clear. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/logo.png

logo_square_image   string  optional  

Public https URL to a square logo. Pass null to clear. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/logo-square.png

header_image   string  optional  

Public https URL to email header banner. Pass null to clear. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/header.png

footer_image   string  optional  

Public https URL to email footer banner. Pass null to clear. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/footer.png

social_share_image   string  optional  

Public https URL to og:image. Pass null to clear. Must be a valid URL. Must not be greater than 255 characters. Example: https://cdn.example.com/acme/og.png

logo_image_path   string  optional  

Path in the team's private file library to publish as the horizontal logo. Takes precedence over logo_image. Must not be greater than 1024 characters. Example: brands/acme/logo.png

logo_square_image_path   string  optional  

Same as logo_image_path but for the square logo. Must not be greater than 1024 characters. Example: brands/acme/logo-square.png

header_image_path   string  optional  

Same as logo_image_path but for the header banner. Must not be greater than 1024 characters. Example: brands/acme/header.png

footer_image_path   string  optional  

Same as logo_image_path but for the footer banner. Must not be greater than 1024 characters. Example: brands/acme/footer.png

social_share_image_path   string  optional  

Same as logo_image_path but for the og:image. Must not be greater than 1024 characters. Example: brands/acme/og.png

social_links   string[]  optional  

Must be a valid URL.

tags   string[]  optional  

Must not be greater than 255 characters.

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."
}
 

Request      

DELETE api/v1/brands/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the brand. Example: 16

brand   integer   

The brand id. Example: 12

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

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"
    }
]
 

Request      

GET api/v1/pages

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

site_id   string  optional  

integer|integer[] Optional Site id filter. Example: cumque

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:

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:

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
    }
]
 

Request      

GET api/v1/contact-fields

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

contact_type   string  optional  

Filter by contact type. Must be person or organization. Example: person

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"
    }
]
 

Request      

GET api/v1/contact-fields/types

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

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."
}
 

Request      

POST api/v1/contact-fields

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

name   string   

The display name for the field. Example: Membership Level

tag   string   

The merge tag identifier (lowercase, underscores, unique per team). Example: membership_level

type   string   

The field type. Must be one of: text, email, phone, number, date, datetime, boolean, url, pick_list, related. Example: pick_list

contact_type   string   

The contact type this field belongs to. Must be person or organization. Example: person

max_length   integer  optional  

The maximum length for text fields. Default: 255. Example: 100

options   string[]  optional  

An array of options for pick_list fields.

allow_multiple   boolean  optional  

Whether multiple values can be selected (for pick_list). Default: false. Example: false

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]"
}
 

Request      

GET api/v1/contact-fields/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the contact field. Example: 5

contact_field   integer   

The contact field ID. Example: 1

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."
}
 

Request      

PUT api/v1/contact-fields/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the contact field. Example: 11

contact_field   integer   

The contact field ID. Example: 1

Body Parameters

name   string  optional  

The display name for the field. Example: Full Name

tag   string  optional  

The merge tag identifier (lowercase, underscores, unique per team). Example: full_name

type   string  optional  

The field type. Must be one of: text, email, phone, number, date, datetime, boolean, url, pick_list, related. Example: text

max_length   integer  optional  

The maximum length for text fields. Example: 150

options   string[]  optional  

An array of options for pick_list fields.

allow_multiple   boolean  optional  

Whether multiple values can be selected (for pick_list). Example: true

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."
}
 

Request      

DELETE api/v1/contact-fields/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the contact field. Example: 17

contact_field   integer   

The contact field ID. Example: 1

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

Example Workflow

  1. First, fetch your team's contact fields using GET /api/v1/contact-fields
  2. Use the field tag values as keys when creating/updating contacts
  3. The field type tells 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."
        ]
    }
}
 

Request      

GET api/v1/contacts

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

page   integer  optional  

Page number to return. Must be at least 1. Example: 1

per_page   integer  optional  

Number of records per page (default: 15, max: 100). Example: 15

Body Parameters

page   integer  optional  

Must be at least 1. Example: 17

per_page   integer  optional  

Must be at least 1. Must not be greater than 100. Example: 6

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

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."
        ]
    }
}
 

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]"
}
 

Request      

POST api/v1/contacts/create

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

type   string   

The contact type. Must be either "person" or "organization". Example: person

first_name   string  optional  

The contact's first name. Example: John

last_name   string  optional  

The contact's last name. Example: Doe

email   string  optional  

The contact's email address. Must be a valid email format. Example: john@example.com

phone   string  optional  

The contact's phone number. Will be normalized to E.164 format. Example: +14155552671

birthday   string  optional  

The contact's birthday (date format). Example: 1990-05-15

address   string  optional  

The contact's street address. Example: 123 Main Street

city   string  optional  

The contact's city. Example: San Francisco

state   string  optional  

The contact's state. Example: CA

zipcode   string  optional  

The contact's zip/postal code. Example: 94102

tags   string[]  optional  

An array of tag slugs to assign to the contact. All tags must exist in your team's tag collection.

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]."
}
 

Request      

GET api/v1/contacts/{contact_id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

contact_id   integer   

The ID of the contact. Example: 16

contact   integer   

The contact ID. Example: 1

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"
}
 

Request      

PUT api/v1/contacts/{id}/update

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the contact. Example: 6

contact   integer   

The contact ID. Example: 1

Body Parameters

first_name   string  optional  

The contact's first name. Example: Jane

last_name   string  optional  

The contact's last name. Example: Smith

email   string  optional  

The contact's email address. Must be a valid email format. Example: jane@example.com

phone   string  optional  

The contact's phone number. Will be normalized to E.164 format. Example: +14155552672

birthday   string  optional  

The contact's birthday (date format). Example: 1985-08-22

address   string  optional  

The contact's street address. Example: 456 Oak Avenue

city   string  optional  

The contact's city. Example: Los Angeles

state   string  optional  

The contact's state. Example: CA

zipcode   string  optional  

The contact's zip/postal code. Example: 90210

tags   string[]  optional  

An array of tag slugs to assign to the contact. Pass null or empty array to remove all tags. All tags must exist in your team's tag collection.

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]."
}
 

Request      

DELETE api/v1/contacts/{contact_id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

contact_id   integer   

The ID of the contact. Example: 16

contact   integer   

The contact ID. Example: 1

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

  1. POST /v1/domains/{domain}/short-links — attach a hostname. If hostname is omitted, defaults to l.<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.
  2. Publish the DNS records at your DNS provider.
  3. 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_at is set and the site becomes usable for tracking.
  4. PUT /v1/lists/{list} with tracking_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.

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

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"
    }
]
 

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"
    }
]
 

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."
        ]
    }
}
 

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."
}
 

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": [...]
}
 

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."
}
 

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

  1. POST /v1/domains — attach the apex hostname. This creates the TeamDomain row but does not by itself register the identity with Amazon SES. Pass email_enabled: true to also initiate SES verification (DKIM signing tokens are then returned in the DNS records payload).
  2. POST /v1/domains/{domain}/enable-email — turn on email for the domain later. Initiates SES verification if the domain is not yet verified.
  3. GET /v1/domains/{domain} — poll the domain. The dns_records payload includes every required + recommended DNS record with its current status (Success, Pending, Failed, etc). Publish these records at your DNS provider.
  4. 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.
  5. 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 unless force: true is 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"
    }
]
 

Request      

GET api/v1/domains

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

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."
}
 

Request      

POST api/v1/domains

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

domain   string   

Apex hostname (no scheme, no www, no path). The team must own DNS for this domain. Uniqueness is enforced per-team. Must match the regex /^(?!-)(?:[a-z0-9-]{1,63}.)+[a-z]{2,63}$/i. Must not be greater than 253 characters. Example: acme.example.com

email_enabled   boolean  optional  

When true, SES verification (identity + DKIM) is initiated immediately and DNS records for signing are returned. Defaults to false — you can toggle it on later via POST /v1/domains/{domain}/enable-email. Example: false

mail_from_subdomain   string  optional  

Optional. When provided together with email_enabled: true, configures the given subdomain as the custom SMTP MAIL FROM (envelope sender) — e.g. sjsj.example.com. Omit to leave the MAIL FROM configuration untouched (SES will use its *.amazonses.com default). Must match the regex /^a-z0-9?$/i. Must be at least 1 character. Must not be greater than 63 characters. Example: sj

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]"
}
 

Request      

GET api/v1/domains/{team_domain_id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: at

team_domain   integer   

The team_domain id. Example: 3

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."
}
 

Request      

POST api/v1/domains/{team_domain_id}/enable-email

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: totam

team_domain   integer   

The team_domain id. Example: 3

Body Parameters

mail_from_subdomain   string  optional  

Optional. When provided, configures the given subdomain as the custom SMTP MAIL FROM (envelope sender), e.g. sjsj.example.com. Omit to leave the current MAIL FROM configuration untouched. Must match the regex /^a-z0-9?$/i. Must be at least 1 character. Must not be greater than 63 characters. Example: sj

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."
}
 

Request      

PATCH api/v1/domains/{team_domain_id}/mail-from

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: qui

team_domain   integer   

The team_domain id. Example: 3

Body Parameters

mail_from_subdomain   string  optional  

Subdomain to use as the custom SMTP MAIL FROM (envelope sender), e.g. sjsj.example.com. Pass null to remove the custom MAIL FROM (SES will fall back to the amazonses.com default). Must match the regex /^a-z0-9?$/i. Must be at least 1 character. Must not be greater than 63 characters. Example: sj

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
}
 

Request      

POST api/v1/domains/{team_domain_id}/verify

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: eaque

team_domain   integer   

The team_domain id. Example: 3

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"
            }
        ]
    }
}
 

Request      

POST api/v1/domains/{team_domain_id}/health-check

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: deleniti

team_domain   integer   

The team_domain id. Example: 3

Body Parameters

force   boolean  optional  

When true, bypasses the 1-hour cache and re-runs every DNS lookup. Defaults to false. Example: false

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."
}
 

Request      

DELETE api/v1/domains/{team_domain_id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

team_domain_id   string   

The ID of the team domain. Example: voluptatum

team_domain   integer   

The team_domain id. Example: 3

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:

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"
    }
]
 

Request      

GET api/v1/email-campaigns

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

email_list_id   integer  optional  

Filter by list. Example: 71

type   string  optional  

Filter by campaign type. One of regular, automated, ab_test. Example: automated

status   string  optional  

Filter by campaign status (draft, scheduled, sending, sent, cancelled). Example: sending

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]"
}
 

Request      

GET api/v1/email-campaigns/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the email campaign. Example: 7

email_campaign   integer   

The email campaign id. Example: 42

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:

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

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"
    }
]
 

Request      

GET api/v1/lists

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

brand_id   string  optional  

integer|integer[] Optional brand id filter. Example: eum

exclude_ids   integer[]  optional  

Optional list ids to omit.

fields   string  optional  

Optional comma-separated field list. Example: et

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."
}
 

Request      

POST api/v1/lists

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

name   string   

Customer-facing name of the email list. Shown on preference pages and in the unsubscribe footer. Must not be greater than 255 characters. Example: Weekly Newsletter

slug   string  optional  

URL-safe slug used in hosted signup/preference URLs. Must be unique per team. If omitted, generated from the name. Must match the regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/. Must not be greater than 255 characters. Example: weekly-newsletter

brand_id   integer  optional  

Brand to attach this list to. When set, the brand supplies default logo/colors on hosted pages and the default From address if default_from_address_id is not provided. The id of an existing record in the brands table.

default_from_address_id   integer  optional  

Verified email identifier id (from GET /v1/verified/email) to use as the default From address for messages sent to this list. If null, the brand's email address is used. The id of an existing record in the email_addresses table.

default_friendly_from   string  optional  

Human-friendly From name (e.g. "Acme Newsletter"). If null, the brand name is used. Must not be greater than 255 characters. Example: Acme Newsletter

envelope_team_domain_id   integer  optional  

Team domain id (from GET /v1/domains) used as the SMTP envelope sender for messages on this list. Optional. The id of an existing record in the team_domains table.

tracking_team_domain_site_id   integer  optional  

Team domain site id used to sign tracking/click links for messages on this list. Optional.

require_double_optin   boolean  optional  

When true, new subscribers must click a confirmation link before their subscription becomes active. When false, subscribers are marked active immediately. Example: true

utm_tracking_enabled   boolean  optional  

When true, SallyJo appends UTM parameters (utm_source, utm_medium, utm_campaign) to outbound links in messages on this list. Example: false

redirect_url   string  optional  

Optional URL to redirect subscribers to after they confirm their subscription. If null, they see the hosted "you're subscribed" page. Must be a valid URL. Must not be greater than 2048 characters. Example: https://acme.example.com/welcome

can_spam   string  optional  

CAN-SPAM disclosure text injected into the email footer for messages sent to this list (mailing address, unsubscribe reminder, etc.). Must not be greater than 1000 characters. Example: Acme, Inc. 123 Main St, Anytown USA 00000

create_welcome   boolean  optional  

When true (default), an automated Welcome Series campaign is created with a signup-triggered welcome email baked from the brand template. Set to false to skip. Example: true

tags   string[]  optional  

Must not be greater than 255 characters.

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]"
}
 

Request      

GET api/v1/lists/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the list. Example: 14

list   integer   

The email list id. Example: 1

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."
}
 

Request      

PUT api/v1/lists/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the list. Example: 14

list   integer   

The email list id. Example: 1

Body Parameters

name   string  optional  

Customer-facing name of the list. Must not be greater than 255 characters. Example: Weekly Newsletter

slug   string  optional  

URL-safe slug. Must be unique per team. Pass null to clear. Must match the regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/. Must not be greater than 255 characters. Example: weekly-newsletter

brand_id   integer  optional  

Brand id. Pass null to detach from the current brand. The id of an existing record in the brands table.

default_from_address_id   integer  optional  

Verified email identifier id to use as default From address. Pass null to fall back to the brand. The id of an existing record in the email_addresses table.

default_friendly_from   string  optional  

Human-friendly From name. Pass null to fall back to the brand. Must not be greater than 255 characters. Example: Acme Newsletter

envelope_team_domain_id   integer  optional  

Team domain id for SMTP envelope. Pass null to clear. The id of an existing record in the team_domains table.

tracking_team_domain_site_id   integer  optional  

Team domain site id for signed tracking links. Pass null to clear.

require_double_optin   boolean  optional  

Toggle double opt-in requirement for new subscribers. Example: true

utm_tracking_enabled   boolean  optional  

Toggle automatic UTM parameter appending on outbound links. Example: false

redirect_url   string  optional  

Post-confirmation redirect URL. Pass null to use the hosted confirmation page. Must be a valid URL. Must not be greater than 2048 characters. Example: https://acme.example.com/welcome

can_spam   string  optional  

CAN-SPAM footer text. Pass null to clear. Must not be greater than 1000 characters. Example: Acme, Inc. 123 Main St, Anytown USA 00000

tags   string[]  optional  

Must not be greater than 255 characters.

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."
}
 

Request      

DELETE api/v1/lists/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the list. Example: 18

list   integer   

The email list id. Example: 1

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"
        }
    }
]
 

Request      

GET api/v1/lists/{list_id}/subscribers

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

list_id   integer   

The ID of the list. Example: 15

list   integer   

The email list ID. Example: 1

Query Parameters

limit   integer  optional  

Limit number of records in the search. Example: 19

order_by   string  optional  

Field tag to order the results by. Example: last_name

page   integer  optional  

Page to return results for. Example: 20

email   string  optional  

Filter by exact email address. Example: subscriber@example.com

status   string  optional  

Filter by subscription status (active, inactive, banned, pending_confirmation). Example: active

Create a subscription

requires authentication

Two modes:

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"
}
 

Request      

POST api/v1/lists/{list_id}/subscribers

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

list_id   integer   

The ID of the list. Example: 17

list   integer   

The email list ID. Example: 1

Query Parameters

mode   string  optional  

Example: raw

email   string   

Must be a valid email address. Example: hrodriguez@example.net

contact_id   integer  optional  

Example: 12

send_welcome   boolean  optional  

Example: true

reactivate   boolean  optional  

Example: true

require_double_optin   boolean  optional  

Example: false

status   string  optional  

Example: inactive

opted_in_at   string  optional  

Must be a valid date. Example: 2026-09-02T23:52:04

subscribed_at   string  optional  

Must be a valid date. Must be a date before or equal to now. Example: 1996-12-25

on_conflict   string  optional  

Example: ignore

tracking   object  optional  

Body Parameters

mode   string  optional  

signup|raw (default signup). Example: signup

email   string   

The email address. Example: subscriber@example.com

contact_id   integer  optional  

Optional contact to associate. Example: 123

send_welcome   boolean  optional  

Signup mode — send welcome email. Default true. Example: false

reactivate   boolean  optional  

Signup mode — reactivate inactive rows. Default true. Example: true

require_double_optin   boolean  optional  

null=list default, true forces DOI, false forces single opt-in. Example: true

status   string  optional  

Raw mode — active|inactive|banned|pending_confirmation. Default active. Example: repellendus

opted_in_at   date  optional  

Raw mode — opt-in timestamp. Default now() when status=active. Example: quas

subscribed_at   date  optional  

Raw mode — historical signup timestamp for ESP-sync imports. Backdates the new row's created_at; ignored on existing rows. Must be <= now. Example: dicta

on_conflict   string  optional  

Raw mode — update (default) | ignore | error. Example: repellendus

tracking   object  optional  

Arbitrary attribution metadata (source, campaign, ip, useragent, utm*).

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."
        }
    ]
}
 

Request      

POST api/v1/lists/{list_id}/subscribers/bulk

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

list_id   integer   

The ID of the list. Example: 15

list   integer   

The email list ID. Example: 1

Body Parameters

mode   string  optional  

Batch default mode (rows may override). Example: quidem

defaults   object  optional  

Merged into each row before validation-level defaults kick in.

tracking   object  optional  
rows   string[]   

Up to 200 row objects. Each accepts the single-create shape.

mode   string  optional  

Example: raw

email   string   

Must be a valid email address. Example: felicity04@example.org

contact_id   integer  optional  

Example: 10

send_welcome   boolean  optional  

Example: true

reactivate   boolean  optional  

Example: true

require_double_optin   boolean  optional  

Example: true

status   string  optional  

Example: pending_confirmation

opted_in_at   string  optional  

Must be a valid date. Example: 2026-09-02T23:52:04

subscribed_at   string  optional  

Must be a valid date. Must be a date before or equal to now. Example: 2010-07-01

on_conflict   string  optional  

Example: error

tracking   object  optional  

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"
}
 

Request      

PUT api/v1/subscriptions/{id}/update

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the subscription. Example: 14

subscription   integer   

The subscription ID. Example: 1

Body Parameters

contact_id   integer  optional  

The contact ID to associate with this subscription. Example: 123

status   string  optional  

The subscription status (active, unsubscribed, bounced, complained). Example: active

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."
        ]
    }
}
 

Request      

POST api/v1/subscriptions/{subscription_id}/events

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

subscription_id   integer   

The ID of the subscription. Example: 14

subscription   integer   

The subscription ID. Example: 1

Body Parameters

events   string[]   

Up to 200 events.

type   string   

Example: open

url   string  optional  

Must be a valid URL. Must not be greater than 2048 characters. Example: http://www.mohr.org/quis-dolore-veniam-qui-nihil.html

occurred_at   string  optional  

Must be a valid date. Example: 2026-09-02T23:52:04

user_agent   string  optional  

Must not be greater than 1024 characters. Example: iigq

ip_address   string  optional  

Must be a valid IP address. Example: 243.124.57.85

short_url_id   integer  optional  

Example: 13

sent_uuid   string   

Must not be greater than 36 characters. Example: 525bb52f-71c6-3bd5-a8bb-356f27c5a71a

*   object  optional  
type   string   

open|click|stylesheet_pixel. Example: open

sent_uuid   string   

uuid_id of an {@see \App\Models\Email\Sent} row already attributed to this subscription (created via POST /v1/email/sents). Unknown / foreign uuids cause a 422. Example: 1fd57f65-c484-3bbd-b5ac-e8b477060571

url   string  optional  

Optional target URL (typical for clicks). Example: https://example.com

occurred_at   date  optional  

Optional event timestamp; defaults to now(). Example: 2026-08-03T12:34:56Z

user_agent   string  optional  

Optional client user agent. When supplied, operating_system, operating_system_version, browser, browser_version, and device_type are parsed from it (bots recorded as device_type=robot). Example: magnam

ip_address   string  optional  

Optional client IP address. Persisted on the visit; a background {@see \App\Jobs\LookupIpAddress} job hydrates geolocation into the shared ip_addresses table. Example: sunt

short_url_id   integer  optional  

Optional related short URL row (must belong to the caller's team). Example: 13

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

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."
        ]
    }
}
 

Request      

POST api/v1/email/send

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: multipart/form-data

Accept      

Example: application/json

Body Parameters

to   string   

Recipient email address. Must be a valid, non-banned address. Example: subscriber@example.com

from   string   

Sender email address. Must be a verified identity on the team (see GET /api/v1/verified-identities/emails). Example: hello@acme.com

subject   string  optional  

Subject line. Required when sending raw content (message_id not supplied). Example: Your weekly digest

html   string  optional  

HTML body. Optional if plaintext or message_id is supplied. Example: <h1>Hi!</h1><p>Welcome.</p>

plaintext   string  optional  

Plain-text body. Falls back to a stripped version of html when omitted. Example: ipsam

message_id   integer  optional  

ID of an existing Email\Message on the team. If supplied, subject / html / plaintext are ignored. Example: 174

campaign   string  optional  
merge_data   string  optional  

JSON-encoded object of additional merge-tag values to interpolate into the message. Example: {"first_name":"Alex","promo_code":"WELCOME10"}

cc   string[]  optional  

CC recipient email addresses (each must be a valid, non-banned address).

bcc   string[]  optional  

BCC recipient email addresses (each must be a valid, non-banned address).

attachments   file[]  optional  

File uploads to attach (multipart/form-data only). Stored under messages/{message_id}/ in the team's private SallyJo file library.

campaign_id   integer  optional  

ID of an Email\Campaign to attribute the send to. Enables list-scoped merge tags and reporting. Example: 12

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."
        ]
    }
}
 

Request      

POST api/v1/email/messages/import

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

subject   string   

Subject line for the new creative. Max 255 characters. Example: Weekly Newsletter — Imported

html   string   

Raw HTML source of the creative. Max 5 MB. Example: <html><body>…</body></html>

rules   object[]  optional  

Import-rule payloads to run before conversion. Order-sensitive: each dom-stage rule sees the mutations from earlier rules. See method docblock for shape and links to the reference rulesets.

id   string   

Kebab-case rule identifier. Must match ^[a-z0-9-]+$. Example: strip-tell-a-friend

find   object   

Matcher spec.

type   string   

One of css, xpath, regex, text. Example: xpath

pattern   string   

The selector / expression / substring to match. Example: //tr[.//p[contains(., 'Tell a Friend')]]

scope   string  optional  

For css / xpath, restrict evaluation to body or the whole document. Defaults to body. Example: body

flags   string  optional  

For regex, extra flags (e.g. i, s). Example: i

action   object   

Mutation spec.

type   string   

One of remove, replace, set-attr, remove-attr, set-text, wrap, unwrap, insert-adjacent. Example: remove

with   string  optional   " data-component="body">

Action payload — the replacement HTML (replace), attribute value (set-attr), text (set-text), or markup to inject (insert-adjacent, wrap). Supports templating tokens {{counter}} (1-based match index), {{@attr}} (existing attribute value), and merge-tag chips {{ ns.key | default:"Fallback" }}. Example: <img src="https://placehold.co/650x180/eeeeee/666666?text=Ad+Slot+{{counter}}"/>

attr   string  optional  

Attribute name for set-attr / remove-attr. Example: src

position   string  optional  

For insert-adjacent: before or after (sibling insertion). prepend / append are NOT supported — target the first / last child and use before / after instead. Example: before

stage   string  optional  

Rule stage. Either html (regex-only, runs on raw HTML) or dom (runs after DOM parse; supports css / xpath / regex / text find types). Defaults to dom. Example: dom

enabled   boolean  optional  

Set to false to skip the rule without removing it from the payload. Defaults to true. Example: true

required   boolean  optional  

If true and the rule matches zero elements, the whole import fails with 422. Use for structural landmarks that must exist in every creative. Defaults to false. Example: true

max   integer  optional  

Cap the number of matches this rule may act on. Extra matches are ignored. Example: 1

notes   string  optional  

Free-form notes (not consumed by the engine — helpful for reviewers). Example: Drops the address-book / date row.

data_mappings   object[]  optional  

Content-source bindings for merge-tag chips inserted by rules. Only ONE mapping may set default: true (first one wins). See docs/email-import-data-mappings.md.

name   string   

Display name for the mapping. Example: Featured sites

default   boolean  optional  

Mark as the default mapping used when previewing / sending without an explicit selection. Example: false

dedupe   object  optional  
key   string  optional  

This field is required when data_mappings.*.dedupe is present. Example: url

scope   string  optional  

Example: per_source

bindings   object[]   

At least one binding.

category   string   

Merge-tag namespace (e.g. spotlight, featured). Must match ^[A-Za-z_][A-Za-z0-9_]*$. Example: featured

source   string   

Either content_source (resolve from a Sally Jo content source at send time) or literal (use a static object). Example: content_source

content_source_id   integer  optional  

Required when source=content_source. The content source to pull items from. Example: 3

count   integer  optional  

Number of items to resolve (1..50). Example: 3

sort   string  optional  

One of newest, oldest, random. Example: newest

require_image   boolean  optional  

When true, resolver filters out items that have no rows in content_item_images. Set this for any binding that feeds image slots. Example: true

literal   object  optional  

Required when source=literal. Static object exposed under the namespace.

path   string  optional  

Optional sub-namespace under category, e.g. item_1. Restricted to [A-Za-z0-9_.-]. Example: item_1

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."
        ]
    }
}
 

Request      

POST api/v1/email/messages/import/dry-run

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

subject   string   

Subject line (used only for logging in the dry-run — no creative is created). Max 255 characters. Example: Weekly Newsletter — Imported

html   string   

Raw HTML source of the creative. Max 5 MB. Example: <html><body>…</body></html>

rules   object[]  optional  

Import-rule payloads. Same shape as POST /email/messages/import.

id   string   

Must match the regex /^[a-z0-9-]+$/. Example: a

find   object   
type   string   

Example: xpath

pattern   string   

Example: enim

scope   string  optional  

Example: document

flags   string  optional  

Example: repellat

action   object   
type   string   

Example: insert-adjacent

with   string  optional  

Example: et

attr   string  optional  

Example: et

position   string  optional  

Example: after

stage   string  optional  

Example: dom

enabled   string  optional  
required   string  optional  
max   string  optional  
data_mappings   object[]  optional  

Content-source bindings. Same shape as POST /email/messages/import.

name   string   

Must not be greater than 255 characters. Example: lbjperozcksy

default   boolean  optional  

Example: false

dedupe   object  optional  
key   string  optional  

This field is required when data_mappings.*.dedupe is present. Example: title

scope   string  optional  

Example: across_sources

bindings   object[]   

Must have at least 1 items.

category   string   

Must match the regex /^[A-Za-z][A-Za-z0-9]*$/. Must not be greater than 64 characters. Example: iqwvqhhtbtrwgaqlycdi

source   string   

Example: literal

content_source_id   integer  optional  

This field is required when data_mappings..bindings..source is content_source. Must be at least 1. Example: 32

count   integer  optional  

Must be at least 1. Must not be greater than 50. Example: 25

sort   string  optional  

Example: oldest

require_image   boolean  optional  

Example: true

literal   object  optional  

This field is required when data_mappings..bindings..source is literal.

path   string  optional  

Must match the regex /^[A-Za-z0-9_.-]*$/. Must not be greater than 120 characters. Example: vujdcvjh

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."
        ]
    }
}
 

Request      

POST api/v1/email/messages

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

subject   string   

Subject line. Max 255 characters. Example: Weekly Newsletter — 2026-08-27

html   string   

Raw HTML body. Max 5 MB. Example: <html><body>…</body></html>

plaintext   string  optional  

Optional plaintext alternative. Example: aut

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."
        ]
    }
}
 

Request      

POST api/v1/email/sents

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

email_message_id   integer   

The message this send delivered. Must belong to the caller's team. Example: 174

email_subscription_id   integer   

The subscription this send was addressed to. Must belong to the caller's team. Example: 323346

from   string   

The from-address the send used. Resolved via Address::getClean() so RFC + DNS validation applies. Example: newsletter@example.com

external_id   string   

The upstream ESP's identifier for this send. Uniqueness is scoped per service. Example: ml-msg-abc123

service   string  optional  

The upstream service. Defaults to imported. Example: imported

sent_at   date  optional  

Optional send timestamp; defaults to now(). Example: 2026-08-27T12:00:00Z

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"
        }
    ]
}
 

Request      

POST api/v1/email/sents/bulk

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

defaults   object  optional  

Merged into each row before per-row validation.

from   string  optional  

Default from-address for every row. Example: eum

service   string  optional  

Default upstream service for every row. Example: non

sent_at   string  optional  

Must be a valid date. Example: 2026-09-02T23:52:04

sents   string[]   

Up to 200 row objects. Each accepts the single-create shape.

email_message_id   integer   

Example: 8

email_subscription_id   integer   

Example: 20

from   string  optional  

Must be a valid email address. Example: nrunte@example.org

external_id   string   

Must not be greater than 255 characters. Example: ftnljgvjxahaqdt

service   string  optional  

Example: ses

sent_at   string  optional  

Must be a valid date. Example: 2026-09-02T23:52:04

*   object  optional  
email_message_id   integer   

See single-create. Example: 12

email_subscription_id   integer   

See single-create. Example: 8

from   string   

See single-create (may be supplied via defaults.from). Example: repellat

external_id   string   

See single-create. Example: quo

service   string  optional  

See single-create. Example: iste

sent_at   date  optional  

See single-create. Example: sapiente

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

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"
    }
]
 

Request      

GET api/v1/files

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

prefix   string  optional  

Restrict the listing to files whose path begins with this prefix. Example: brands/logos

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."
}
 

Request      

POST api/v1/files

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: multipart/form-data

Accept      

Example: application/json

Body Parameters

file   file   

The file binary. Multipart upload. Max 10 MB. Must be a file. Must not be greater than 10240 kilobytes. Example: /tmp/phpspk7hsm03cg2cvocCi8

path   string  optional  

Destination path relative to the team's file root. May include subdirectories (e.g. brands/logos/acme.png). If omitted, the file is stored at the root under its uploaded filename. Any existing file at the same path is overwritten. Must not be greater than 1024 characters. Example: brands/logos/acme.png

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."
}
 

Request      

GET api/v1/files/show

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

path   string   

The file path returned by POST /v1/files. Example: brands/logos/acme.png

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."
}
 

Request      

DELETE api/v1/files

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

path   string   

The file path returned by POST /v1/files. Example: brands/logos/acme.png

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"
    }
]
 

Request      

GET api/v1/forms

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

has_email_field   boolean  optional  

Optional; when true only forms with an email field. Example: false

Link management

APIs for managing emails

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."
        ]
    }
}
 

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."
}
 

Request      

POST api/v1/push/subscribe

Headers

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

list_slug   string   

The slug of the push notification list. Example: my-notifications

endpoint   string   

The push subscription endpoint URL. Example: https://fcm.googleapis.com/fcm/send/...

p256dh   string   

The p256dh encryption key. Example: BNVAPKu...

auth   string   

The auth secret. Example: abc123...

expiration_time   integer  optional  

Example: 18

domain   string  optional  

The domain where the subscription was created. Example: example.com

browser   string  optional  

The browser name. Example: Chrome

platform   string  optional  

The platform/OS. Example: Win32

categories   string[]  optional  

Must not be greater than 100 characters.

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."
}
 

Request      

POST api/v1/push/batch-subscribe

Headers

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

prompt_id   string  optional  

The UUID of the prompt that initiated the subscription. Example: 550e8400-e29b-41d4-a716-446655440000

subscriptions   string[]   

Array of subscription objects.

list_slug   string   

The slug of the push notification list. Example: my-notifications

endpoint   string   

The push subscription endpoint URL. Example: https://fcm.googleapis.com/fcm/send/...

p256dh   string   

The p256dh encryption key. Example: BNVAPKu...

auth   string   

The auth secret. Example: abc123...

categories   string[]  optional  

Must not be greater than 100 characters.

domain   string  optional  

The domain where the subscriptions were created. Example: example.com

browser   string  optional  

The browser name. Example: Chrome

platform   string  optional  

The platform/OS. Example: Win32

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."
}
 

Request      

GET api/v1/push/prompt/{prompt_uuid_id}

Headers

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

prompt_uuid_id   string   

The ID of the prompt uuid. Example: nulla

prompt   string   

The UUID of the prompt. Example: 550e8400-e29b-41d4-a716-446655440000

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
}
 

Request      

GET api/v1/sms/lists/{list_id}/subscribers

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

list_id   integer   

The ID of the list. Example: 14

list   integer   

The SMS list ID. Example: 1

Query Parameters

phone_number   string  optional  

Filter subscribers by phone number (partial match). Example: +1555

status   string  optional  

Filter by subscription status. Example: active

per_page   integer  optional  

Number of results per page. Example: 15

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"
        ]
    }
}
 

Request      

POST api/v1/sms/lists/{list_id}/subscribers

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

list_id   integer   

The ID of the list. Example: 15

list   integer   

The SMS list ID. Example: 1

Body Parameters

phone_number   string   

The phone number to subscribe. Example: +15551234567

contact_id   integer  optional  

The contact ID to associate with the subscription. Example: 42

assume_country_code   integer  optional  

Country code to assume if not provided. Defaults to 1 (US). Example: 1

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."
        ]
    }
}
 

Request      

POST api/v1/sms/send

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

to   string   

The recipient phone number in E.164 format. Example: +15551234567

from   string   

The sender phone number. Must be a verified Twilio number for the team. Example: +15559876543

message   string   

The message body text. Example: Hello from our team!

media_urls   string[]  optional  

An array of public image URLs for MMS. Max 10 items. Supported formats: JPEG, PNG, GIF. Max 5MB per image.

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.

Example request:
curl --request GET \
    --get "https://www.sallyjo.com/api/v1/tags?type=brand" \
    --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/tags"
);

const params = {
    "type": "brand",
};
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/tags'
params = {
  'type': 'brand',
}
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/tags',
    [
        'headers' => [
            'Authorization' => 'Bearer {YOUR_API_KEY}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'query' => [
            'type' => 'brand',
        ],
    ]
);
$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/tags?type=brand"),
};
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": "craft",
        "slug": "craft",
        "type": null,
        "order_column": 1
    },
    {
        "id": 13,
        "team_id": 4,
        "name": "recipe",
        "slug": "recipe",
        "type": null,
        "order_column": 2
    }
]
 

Request      

GET api/v1/tags

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

type   string  optional  

Filter to tags of a given type. Example: brand

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.

Example request:
curl --request POST \
    "https://www.sallyjo.com/api/v1/tags" \
    --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\": \"craft\",
    \"slug\": \"craft\",
    \"type\": \"brand\"
}"
const url = new URL(
    "https://www.sallyjo.com/api/v1/tags"
);

const headers = {
    "Authorization": "Bearer {YOUR_API_KEY}",
    "Idempotency-Key": "8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "craft",
    "slug": "craft",
    "type": "brand"
};

fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
import requests
import json

url = 'https://www.sallyjo.com/api/v1/tags'
payload = {
    "name": "craft",
    "slug": "craft",
    "type": "brand"
}
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/tags',
    [
        'headers' => [
            'Authorization' => 'Bearer {YOUR_API_KEY}',
            'Idempotency-Key' => '8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'craft',
            'slug' => 'craft',
            'type' => 'brand',
        ],
    ]
);
$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("craft"), "name");
data.Add(new StringContent("craft"), "slug");
data.Add(new StringContent("brand"), "type");

var request = new HttpRequestMessage
{
    Method = HttpMethod.Post,
    RequestUri = new Uri("https://www.sallyjo.com/api/v1/tags"),
    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": "craft",
    "slug": "craft",
    "type": null
}
 

Example response (422, Duplicate name):


{
    "message": "A tag with that name already exists on this team."
}
 

Request      

POST api/v1/tags

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: 8d3c9c76-3b9a-4a4b-9d7c-2d0c1a3e0f11

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

name   string   

Human-readable tag name. Must be unique within the team (case-insensitive). Must not be greater than 255 characters. Example: craft

slug   string  optional  

Optional URL-safe form of the name. If omitted, the slug is generated from name. Must not be greater than 255 characters. Example: craft

type   string  optional  

Optional bucket that scopes the tag (e.g. brand, contact). Use to keep unrelated tag families from mixing. Must not be greater than 255 characters. Example: brand

Get a tag

requires authentication

Retrieve details for a single tag by id.

Example request:
curl --request GET \
    --get "https://www.sallyjo.com/api/v1/tags/8" \
    --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/tags/8"
);

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/tags/8'
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/tags/8',
    [
        '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/tags/8"),
};
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": "craft",
    "slug": "craft",
    "type": null
}
 

Example response (403, Wrong team):


{
    "message": "This action is unauthorized."
}
 

Request      

GET api/v1/tags/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the tag. Example: 8

tag   integer   

The tag id. Example: 12

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.

Example request:
curl --request PUT \
    "https://www.sallyjo.com/api/v1/tags/15" \
    --header "Authorization: Bearer {YOUR_API_KEY}" \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    --data "{
    \"name\": \"crafts\",
    \"slug\": \"crafts\",
    \"type\": \"brand\"
}"
const url = new URL(
    "https://www.sallyjo.com/api/v1/tags/15"
);

const headers = {
    "Authorization": "Bearer {YOUR_API_KEY}",
    "Content-Type": "application/json",
    "Accept": "application/json",
};

let body = {
    "name": "crafts",
    "slug": "crafts",
    "type": "brand"
};

fetch(url, {
    method: "PUT",
    headers,
    body: JSON.stringify(body),
}).then(response => response.json());
import requests
import json

url = 'https://www.sallyjo.com/api/v1/tags/15'
payload = {
    "name": "crafts",
    "slug": "crafts",
    "type": "brand"
}
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/tags/15',
    [
        'headers' => [
            'Authorization' => 'Bearer {YOUR_API_KEY}',
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
        'json' => [
            'name' => 'crafts',
            'slug' => 'crafts',
            'type' => 'brand',
        ],
    ]
);
$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("crafts"), "name");
data.Add(new StringContent("crafts"), "slug");
data.Add(new StringContent("brand"), "type");

var request = new HttpRequestMessage
{
    Method = HttpMethod.Post,
    RequestUri = new Uri("https://www.sallyjo.com/api/v1/tags/15"),
    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": "crafts",
    "slug": "crafts"
}
 

Request      

PUT api/v1/tags/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the tag. Example: 15

tag   integer   

The tag id. Example: 12

Body Parameters

name   string  optional  

New human-readable name for the tag. Must not be greater than 255 characters. Example: crafts

slug   string  optional  

Override the slug. Omit to re-derive from name. Must not be greater than 255 characters. Example: crafts

type   string  optional  

Change the tag type bucket. Pass null to clear. Must not be greater than 255 characters. Example: brand

Delete a tag

requires authentication

Permanently delete a tag. All resources that were tagged are automatically untagged; the resources themselves are not deleted.

Example request:
curl --request DELETE \
    "https://www.sallyjo.com/api/v1/tags/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/tags/14"
);

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/tags/14'
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/tags/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 data = new MultipartFormDataContent();

var request = new HttpRequestMessage
{
    Method = HttpMethod.Post,
    RequestUri = new Uri("https://www.sallyjo.com/api/v1/tags/14"),
    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": "Tag deleted successfully."
}
 

Request      

DELETE api/v1/tags/{id}

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

URL Parameters

id   integer   

The ID of the tag. Example: 14

tag   integer   

The tag id. Example: 12

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."
}
 

Request      

GET api/v1/team

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

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

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"
        }
    ]
}
 

Request      

GET api/v1/verified-identities

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

Query Parameters

type   string  optional  

Filter by identity type. Must be email, phone, or domain. Example: email

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"
    }
]
 

Request      

GET api/v1/verified-identities/emails

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json

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."
}
 

Request      

POST api/v1/verified-identities/emails

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Idempotency-Key      

Example: f9d2c5be-4d0c-4a8f-9f74-6a9a2e1c8b02

Content-Type      

Example: application/json

Accept      

Example: application/json

Body Parameters

email   string   

Email address to register as a verified sender. If the address's domain is already a verified, email-enabled team_domain, the identity is auto-verified. Otherwise a confirmation email is sent to the address and the identity remains pending until the recipient clicks the confirm link. Must be a valid email address. Must not be greater than 254 characters. Example: newsletters@example.com

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"
    }
]
 

Request      

GET api/v1/verified-identities/phones

Headers

Authorization      

Example: Bearer {YOUR_API_KEY}

Content-Type      

Example: application/json

Accept      

Example: application/json