Skip to main content

API Reference

The BandTools API is designed to be predictable, explicit and safe to use in production. It is organised around REST, with resource-oriented URLs, JSON-encoded request and response bodies, and standard HTTP verbs, authentication and status codes. File uploads use multipart form data.

The API is available on all plans. Some endpoints enforce plan-level gates (for example, scheduling a newsletter requires the Headliner plan), but access to the API itself is not gated. The current version is v1, served under the /api/v1 path prefix.

An OpenAPI 3.0 schema is available for this API. Import it into Postman, Insomnia, Stoplight, openapi-generator, or any other tool that speaks OpenAPI to generate clients, request collections, or mock servers.

1.Quick start

Create an API token from Settings → API, then set two environment variables:

export BANDTOOLS_API_TOKEN="bndt_your_token_here"
export API="https://bandtools.app/api/v1"

List your subscribers:

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" "$API/subscribers?sort=email_asc"

Create a draft newsletter and note the returned id:

curl -X POST "$API/newsletters" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Hello from the API", "message": "<p>This is my first API newsletter.</p>"}'

Send it, replacing $ID with that returned id:

curl -X POST "$API/newsletters/$ID/send" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"

That is all it takes. The rest of this document covers every endpoint in detail.

Top

2.What can you build?

  • Sync subscribers with your website, CRM, or membership platform
  • Send newsletters programmatically from a CMS or publishing workflow
  • Automate campaigns by turning an RSS or Atom feed into automatic newsletters
  • Build a custom dashboard or admin interface on top of the API
  • Integrate with other tools like Zapier, Make, or n8n using signed webhooks

Plan capabilities

Capability Required plan
API access, newsletters, subscribers, page designs, accountAll plans
Automatic newslettersArtist or Headliner
Collaborative editingArtist or Headliner
Subscriber importsArtist or Headliner
Newsletter schedulingArtist or Headliner
ThemesHeadliner
WebhooksHeadliner

Top

3.Example code

Want to build something with the BandTools API? We publish example code on GitHub showing how to authenticate, make requests, handle errors, and work with common resources such as subscribers.

These repositories are provided as practical starting points rather than official SDKs. You can copy, adapt, or build on them for your own project. This API reference remains the source of truth for supported endpoints, request formats, and response formats.

View BandTools on GitHub

Top

4.Overview

Base URL https://bandtools.app/api/v1
Request content type application/json (attachment uploads use multipart/form-data)
Response content type application/json; charset=utf-8
Authentication Bearer token in the Authorization header
Versioning URL-versioned (/api/v1/…); no Accept-header negotiation

All of the examples in this document use two environment variables. Set them once per shell so the snippets can be copied without editing:

export BANDTOOLS_API_TOKEN="bndt_your_token_here"
export API="https://bandtools.app/api/v1"

Top

5.Authentication

Every endpoint requires an Authorization: Bearer <token> header. Missing, malformed, unknown, revoked, or expired tokens are rejected with 401 unauthenticated. Suspended users are rejected with 403 forbidden.

Provisioning a token

Create tokens from the Settings → API tab in the web app. Each token requires:

  • Name: a human-readable label so you can tell tokens apart (4–100 characters)
  • Scope: read for read-only access, or write for full access. Scope is immutable once created
  • Expires in (days): optional; leave blank for a non-expiring token

The raw token is shown exactly once on creation. Capture it immediately. If it is lost, create a new token and delete the old one. Each account can hold a maximum of 10 non-revoked tokens.

Opening Settings → API, and creating, renaming, or deleting a token, all require a recently-authenticated session. You may be prompted to re-enter your password.

Rotation

Standard rotation flow:

  1. Create a new token from Settings → API
  2. Update the client integration to use the new value
  3. Delete the previous token from Settings → API

Multiple live tokens per user are supported, so rotations can happen with zero downtime.

Scopes

Each token carries a single scope:

Scope Allowed verbs Notes
read GET only No side-effecting calls.
write GET, POST, PATCH, DELETE Plus preview, send, send-to-new-subscribers, schedule, cancel_schedule, pin, unpin, duplicate, archive, and unarchive newsletter actions, and rotate-signing-secret on webhooks.

A read token hitting a write endpoint receives 403 forbidden with error.code = “forbidden”.

Top

6.Response format and error codes

Every JSON response, success or failure, has the same top-level structure. Exactly one of data or error is present on a given response.

Success (single resource)

{
  "data": {
    "id": "...",
    "email_address": "user@example.com"
  },
  "meta": { "request_id": "..." }
}

Success (list)

{
  "data": [
    { "id": "...", "email_address": "a@example.com" },
    { "id": "...", "email_address": "b@example.com" }
  ],
  "meta": {
    "request_id": "...",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 412,
      "total_pages": 17,
      "next_page": 2,
      "prev_page": null
    }
  }
}

Error

{
  "error": {
    "code": "validation_failed",
    "message": "Email address is invalid",
    "details": [
      { "field": "email_address", "code": "invalid_format" }
    ]
  },
  "meta": { "request_id": "..." }
}

error.details is optional. When present it is an array of per-field (or per-item) objects describing the specific reasons the request was rejected.

Canonical error codes

error.code HTTP status Meaning
unauthenticated401Missing, invalid, revoked, or expired token.
forbidden403Authenticated but not permitted (suspended user, insufficient scope, plan feature missing).
not_found404Resource not found or not owned by the authenticated user.
conflict409Resource state incompatible with the request (e.g. editing a sent newsletter).
payload_too_large413Request body, an uploaded file, or an individual JSON field exceeds its byte cap.
unsupported_media_type415Uploaded file’s actual content does not match an accepted type. The file contents are inspected regardless of the declared Content-Type.
validation_failed422Input or field-level error: a value was missing, malformed, or out of range.
unprocessable422Business-logic precondition failure: the request was valid but cannot be carried out in the current state (e.g. sending a newsletter with no subscribers).
rate_limited429Rate limit exceeded. See the X-RateLimit-* headers.
internal_error500Unexpected server error.

Both validation_failed and unprocessable return 422, but they signal different problems. validation_failed means the request body itself was invalid (missing required fields, bad formats, out-of-range values) and includes a details array pointing at the offending fields. unprocessable means the input was well-formed but a business rule prevented the action (e.g. the account has reached its subscriber limit, or the newsletter has already been sent).

Size limits

Requests that exceed a size limit are rejected with 413 payload_too_large.

LimitMaximum sizeNotes
Request body 25 MiB Applies to every API request.
JSON fields 2 MiB (newsletter message) · 256 KiB (confirmation email message; page-design rich-text fields) · 256 bytes (each entry in an email_addresses import array) The response includes details: [{"field": "…", "code": "too_large", "max_bytes": …}].
File uploads 5 MiB (account picture) · 10 MiB (page-design background image) · 20 MiB (newsletter attachments, subscriber CSV imports) Also documented per endpoint in the relevant section.

Upload content types

Multipart uploads (newsletter attachments, account pictures, page-design background images, subscriber CSV imports) are validated against each endpoint’s allow-list. A file whose actual contents do not match the declared Content-Type is rejected with 415 unsupported_media_type; error.details reports both the declared and the detected types so you can correct the request.

Top

7.Rate limiting

Limits apply per API token, per account, and per source IP address. Any of them tripping returns 429 rate_limited. A baseline limit applies to each resource group (for example, /api/v1/subscribers is counted separately from /api/v1/newsletters); some expensive endpoints, such as sending a newsletter, importing subscribers, or uploading attachments, have tighter limits layered on top.

Every response carries three headers describing the per-token baseline limit for the resource being accessed:

Header Meaning
X-RateLimit-LimitMaximum requests allowed in the current window.
X-RateLimit-RemainingRequests remaining in the current window.
X-RateLimit-ResetUnix timestamp (seconds since the epoch) at which the current window resets.

Hitting any limit returns HTTP 429 with error.code = “rate_limited”. Retry after the time indicated by X-RateLimit-Reset or the Retry-After header, which is set on 429 responses.

Top

8.Pagination

List endpoints accept two query parameters:

  • page: 1-indexed page number. Defaults to 1. Requesting an out-of-range page returns 422 validation_failed
  • per_page: number of items per page. Defaults to 25, capped at 100

Each list response includes both meta.pagination (see Response format) and RFC 5988 Link headers:

Link: <https://bandtools.app/api/v1/subscribers?page=2&per_page=25>; rel="next",
      <https://bandtools.app/api/v1/subscribers?page=17&per_page=25>; rel="last"

Top

9.Standard response headers

Every API response includes:

Header Value Purpose
Content-Typeapplication/json; charset=utf-8Every response body is JSON.
Cache-Controlno-storeAPI bodies are per-token; never cache them in any intermediary.
VaryAuthorizationEnsures shared caches distinguish responses by token.
X-Request-Id<uuid>Matches the meta.request_id in the response body. Include this when reporting problems.

List endpoints additionally emit:

  • Link: RFC 5988 pagination links (first, prev, next, last)
  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset: rate-limit accounting (see Rate limiting)

Top

10.Sorting and filtering

Newsletters

status query parameter (required on GET /newsletters). One of draft, sent, or scheduled.

sort query parameter. One of:

  • subject_asc, subject_desc
  • created_asc, created_desc
  • sent_asc, sent_desc (only meaningful when status=sent)
  • scheduled_asc, scheduled_desc (only meaningful when status=scheduled)

Defaults: status=sentsent_desc; status=scheduledscheduled_asc; status=draftcreated_desc.

Shared newsletters

sort query parameter. One of:

Value Meaning
updated_recent (default)Recently updated first
updated_oldestOldest updated first
subject_ascSubject A → Z
subject_descSubject Z → A

Collaborators

sort query parameter. One of:

Value Meaning
name_asc (default)Collaborator name A → Z (pending shares sort last)
name_descCollaborator name Z → A (pending shares sort last)
email_ascInvited email address A → Z
email_descInvited email address Z → A

Automatic newsletters

sort query parameter. One of:

Value Meaning
name_ascName A → Z
name_descName Z → A
created_ascOldest first
created_desc (default)Newest first

Subscribers

sort query parameter. One of:

Value Meaning
email_asc (default)Email address A → Z
email_descEmail address Z → A
subscribed_recentMost recently subscribed first
subscribed_oldestOldest subscription first

Optional filter query parameter: all (default), confirmed, or unconfirmed.

Themes

sort: name_asc (default), name_desc, created_asc, created_desc. filter: all (default: user + system), user, or system. Invalid values fall back to all.

Webhooks

sort query parameter. One of:

Value Meaning
name_ascName A → Z
name_descName Z → A
created_ascOldest first
created_desc (default)Newest first

Top

11.Newsletters

A newsletter’s ID is its slug. Most snippets below refer to that as $NEWSLETTER_SLUG. See Sorting and filtering for the required status parameter and the available sort values.

11.1 List newsletters

GET /api/v1/newsletters

Returns a paginated list of newsletters filtered by status. The status query parameter is required. One of draft, sent, or scheduled.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/newsletters?status=draft&sort=created_desc"
import os, requests

headers = {"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"}

response = requests.get(
    f"{os.environ['API']}/newsletters",
    headers=headers,
    params={"status": "draft", "sort": "created_desc"},
)
response.raise_for_status()
print(response.json()["data"])
const headers = { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` };

const url = new URL(`${process.env.API}/newsletters`);
url.search = new URLSearchParams({ status: "draft", sort: "created_desc" });

const response = await fetch(url, { headers });
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

const { data } = await response.json();
console.log(data);
req, _ := http.NewRequest("GET", os.Getenv("API")+"/newsletters?status=draft&sort=created_desc", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}

body, _ := io.ReadAll(response.Body)
response.Body.Close()
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters")
uri.query = URI.encode_www_form(status: "draft", sort: "created_desc")

request = Net::HTTP::Get.new(uri, { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" })
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body).fetch("data")
HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters?status=draft&sort=created_desc"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters?status=draft&sort=created_desc");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response . "\n";

Top

11.2 Get a newsletter

GET /api/v1/newsletters/:slug

Returns a single newsletter by slug. The response includes the rendered html. For drafts this is a live preview; for sent newsletters it is the content as it was sent. The message field holds the source HTML with attachments expressed as neutral <attachment id="att_…"> elements.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/newsletters/$NEWSLETTER_SLUG"
import os, requests

response = requests.get(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}`,
  { headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG"));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

11.3 Create a draft newsletter

POST /api/v1/newsletters

Creates a new draft newsletter. subject must be unique within the account and between 5 and 100 characters; message must be present and at most 2 MiB. Validation errors return 422 validation_failed with per-field details; an oversize message returns 413 payload_too_large. To include images or audio, upload the file first (see Attachments and inline content) and then reference the returned ID inside an <attachment> element in the message.

Text only

curl -X POST "$API/newsletters" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Spring tour dates",
    "message": "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
    "reply_to_enabled": true,
    "public": false
  }'
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={
        "subject": "Spring tour dates",
        "message": "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
        "reply_to_enabled": True,
        "public": False,
    },
)
response.raise_for_status()
print(response.json()["data"]["id"])
const response = await fetch(`${process.env.API}/newsletters`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    subject:          "Spring tour dates",
    message:          "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
    reply_to_enabled: true,
    public:           false
  })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log((await response.json()).data.id);
payload, _ := json.Marshal(map[string]any{
    "subject":          "Spring tour dates",
    "message":          "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
    "reply_to_enabled": true,
    "public":           false,
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  subject:          "Spring tour dates",
  message:          "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
  reply_to_enabled: true,
  public:           false
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body).dig("data", "id")
String body = """
    {
      "subject": "Spring tour dates",
      "message": "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
      "reply_to_enabled": true,
      "public": false
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "subject"          => "Spring tour dates",
    "message"          => "<p>We are hitting the road in April. Tickets on sale Friday.</p>",
    "reply_to_enabled" => true,
    "public"           => false,
]);

$ch = curl_init(getenv("API") . "/newsletters");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

With an inline image or audio attachment

Upload the file first, then reference the returned ID inside an <attachment> element. The server verifies that the attachment was uploaded by the authenticated user; a foreign or unknown ID yields 422 validation_failed with details: [{"field": "message", "code": "invalid_attachment", "id": "att_…"}].

# Upload the image
curl -X POST "$API/newsletters/attachments" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -F "file=@cover.jpg;type=image/jpeg"

export ATTACHMENT_ID="att_BAhmNF..."

# Create the draft newsletter referencing the attachment
curl -X POST "$API/newsletters" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "subject": "New single artwork",
  "message": "<p>Here is the cover art:</p><attachment id=\"$ATTACHMENT_ID\"></attachment><p>More details soon.</p>"
}
JSON
import os, requests

headers = {"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"}

with open("cover.jpg", "rb") as f:
    upload = requests.post(
        f"{os.environ['API']}/newsletters/attachments",
        headers=headers,
        files={"file": ("cover.jpg", f, "image/jpeg")},
    )
upload.raise_for_status()
attachment_id = upload.json()["data"]["id"]

response = requests.post(
    f"{os.environ['API']}/newsletters",
    headers=headers,
    json={
        "subject": "New single artwork",
        "message": (
            "<p>Here is the cover art:</p>"
            f'<attachment id="{attachment_id}"></attachment>'
            "<p>More details soon.</p>"
        ),
    },
)
response.raise_for_status()
import { readFileSync } from "node:fs";

const headers = { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` };

const upload = new FormData();
upload.append("file", new Blob([readFileSync("cover.jpg")], { type: "image/jpeg" }), "cover.jpg");

const uploadResponse = await fetch(`${process.env.API}/newsletters/attachments`, {
  method: "POST", headers, body: upload
});
const { data: { id: attachmentId } } = await uploadResponse.json();

await fetch(`${process.env.API}/newsletters`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    subject: "New single artwork",
    message: `<p>Here is the cover art:</p><attachment id="${attachmentId}"></attachment><p>More details soon.</p>`
  })
});
var uploadBody bytes.Buffer
writer := multipart.NewWriter(&uploadBody)

part, _ := writer.CreatePart(textproto.MIMEHeader{
    "Content-Disposition": []string{`form-data; name="file"; filename="cover.jpg"`},
    "Content-Type":        []string{"image/jpeg"},
})
file, _ := os.Open("cover.jpg")
io.Copy(part, file)
file.Close()
writer.Close()

uploadReq, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/attachments", &uploadBody)
uploadReq.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())

uploadResp, _ := http.DefaultClient.Do(uploadReq)
var uploaded struct {
    Data struct{ ID string } `json:"data"`
}
json.NewDecoder(uploadResp.Body).Decode(&uploaded)
uploadResp.Body.Close()

payload, _ := json.Marshal(map[string]any{
    "subject": "New single artwork",
    "message": fmt.Sprintf(`<p>Here is the cover art:</p><attachment id="%s"></attachment><p>More details soon.</p>`, uploaded.Data.ID),
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, _ := http.DefaultClient.Do(req)
response.Body.Close()
require "net/http"
require "json"

headers = { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" }

upload_uri = URI("#{ENV.fetch("API")}/newsletters/attachments")
upload_request = Net::HTTP::Post.new(upload_uri, headers)
File.open("cover.jpg") do |file|
  upload_request.set_form([ [ "file", file, { filename: "cover.jpg", content_type: "image/jpeg" } ] ], "multipart/form-data")
end

upload_response = Net::HTTP.start(upload_uri.hostname, upload_uri.port, use_ssl: true) { |http| http.request(upload_request) }
attachment_id = JSON.parse(upload_response.body).dig("data", "id")

uri = URI("#{ENV.fetch("API")}/newsletters")
request = Net::HTTP::Post.new(uri, headers.merge("Content-Type" => "application/json"))
request.body = {
  subject: "New single artwork",
  message: %(<p>Here is the cover art:</p><attachment id="#{attachment_id}"></attachment><p>More details soon.</p>)
}.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpClient client = HttpClient.newHttpClient();
String token = "Bearer " + System.getenv("BANDTOOLS_API_TOKEN");

String boundary = "----BandToolsBoundary" + System.currentTimeMillis();
byte[] fileBytes = Files.readAllBytes(Path.of("cover.jpg"));

ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.writeBytes(("--" + boundary + "\r\n").getBytes());
buffer.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\r\n".getBytes());
buffer.writeBytes("Content-Type: image/jpeg\r\n\r\n".getBytes());
buffer.writeBytes(fileBytes);
buffer.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());

HttpRequest uploadRequest = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/attachments"))
    .header("Authorization", token)
    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
    .POST(HttpRequest.BodyPublishers.ofByteArray(buffer.toByteArray()))
    .build();

HttpResponse<String> uploadResponse = client.send(uploadRequest, HttpResponse.BodyHandlers.ofString());
String attachmentId = uploadResponse.body().replaceAll(".*\"id\":\"([^\"]+)\".*", "$1");

String body = String.format(
    "{\"subject\":\"New single artwork\",\"message\":\"<p>Here is the cover art:</p><attachment id=\\\"%s\\\"></attachment><p>More details soon.</p>\"}",
    attachmentId);

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters"))
    .header("Authorization", token)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

client.send(request, HttpResponse.BodyHandlers.discarding());
$token = "Bearer " . getenv("BANDTOOLS_API_TOKEN");

$ch = curl_init(getenv("API") . "/newsletters/attachments");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "file" => new CURLFile("cover.jpg", "image/jpeg", "cover.jpg"),
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: " . $token]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$uploadResponse = curl_exec($ch);
curl_close($ch);
$attachmentId = json_decode($uploadResponse, true)["data"]["id"];

$payload = json_encode([
    "subject" => "New single artwork",
    "message" => "<p>Here is the cover art:</p><attachment id=\"$attachmentId\"></attachment><p>More details soon.</p>",
]);

$ch = curl_init(getenv("API") . "/newsletters");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: " . $token,
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);
<attachment> is the only attachment element the API accepts or emits.

Top

11.4 Update a draft newsletter

PATCH /api/v1/newsletters/:slug

Updates an existing draft newsletter. PATCH semantics: fields you omit are left unchanged. Supplying message fully replaces the stored HTML (not merged). scheduled_for is not updatable via this endpoint. Use the schedule / cancel-schedule endpoints below. Sent newsletters are immutable: updating one returns 409 conflict.

curl -X PATCH "$API/newsletters/$NEWSLETTER_SLUG" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Updated subject","reply_to_enabled":true}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"subject": "Updated subject", "reply_to_enabled": True},
)
response.raise_for_status()
await fetch(`${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ subject: "Updated subject", reply_to_enabled: true })
});
payload, _ := json.Marshal(map[string]any{
    "subject":          "Updated subject",
    "reply_to_enabled": true,
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG"), bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { subject: "Updated subject", reply_to_enabled: true }.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = "{\"subject\":\"Updated subject\",\"reply_to_enabled\":true}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode([
    "subject"          => "Updated subject",
    "reply_to_enabled" => true,
]);

$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.5 Delete a draft newsletter

DELETE /api/v1/newsletters/:slug

Permanently deletes a draft newsletter. Sent newsletters cannot be deleted (409 conflict).

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(`${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.6 Send a preview

POST /api/v1/newsletters/:slug/preview

Sends a preview to the authenticated user’s email address and returns 202 Accepted. Requires the user to have a verified email address; otherwise 403 forbidden.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/preview" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/preview",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/preview`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/preview", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/preview")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/preview"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/preview");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.7 Send a newsletter to subscribers

Dispatches a newsletter.sent webhook.
POST /api/v1/newsletters/:slug/send

Sends the newsletter to all confirmed subscribers. A preflight check verifies the account is eligible to send (subscriber count within plan limits, payment status, verified email address, etc.). A preflight failure returns 422 unprocessable; on success the send is accepted and the response is 202 Accepted.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/send" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/send",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/send`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/send", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/send")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/send"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/send");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.8 Send to new subscribers

Dispatches a newsletter.sent webhook.
POST /api/v1/newsletters/:slug/send-to-new-subscribers

Sends a previously-sent newsletter to subscribers who joined after the original send. Returns 409 conflict if the newsletter has not been sent yet, or 422 unprocessable if no new subscribers are found or the account has a failed payment.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/send-to-new-subscribers" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/send-to-new-subscribers",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/send-to-new-subscribers`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
console.log(await response.json());
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/send-to-new-subscribers", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/send-to-new-subscribers")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/send-to-new-subscribers"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/send-to-new-subscribers");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.9 Schedule a newsletter

Requires the Artist or Headliner plan. Callers on lower plans receive 403 plan_feature_required.
Dispatches a newsletter.scheduled webhook.
POST /api/v1/newsletters/:slug/schedule

Schedules the newsletter for future delivery. scheduled_for must be an ISO 8601 timestamp in the future, within 32 days, and on a whole hour.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/schedule" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_for":"2026-05-01T14:00:00Z"}'
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/schedule",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"scheduled_for": "2026-05-01T14:00:00Z"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/schedule`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ scheduled_for: "2026-05-01T14:00:00Z" })
  }
);
payload, _ := json.Marshal(map[string]any{
    "scheduled_for": "2026-05-01T14:00:00Z",
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/schedule", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/schedule")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { scheduled_for: "2026-05-01T14:00:00Z" }.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = "{\"scheduled_for\":\"2026-05-01T14:00:00Z\"}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/schedule"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode(["scheduled_for" => "2026-05-01T14:00:00Z"]);

$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/schedule");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.10 Cancel a scheduled newsletter

DELETE /api/v1/newsletters/:slug/schedule

Cancels a previously scheduled newsletter and returns it to draft. Clears scheduled_for and returns the newsletter to a plain-draft state. Idempotent: calling when no schedule is set returns 200 with the draft unchanged. Sent newsletters cannot have a schedule cancelled (409 conflict).

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG/schedule" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/schedule",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/schedule`,
  { method: "DELETE", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/schedule", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/schedule")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/schedule"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/schedule");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.11 Pin a newsletter

Requires the Headliner plan. Callers on lower plans receive 403 plan_feature_required.
POST /api/v1/newsletters/:slug/pin

Pins the newsletter to the top of your public newsletter archive. The newsletter must be sent and public: true; otherwise the request returns 409 conflict with error.code = “not_in_archive”. At most one newsletter can be pinned per account — pinning a different newsletter automatically unpins the previous one. Idempotent: re-pinning an already-pinned newsletter returns 200 without changing state.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/pin" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/pin",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/pin`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/pin", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/pin")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/pin"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/pin");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.12 Unpin a newsletter

Requires the Headliner plan. Callers on lower plans receive 403 plan_feature_required.
DELETE /api/v1/newsletters/:slug/pin

Unpins the newsletter from the top of your public archive. Idempotent: returns 200 even when the newsletter is not currently pinned.

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG/pin" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/pin",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/pin`,
  { method: "DELETE", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/pin", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/pin")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/pin"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/pin");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

11.13 Duplicate a newsletter

Requires the Artist or Headliner plan. Callers on lower plans receive 403 plan_feature_required.
POST /api/v1/newsletters/:slug/duplicate

Creates a new draft newsletter by duplicating the specified newsletter. The copy has its subject appended with “copy” (or “copy N” if duplicates already exist), and is reset to draft status with archive visibility and pinned status cleared.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/duplicate" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/duplicate",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/duplicate`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
const data = await response.json();
console.log(data);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/duplicate", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/duplicate")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/duplicate"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/duplicate");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN")]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

echo curl_exec($ch);
curl_close($ch);

Top

11.14 Add to archive

Requires the Headliner plan. Callers on lower plans receive 403 plan_feature_required.
POST /api/v1/newsletters/:slug/archive

Adds a sent newsletter to the public archive. Returns 409 conflict if the newsletter has not been sent yet. Idempotent: returns 200 even when the newsletter is already in the archive.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/archive" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/archive",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/archive`,
  { method: "POST", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
const data = await response.json();
console.log(data);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/archive", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/archive")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/archive"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/archive");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN")]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

echo curl_exec($ch);
curl_close($ch);

Top

11.15 Remove from archive

Requires the Headliner plan. Callers on lower plans receive 403 plan_feature_required.
DELETE /api/v1/newsletters/:slug/archive

Removes a sent newsletter from the public archive and clears its pinned status. Returns 409 conflict if the newsletter has not been sent yet. Idempotent: returns 200 even when the newsletter is already private.

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG/archive" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/newsletters/{os.environ['NEWSLETTER_SLUG']}/archive",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/newsletters/${process.env.NEWSLETTER_SLUG}/archive`,
  { method: "DELETE", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
const data = await response.json();
console.log(data);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/newsletters/"+os.Getenv("NEWSLETTER_SLUG")+"/archive", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/newsletters/#{ENV.fetch("NEWSLETTER_SLUG")}/archive")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/" + System.getenv("NEWSLETTER_SLUG") + "/archive"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters/" . getenv("NEWSLETTER_SLUG") . "/archive");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN")]);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

echo curl_exec($ch);
curl_close($ch);

Top

12.Collaborative editing

Newsletter owners on the Artist or Headliner plan can invite other BandTools users to collaborate on editing a draft newsletter. Collaborators can read and edit the draft’s subject and message, and send themselves a preview, but only the owner can send, schedule, or delete the newsletter.

The API exposes three sub-resources: collaborators (managing invitations), shared newsletters (listing drafts shared with you), and locks (coordinating concurrent editing).

Plan gating

POST and DELETE on the collaborators resource require the Artist or Headliner plan. Callers on lower plans receive 403 plan_feature_required. Listing collaborators (GET) is available to any owner.

Authorisation

ActionOwnerCollaboratorOther user
View newsletter (GET)YesYes404
Edit subject & message (PATCH)YesYes404
Edit public/reply_to_enabled (PATCH)Yes403404
Send previewYesYes (to their own address)404
Send / schedule / deleteYes404404
Manage collaboratorsYes403404

Optimistic locking

Newsletter responses include a lock_version integer. To protect against lost updates when two users edit simultaneously, pass the lock_version you received in a previous GET when calling PATCH. If the newsletter has been saved by someone else since your read, the server returns 409 conflict.

Collaborator fields in newsletter responses

When the request is authenticated, single-newsletter responses (GET, PATCH, POST) include additional fields:

FieldTypeNotes
lock_versionintegerPass this back on PATCH for optimistic conflict detection.
rolestring"owner" or "collaborator".
collaborators_countintegerOwner only. Number of pending + accepted collaborators.
lockobject or nullPresent on show. { "holder": { "username": "...", "name": "..." }, "locked_until": "..." } when a lock is active; null otherwise.

12.1 List collaborators

GET /api/v1/newsletters/:slug/collaborators

Lists all collaborators (pending and accepted) on a newsletter. Owner-only. Use the sort parameter to control sorting (see Sorting and filtering).

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/newsletters/$NEWSLETTER_SLUG/collaborators?sort=name_asc"

Response:

{
  "data": [
    {
      "id": 1,
      "email_address": "collaborator@example.com",
      "status": "accepted",
      "name": "Jane Doe",
      "invited_at": "2026-05-01T12:00:00Z",
      "accepted_at": "2026-05-01T14:00:00Z",
      "last_edited_at": "2026-05-02T09:30:00Z"
    },
    {
      "id": 2,
      "email_address": "pending@example.com",
      "status": "pending",
      "name": null,
      "invited_at": "2026-05-03T10:00:00Z",
      "accepted_at": null,
      "last_edited_at": null
    }
  ],
  "meta": { "request_id": "..." }
}

Top

12.2 Invite a collaborator

Requires the Artist or Headliner plan. Callers on lower plans receive 403 plan_feature_required.
POST /api/v1/newsletters/:slug/collaborators

Invites a user by email address. An invitation email is sent automatically.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/collaborators" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email_address": "collaborator@example.com"}'

Returns 201 Created with the new collaborator object. Errors:

  • 409 conflict with collaborator_limit_reached when the newsletter already has 10 collaborators
  • 409 conflict when the newsletter has already been sent
  • 422 validation_failed when the email address is invalid, already invited, or is the owner’s own address
  • 403 plan_feature_required when the plan does not include collaborative editing

Top

12.3 Revoke a collaborator

DELETE /api/v1/newsletters/:slug/collaborators/:share_id

Revokes a collaborator’s access. Works for both pending invitations and accepted collaborators.

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG/collaborators/1" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"

Returns 204 No Content.

Top

12.4 Shared newsletters

GET /api/v1/shared-newsletters

Lists draft newsletters that other users have shared with you (accepted invitations only). Use the sort parameter to control sorting (see Sorting and filtering).

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/shared-newsletters?sort=updated_recent"

Each newsletter in the response includes an owner object with the newsletter owner’s username and name, plus role: "collaborator".

sort query parameter: updated_recent (default), updated_oldest, subject_asc, subject_desc.

Standard page and per_page pagination applies.

Top

12.5 Acquire the editing lock

POST /api/v1/newsletters/:slug/lock

Acquires a five-minute editing lease on the newsletter. Both the owner and accepted collaborators can acquire the lock. Optional — most API integrations can rely on lock_version for conflict detection instead.

curl -X POST "$API/newsletters/$NEWSLETTER_SLUG/lock" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"

Returns 200 with { "data": { "acquired": true, "lock_holder": "...", "locked_until": "..." } } on success. Returns 409 conflict when another user currently holds the lock.

Top

12.6 Refresh the editing lock

PATCH /api/v1/newsletters/:slug/lock/heartbeat

Extends the editing lease by another five minutes. Must be called by the current lock holder.

curl -X PATCH "$API/newsletters/$NEWSLETTER_SLUG/lock/heartbeat" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"

Returns 409 conflict if you do not hold the lock.

Top

12.7 Release the editing lock

DELETE /api/v1/newsletters/:slug/lock

Releases the editing lease. Safe to call even if you do not hold the lock (no-op in that case).

curl -X DELETE "$API/newsletters/$NEWSLETTER_SLUG/lock" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"

Returns 204 No Content.

Top

13.Attachments and inline content

Attachments live under the newsletters resource at POST /api/v1/newsletters/attachments. Uploads are multipart (the only non-JSON endpoint in the API) and accept a single file under the file part. Requires write scope.

Upload an attachment

POST /api/v1/newsletters/attachments

Uploads an image or audio file and returns an attachment ID for use in newsletter content.

curl -X POST "$API/newsletters/attachments" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -F "file=@cover.jpg;type=image/jpeg"
import os, requests

with open("cover.jpg", "rb") as f:
    response = requests.post(
        f"{os.environ['API']}/newsletters/attachments",
        headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
        files={"file": ("cover.jpg", f, "image/jpeg")},
    )
response.raise_for_status()
print(response.json()["data"]["id"])
import { readFileSync } from "node:fs";

const body = new FormData();
body.append("file", new Blob([readFileSync("cover.jpg")], { type: "image/jpeg" }), "cover.jpg");

const response = await fetch(`${process.env.API}/newsletters/attachments`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` },
  body
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log((await response.json()).data.id);
var body bytes.Buffer
writer := multipart.NewWriter(&body)

part, _ := writer.CreatePart(textproto.MIMEHeader{
    "Content-Disposition": []string{`form-data; name="file"; filename="cover.jpg"`},
    "Content-Type":        []string{"image/jpeg"},
})
file, _ := os.Open("cover.jpg")
defer file.Close()
io.Copy(part, file)
writer.Close()

req, _ := http.NewRequest("POST", os.Getenv("API")+"/newsletters/attachments", &body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", writer.FormDataContentType())

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

data, _ := io.ReadAll(response.Body)
fmt.Println(string(data))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/newsletters/attachments")
request = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" })
File.open("cover.jpg") do |file|
  request.set_form([ [ "file", file, { filename: "cover.jpg", content_type: "image/jpeg" } ] ], "multipart/form-data")
end

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body).dig("data", "id")
String boundary = "----BandToolsBoundary" + System.currentTimeMillis();
byte[] fileBytes = Files.readAllBytes(Path.of("cover.jpg"));

ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.writeBytes(("--" + boundary + "\r\n").getBytes());
buffer.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"cover.jpg\"\r\n".getBytes());
buffer.writeBytes("Content-Type: image/jpeg\r\n\r\n".getBytes());
buffer.writeBytes(fileBytes);
buffer.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/newsletters/attachments"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
    .POST(HttpRequest.BodyPublishers.ofByteArray(buffer.toByteArray()))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/newsletters/attachments");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "file" => new CURLFile("cover.jpg", "image/jpeg", "cover.jpg"),
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
  "data": {
    "id": "att_BAhmNF...",
    "content_type": "image/jpeg",
    "byte_size": 142112,
    "filename": "cover.jpg"
  },
  "meta": { "request_id": "..." }
}

Validation rules

RuleFailure
Declared Content-Type must be one of application/pdf, audio/mp4, audio/mpeg, image/gif, image/jpeg, image/png, image/webp, video/mp4, video/mpeg 415 unsupported_media_type
The file’s actual contents must match the declared Content-Type (e.g. a JPEG declared as image/png is rejected). 415 unsupported_media_type
Byte size ≤ 20 MiB413 payload_too_large
file part present422 validation_failed
Requires write scope403 forbidden

The returned id is opaque. Treat it as a token to be passed back into a newsletter message body inside an <attachment id="…"> element. Attachments are scoped to the uploading user: referencing another user’s ID returns 422 validation_failed. Unreferenced uploads are cleaned up automatically after 24 hours.

Referencing attachments in a newsletter

Use a <attachment> element in message bodies on both reads and writes:

<p>Here is the artwork:</p>
<attachment id="att_BAhmNF..."></attachment>
<p>And a preview track:</p>
<attachment id="att_BAhmNF..."></attachment>

On POST/PATCH /newsletters, unowned, unknown, or malformed ids produce 422 validation_failed. On GET /newsletters/:slug, attachments are returned using the same <attachment id="att_…"> form.

Top

14.Automatic newsletters

Automatic newsletters poll an RSS or Atom feed and turn each new entry into a draft or sent newsletter on a schedule you pick.

Every action on /api/v1/automatic-newsletters requires the Artist or Headliner plan. Callers on the Solo plan receive 403 plan_feature_required.

Each automatic newsletter is identified by a slug derived from its name (e.g. "Indie news"indie-news). The slug is read-only; rename the resource and a new slug takes effect immediately. Most snippets below refer to it as $AUTO_SLUG.

Resource fields

Field Type Notes
id / slugstringRead-only. Path identifier.
namestring4–100 characters. Unique per account. Used to derive the slug.
feed_urlstringHTTP or HTTPS URL of an RSS or Atom feed. Unique per account.
behaviourenum"draft" (default) saves new entries as drafts; "auto_send" sends them automatically.
frequencyenum"immediate", "daily" (default), or "weekly".
use_post_title_as_subjectbooleanUse the entry's title as the newsletter subject. Defaults to true.
include_featured_imagebooleanInclude the entry's featured image at the top of the newsletter. Defaults to true.
publicbooleanWhether produced newsletters are visible in the public archive.
reply_to_enabledbooleanWhether produced newsletters allow direct replies.
pausedbooleanRead-only. Toggled via pause / resume.
consecutive_failuresintegerRead-only. Reset to 0 on resume.
disabled_by_failuresbooleanRead-only. true when consecutive failures auto-paused the feed.
feed_title / site_urlstringRead-only. Cached from the feed on create / when feed_url changes.
last_entry_title / last_entry_urlstringRead-only. Cached metadata for the most recent entry seen.
last_checked_atiso8601Read-only. When BandTools last polled the feed.
last_newsletter_created_atiso8601Read-only. When BandTools last produced a newsletter from this feed.
created_at / updated_atiso8601Read-only.

Each account is capped at 10 automatic newsletters. Hitting the cap returns 422 validation_failed with error.details[].code = "limit_reached".

14.1 List automatic newsletters

GET /api/v1/automatic-newsletters

Returns a paginated list. Use the sort parameter to control sorting (see Sorting and filtering).

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/automatic-newsletters?page=1&per_page=25&sort=created_desc"
import os, requests

response = requests.get(
    f"{os.environ['API']}/automatic-newsletters",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    params={"page": 1, "per_page": 25},
)
response.raise_for_status()
print(response.json())
const url = new URL(`${process.env.API}/automatic-newsletters`);
url.search = new URLSearchParams({ page: "1", per_page: "25" });

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
u, _ := url.Parse(os.Getenv("API") + "/automatic-newsletters")
query := u.Query()
query.Set("page", "1")
query.Set("per_page", "25")
u.RawQuery = query.Encode()

req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters")
uri.query = URI.encode_www_form(page: 1, per_page: 25)

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters?page=1&per_page=25"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$query = http_build_query(["page" => 1, "per_page" => 25]);

$ch = curl_init(getenv("API") . "/automatic-newsletters?" . $query);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

14.2 Get an automatic newsletter

GET /api/v1/automatic-newsletters/:slug

Returns a single automatic newsletter by slug.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/automatic-newsletters/$AUTO_SLUG"
import os, requests

response = requests.get(
    f"{os.environ['API']}/automatic-newsletters/{os.environ['AUTO_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/automatic-newsletters/${process.env.AUTO_SLUG}`,
  { headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/automatic-newsletters/"+os.Getenv("AUTO_SLUG"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/#{ENV.fetch("AUTO_SLUG")}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/" + System.getenv("AUTO_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/automatic-newsletters/" . getenv("AUTO_SLUG"));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

14.3 Create an automatic newsletter

POST /api/v1/automatic-newsletters

The feed is fetched once on create to populate feed_title, site_url, and the cached entry metadata. A network failure does not block creation. The resource is saved and metadata fields stay null until the next successful poll. Requires write scope.

{
  "automatic_newsletter": {
    "name": "Studio diary",
    "feed_url": "https://example.com/diary.xml",
    "behaviour": "draft",
    "frequency": "weekly",
    "use_post_title_as_subject": true,
    "include_featured_image": true,
    "public": false,
    "reply_to_enabled": false
  }
}
curl -X POST "$API/automatic-newsletters" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"automatic_newsletter":{"name":"Studio diary","feed_url":"https://example.com/diary.xml","frequency":"weekly"}}'
import os, requests

response = requests.post(
    f"{os.environ['API']}/automatic-newsletters",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"automatic_newsletter": {
        "name": "Studio diary",
        "feed_url": "https://example.com/diary.xml",
        "frequency": "weekly",
    }},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/automatic-newsletters`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    automatic_newsletter: {
      name: "Studio diary",
      feed_url: "https://example.com/diary.xml",
      frequency: "weekly"
    }
  })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "automatic_newsletter": map[string]any{
        "name":      "Studio diary",
        "feed_url":  "https://example.com/diary.xml",
        "frequency": "weekly",
    },
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/automatic-newsletters", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  automatic_newsletter: {
    name: "Studio diary",
    feed_url: "https://example.com/diary.xml",
    frequency: "weekly"
  }
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"automatic_newsletter\":{\"name\":\"Studio diary\","
            + "\"feed_url\":\"https://example.com/diary.xml\","
            + "\"frequency\":\"weekly\"}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "automatic_newsletter" => [
        "name"      => "Studio diary",
        "feed_url"  => "https://example.com/diary.xml",
        "frequency" => "weekly",
    ],
]);

$ch = curl_init(getenv("API") . "/automatic-newsletters");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

14.4 Update an automatic newsletter

PATCH /api/v1/automatic-newsletters/:slug

Updates an existing automatic newsletter. Send only the fields you want to change. The feed metadata is re-fetched only when feed_url changes. Renaming the resource updates the slug; subsequent calls must use the new slug. Requires write scope.

curl -X PATCH "$API/automatic-newsletters/$AUTO_SLUG" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"automatic_newsletter":{"frequency":"daily","public":true}}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/automatic-newsletters/{os.environ['AUTO_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"automatic_newsletter": {"frequency": "daily", "public": True}},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/automatic-newsletters/${process.env.AUTO_SLUG}`,
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      automatic_newsletter: { frequency: "daily", public: true }
    })
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "automatic_newsletter": map[string]any{
        "frequency": "daily",
        "public":    true,
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/automatic-newsletters/"+os.Getenv("AUTO_SLUG"), bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/#{ENV.fetch("AUTO_SLUG")}")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { automatic_newsletter: { frequency: "daily", public: true } }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"automatic_newsletter\":{\"frequency\":\"daily\",\"public\":true}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/" + System.getenv("AUTO_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "automatic_newsletter" => ["frequency" => "daily", "public" => true],
]);

$ch = curl_init(getenv("API") . "/automatic-newsletters/" . getenv("AUTO_SLUG"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

14.5 Delete an automatic newsletter

DELETE /api/v1/automatic-newsletters/:slug

Deletes the automatic newsletter and its feed-entry audit trail. Newsletters that have already been produced are kept untouched. Requires write scope.

curl -X DELETE "$API/automatic-newsletters/$AUTO_SLUG" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/automatic-newsletters/{os.environ['AUTO_SLUG']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(
  `${process.env.API}/automatic-newsletters/${process.env.AUTO_SLUG}`,
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/automatic-newsletters/"+os.Getenv("AUTO_SLUG"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/#{ENV.fetch("AUTO_SLUG")}")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/" + System.getenv("AUTO_SLUG")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/automatic-newsletters/" . getenv("AUTO_SLUG"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

14.6 Pause an automatic newsletter

POST /api/v1/automatic-newsletters/:slug/pause

Pauses an active automatic newsletter. Stops polling the feed until you call resume. Idempotent: pausing an already-paused feed has no further effect. Requires write scope.

curl -X POST "$API/automatic-newsletters/$AUTO_SLUG/pause" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/automatic-newsletters/{os.environ['AUTO_SLUG']}/pause",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(
  `${process.env.API}/automatic-newsletters/${process.env.AUTO_SLUG}/pause`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/automatic-newsletters/"+os.Getenv("AUTO_SLUG")+"/pause", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/#{ENV.fetch("AUTO_SLUG")}/pause")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/" + System.getenv("AUTO_SLUG") + "/pause"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/automatic-newsletters/" . getenv("AUTO_SLUG") . "/pause");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

14.7 Resume an automatic newsletter

POST /api/v1/automatic-newsletters/:slug/resume

Resumes a paused automatic newsletter. Re-enables polling. Also resets consecutive_failures to 0, so this is the supported way to recover a feed that was auto-paused after repeated failures. Requires write scope.

curl -X POST "$API/automatic-newsletters/$AUTO_SLUG/resume" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/automatic-newsletters/{os.environ['AUTO_SLUG']}/resume",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(
  `${process.env.API}/automatic-newsletters/${process.env.AUTO_SLUG}/resume`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("POST", os.Getenv("API")+"/automatic-newsletters/"+os.Getenv("AUTO_SLUG")+"/resume", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/#{ENV.fetch("AUTO_SLUG")}/resume")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/" + System.getenv("AUTO_SLUG") + "/resume"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/automatic-newsletters/" . getenv("AUTO_SLUG") . "/resume");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

14.8 Validate a feed URL

POST /api/v1/automatic-newsletters/validate-feed

Tests whether a feed URL is reachable and parseable. Network helper for the create / update flow. Fetches the feed and returns its title plus the latest entry, or 422 validation_failed if the URL is malformed or unreachable. Requires write scope.

Successful response shape:

{
  "data": {
    "valid": true,
    "feed_title": "Studio Diary",
    "site_url": "https://example.com",
    "latest_item_title": "Mixing day three",
    "latest_item_url": "https://example.com/posts/mixing-day-three"
  },
  "meta": { "request_id": "..." }
}
curl -X POST "$API/automatic-newsletters/validate" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feed_url":"https://example.com/diary.xml"}'
import os, requests

response = requests.post(
    f"{os.environ['API']}/automatic-newsletters/validate",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"feed_url": "https://example.com/diary.xml"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/automatic-newsletters/validate`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ feed_url: "https://example.com/diary.xml" })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "feed_url": "https://example.com/diary.xml",
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/automatic-newsletters/validate", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/automatic-newsletters/validate")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { feed_url: "https://example.com/diary.xml" }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"feed_url\":\"https://example.com/diary.xml\"}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/automatic-newsletters/validate"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode(["feed_url" => "https://example.com/diary.xml"]);

$ch = curl_init(getenv("API") . "/automatic-newsletters/validate");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

15.Subscribers

A subscriber’s ID is a stable SHA-256 digest of their email address, exposed as email_address_hash. Most snippets below refer to that as $EMAIL_HASH.

15.1 List subscribers

GET /api/v1/subscribers

Returns a paginated list. Combine sort, filter, and the pagination parameters as needed. See Sorting and filtering.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/subscribers?page=1&per_page=25&sort=subscribed_recent&filter=confirmed"
import os, requests

response = requests.get(
    f"{os.environ['API']}/subscribers",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    params={"page": 1, "per_page": 25, "sort": "subscribed_recent", "filter": "confirmed"},
)
response.raise_for_status()
print(response.json())
const url = new URL(`${process.env.API}/subscribers`);
url.search = new URLSearchParams({
  page: "1", per_page: "25", sort: "subscribed_recent", filter: "confirmed"
});

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
u, _ := url.Parse(os.Getenv("API") + "/subscribers")
query := u.Query()
query.Set("page", "1")
query.Set("per_page", "25")
query.Set("sort", "subscribed_recent")
query.Set("filter", "confirmed")
u.RawQuery = query.Encode()

req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/subscribers")
uri.query = URI.encode_www_form(page: 1, per_page: 25, sort: "subscribed_recent", filter: "confirmed")

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
String query = "page=1&per_page=25&sort=subscribed_recent&filter=confirmed";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers?" + query))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$query = http_build_query([
    "page"     => 1,
    "per_page" => 25,
    "sort"     => "subscribed_recent",
    "filter"   => "confirmed",
]);

$ch = curl_init(getenv("API") . "/subscribers?" . $query);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

15.2 Add a subscriber

Dispatches a subscriber.created webhook.
POST /api/v1/subscribers

Creates the subscriber, or re-attaches an existing subscriber record when the email address is already known. Requires write scope.

curl -X POST "$API/subscribers" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email_address":"fan@example.com"}'
import os, requests

response = requests.post(
    f"{os.environ['API']}/subscribers",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"email_address": "fan@example.com"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/subscribers`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ email_address: "fan@example.com" })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "email_address": "fan@example.com",
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/subscribers", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/subscribers")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { email_address: "fan@example.com" }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"email_address\":\"fan@example.com\"}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode(["email_address" => "fan@example.com"]);

$ch = curl_init(getenv("API") . "/subscribers");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

15.3 Get a subscriber

GET /api/v1/subscribers/:email_address_hash

Returns a single subscriber by email address hash.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/subscribers/$EMAIL_HASH"
import os, requests

response = requests.get(
    f"{os.environ['API']}/subscribers/{os.environ['EMAIL_HASH']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/subscribers/${process.env.EMAIL_HASH}`,
  { headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/subscribers/"+os.Getenv("EMAIL_HASH"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/subscribers/#{ENV.fetch("EMAIL_HASH")}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers/" + System.getenv("EMAIL_HASH")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/subscribers/" . getenv("EMAIL_HASH"));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

15.4 Delete a subscriber

Dispatches a subscriber.removed webhook.
DELETE /api/v1/subscribers/:email_address_hash

Permanently removes a subscriber. Requires write scope.

curl -X DELETE "$API/subscribers/$EMAIL_HASH" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/subscribers/{os.environ['EMAIL_HASH']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(
  `${process.env.API}/subscribers/${process.env.EMAIL_HASH}`,
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/subscribers/"+os.Getenv("EMAIL_HASH"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/subscribers/#{ENV.fetch("EMAIL_HASH")}")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers/" + System.getenv("EMAIL_HASH")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/subscribers/" . getenv("EMAIL_HASH"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

15.5 Delete all subscribers

DELETE /api/v1/subscribers

Removes every subscriber from the account. Requires ?confirm=true to avoid accidental wipes. Without it the request is rejected with 422 validation_failed.

curl -X DELETE "$API/subscribers?confirm=true" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/subscribers",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    params={"confirm": "true"},
)
response.raise_for_status()
const response = await fetch(`${process.env.API}/subscribers?confirm=true`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/subscribers?confirm=true", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/subscribers")
uri.query = URI.encode_www_form(confirm: true)
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers?confirm=true"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/subscribers?confirm=true");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

15.6 Import subscribers

POST /api/v1/subscribers/imports

Bulk imports run asynchronously, so the API follows an accept-then-poll pattern:

  1. POST /subscribers/imports: queues the import, returns 202 Accepted with a SubscriberImport resource (status processing) and a Location header pointing at the poll URL
  2. GET /subscribers/imports/:id: reports the current status. When status transitions to completed (or error) the import is final

Returns 409 subscriber_limit_reached if the account is already at its subscriber cap. Two request formats are supported (see below).

Multipart CSV upload

A header row is auto-detected, and the first column-with-an-email per row wins. Only text/csv (and the common aliases application/csv, application/vnd.ms-excel, text/plain) with a .csv extension are accepted. The hard upload cap is 20 MiB.

Submitting a non-CSV file (e.g. a PDF) returns 415 unsupported_media_type.

curl -i -X POST "$API/subscribers/imports" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -F "file=@subscribers.csv;type=text/csv"
import os, requests

with open("subscribers.csv", "rb") as f:
    response = requests.post(
        f"{os.environ['API']}/subscribers/imports",
        headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
        files={"file": ("subscribers.csv", f, "text/csv")},
    )

response.raise_for_status()
print(response.json())
import { readFileSync } from "node:fs";

const body = new FormData();
body.append("file", new Blob([readFileSync("subscribers.csv")], { type: "text/csv" }), "subscribers.csv");

const response = await fetch(`${process.env.API}/subscribers/imports`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` },
  body
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
var body bytes.Buffer
writer := multipart.NewWriter(&body)

part, _ := writer.CreatePart(textproto.MIMEHeader{
    "Content-Disposition": []string{`form-data; name="file"; filename="subscribers.csv"`},
    "Content-Type":        []string{"text/csv"},
})
file, _ := os.Open("subscribers.csv")
defer file.Close()
io.Copy(part, file)
writer.Close()

req, _ := http.NewRequest("POST", os.Getenv("API")+"/subscribers/imports", &body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", writer.FormDataContentType())

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

data, _ := io.ReadAll(response.Body)
fmt.Println(string(data))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/subscribers/imports")
request = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" })
File.open("subscribers.csv") do |file|
  request.set_form([ [ "file", file, { filename: "subscribers.csv", content_type: "text/csv" } ] ], "multipart/form-data")
end

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String boundary = "----BandToolsBoundary" + System.currentTimeMillis();
byte[] fileBytes = Files.readAllBytes(Path.of("subscribers.csv"));

ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.writeBytes(("--" + boundary + "\r\n").getBytes());
buffer.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"subscribers.csv\"\r\n".getBytes());
buffer.writeBytes("Content-Type: text/csv\r\n\r\n".getBytes());
buffer.writeBytes(fileBytes);
buffer.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers/imports"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
    .POST(HttpRequest.BodyPublishers.ofByteArray(buffer.toByteArray()))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/subscribers/imports");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "file" => new CURLFile("subscribers.csv", "text/csv", "subscribers.csv"),
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Inline JSON email list

Convenient when the addresses are already in memory. email_addresses must contain between 1 and 10,000 entries. Each individual address is capped at 256 bytes; an oversize entry returns 413 payload_too_large.

curl -i -X POST "$API/subscribers/imports" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email_addresses": [
      "fan-one@example.com",
      "fan-two@example.com",
      "fan-three@example.com"
    ]
  }'
import os, requests

response = requests.post(
    f"{os.environ['API']}/subscribers/imports",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"email_addresses": [
        "fan-one@example.com",
        "fan-two@example.com",
        "fan-three@example.com",
    ]},
)
response.raise_for_status()
import_id = response.json()["data"]["id"]
const response = await fetch(`${process.env.API}/subscribers/imports`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    email_addresses: [
      "fan-one@example.com",
      "fan-two@example.com",
      "fan-three@example.com"
    ]
  })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { data } = await response.json();
console.log(data.id);
payload, _ := json.Marshal(map[string]any{
    "email_addresses": []string{
        "fan-one@example.com",
        "fan-two@example.com",
        "fan-three@example.com",
    },
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/subscribers/imports", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/subscribers/imports")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  email_addresses: %w[fan-one@example.com fan-two@example.com fan-three@example.com]
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body).dig("data", "id")
String body = """
    {
      "email_addresses": [
        "fan-one@example.com",
        "fan-two@example.com",
        "fan-three@example.com"
      ]
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/subscribers/imports"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "email_addresses" => [
        "fan-one@example.com",
        "fan-two@example.com",
        "fan-three@example.com",
    ],
]);

$ch = curl_init(getenv("API") . "/subscribers/imports");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

A successful response returns 202 Accepted:

{
  "data": {
    "id": "imp_abc123",
    "status": "processing",
    "imported_count": null,
    "skipped_count": null,
    "failed_email_addresses": [],
    "filename": "subscribers.csv",
    "error": null,
    "created_at": "2026-04-22T12:00:00Z",
    "completed_at": null
  },
  "meta": { "request_id": "..." }
}

Poll every 2–5 seconds until status is completed or error:

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/subscribers/imports/imp_abc123"
import os, time, requests

while True:
    response = requests.get(
        f"{os.environ['API']}/subscribers/imports/{import_id}",
        headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    )
    response.raise_for_status()
    status = response.json()["data"]["status"]
    if status in {"completed", "error"}:
        print(response.json())
        break
    time.sleep(3)
while (true) {
  const response = await fetch(
    `${process.env.API}/subscribers/imports/${importId}`,
    { headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
  );
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const { data } = await response.json();
  if (data.status === "completed" || data.status === "error") {
    console.log(data);
    break;
  }
  await new Promise(r => setTimeout(r, 3000));
}
for {
    req, _ := http.NewRequest("GET", os.Getenv("API")+"/subscribers/imports/"+importID, nil)
    req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

    response, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    var payload struct {
        Data struct {
            Status string `json:"status"`
        } `json:"data"`
    }
    json.NewDecoder(response.Body).Decode(&payload)
    response.Body.Close()

    if payload.Data.Status == "completed" || payload.Data.Status == "error" {
        fmt.Println(payload.Data.Status)
        break
    }
    time.Sleep(3 * time.Second)
}
require "net/http"
require "json"

loop do
  uri = URI("#{ENV.fetch("API")}/subscribers/imports/#{import_id}")
  request = Net::HTTP::Get.new(uri)
  request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

  data = JSON.parse(response.body).fetch("data")
  break pp data if %w[completed error].include?(data["status"])

  sleep 3
end
HttpClient client = HttpClient.newHttpClient();

while (true) {
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(System.getenv("API") + "/subscribers/imports/" + importId))
        .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
        .GET()
        .build();

    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    if (response.body().contains("\"status\":\"completed\"") || response.body().contains("\"status\":\"error\"")) {
        System.out.println(response.body());
        break;
    }
    Thread.sleep(3000);
}
while (true) {
    $ch = curl_init(getenv("API") . "/subscribers/imports/" . $importId);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    $response = curl_exec($ch);
    curl_close($ch);

    $data = json_decode($response, true)["data"];
    if (in_array($data["status"], ["completed", "error"], true)) {
        print_r($data);
        break;
    }
    sleep(3);
}

Behaviour

  • Only one import runs at a time per account
  • Duplicates and reserved addresses count as skipped; unconfirmed subscribers remain unconfirmed
  • The import stops early if the account reaches its subscriber limit mid-import. Already-imported rows are kept and the final imported_count reflects what actually landed
  • Polling a completed import is idempotent

Top

16.Page designs

Read and update the four subscriber-facing page designs: the archive index, the subscribe form, the confirmation page, and the unsubscribe page. Each page type is a singular resource under /api/v1/account/…-page, and the accepted fields vary by page type (heading and content keys are only present on pages where they apply).

VerbPathScopePurpose
GET/PATCH/api/v1/account/archive-pageread/writeArchive page design.
GET/PATCH/api/v1/account/subscribe-pageread/writeSubscribe page design.
GET/PATCH/api/v1/account/confirmation-pageread/writeConfirmation page design.
GET/PATCH/api/v1/account/unsubscribe-pageread/writeUnsubscribe page design.
PUT/DELETE/api/v1/account/{page-type}/background-imagewriteUpload, replace, or remove the page’s background image (multipart).
Some page design actions require the Headliner plan. Callers on lower plans receive 403 plan_feature_required.
  • Archive pages: all reads and writes on /archive-page require Headliner
  • page_theme_id writes: setting a theme on any page requires Headliner. Setting page_theme_id to its existing value (including null) is always allowed, so you can round-trip a payload safely
  • Background image uploads and removals: PUT and DELETE on …/background-image require Headliner

Fields

Writable attributes are accepted inside a wrapper keyed on the page type (archive_page, subscribe_page, confirmation_page, unsubscribe_page). heading and content are omitted from the response JSON on page types that do not support them, and ignored if supplied on writes.

Key Type Archive Subscribe Confirmation Unsubscribe
body_background_colour#rrggbb string
body_text_colour#rrggbb string
body_fontfont family name
body_font_sizeinteger (px)
heading_background_colour#rrggbb string
heading_text_colour#rrggbb string
heading_fontfont family name
heading_font_sizeinteger (px)
link_colour#rrggbb string
link_styleinteger 0..14
page_theme_idinteger or null
contentHTML string
headingstring (5–100 chars)

Read-only response keys: link_style_label, background_image_url (signed URL, or null), and updated_at.

Reference value lists

  • body_font / heading_font:
    40 supported fonts

    Acme, Archivo Black, Bebas Neue, Bitcount Single Ink, Changa One, Cherry Cream Soda, Dancing Script, EB Garamond, Fira Code, Inconsolata, Inter, Kablammo, Lato, Lexend, Libre Baskerville, Limelight, Lobster, Lora, Merriweather, Michroma, Montserrat, Open Sans, Orbitron, Oswald, Pacifico, Permanent Marker, Pixelify Sans, Playfair Display, Poiret One, Poppins, Quintessential, Raleway, Rubik Dirt, Saira Stencil One, Source Code Pro, Story Script, Syne Mono, Uncial Antiqua, UnifrakturMaguntia, Work Sans.

  • body_font_size / heading_font_size: one of 14, 16, 18, 20, 24, 28, 32, 40
  • link_style: integer in 0..14.
    Link style labels

    0 Default, 1 Bold, 2 Dashed bottom border, 3 Dotted bottom border, 4 Solid bottom border, 5 Inverted, 6 Italic, 7 Underlined, 8 Hover bold, 9 Hover dashed bottom border, 10 Hover dotted bottom border, 11 Hover solid bottom border, 12 Hover inverted, 13 Hover italic, 14 Hover underlined.

Colours that resolve to the same value on background/foreground pairs (for example body background vs body text) or between the link colour and body background fail validation with 422 validation_failed, with the offending field in details.

Rich-text fields (such as heading and content) are capped at 256 KiB per field. An oversize value returns 413 payload_too_large with details: [{"field": "…", "code": "too_large", "max_bytes": 262144}].

16.1 Get a page design

GET /api/v1/account/{page-type}

Returns the design settings for the specified page type.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/subscribe-page"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/subscribe-page",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/subscribe-page`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/subscribe-page", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/subscribe-page")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/subscribe-page"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/subscribe-page");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
  "data": {
    "body_background_colour":    "#ffffff",
    "body_text_colour":          "#111111",
    "body_font_size":            16,
    "heading_background_colour": "#ffffff",
    "heading_text_colour":       "#111111",
    "heading_font_size":         32,
    "link_colour":               "#0066cc",
    "link_style":                0,
    "body_font":                 "Inter",
    "heading_font":              "Inter",
    "link_style_label":          "Default",
    "page_theme_id":             null,
    "background_image_url":      null,
    "updated_at":                "2026-04-22T12:34:56Z",
    "content":                   "<div class=\"trix-content\"><p>Welcome!</p></div>"
  },
  "meta": { "request_id": "..." }
}

Top

16.2 Update a page design

PATCH /api/v1/account/{page-type}

Updates the design settings for the specified page type. Unknown keys (including any key that does not apply to the current page type) are silently ignored.

curl -X PATCH "$API/account/confirmation-page" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "confirmation_page": {
      "heading":      "You are subscribed!",
      "content":      "<div>Thanks for confirming.</div>",
      "body_font":    "Lato",
      "heading_font": "Oswald",
      "link_colour":  "#0066cc",
      "link_style":   1
    }
  }'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account/confirmation-page",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"confirmation_page": {
        "heading":      "You are subscribed!",
        "content":      "<div>Thanks for confirming.</div>",
        "body_font":    "Lato",
        "heading_font": "Oswald",
        "link_colour":  "#0066cc",
        "link_style":   1,
    }},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/confirmation-page`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    confirmation_page: {
      heading:      "You are subscribed!",
      content:      "<div>Thanks for confirming.</div>",
      body_font:    "Lato",
      heading_font: "Oswald",
      link_colour:  "#0066cc",
      link_style:   1
    }
  })
});
payload, _ := json.Marshal(map[string]any{
    "confirmation_page": map[string]any{
        "heading":      "You are subscribed!",
        "content":      "<div>Thanks for confirming.</div>",
        "body_font":    "Lato",
        "heading_font": "Oswald",
        "link_colour":  "#0066cc",
        "link_style":   1,
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account/confirmation-page", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/confirmation-page")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  confirmation_page: {
    heading:      "You are subscribed!",
    content:      "<div>Thanks for confirming.</div>",
    body_font:    "Lato",
    heading_font: "Oswald",
    link_colour:  "#0066cc",
    link_style:   1
  }
}.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = """
    {
      "confirmation_page": {
        "heading": "You are subscribed!",
        "content": "<div>Thanks for confirming.</div>",
        "body_font": "Lato",
        "heading_font": "Oswald",
        "link_colour": "#0066cc",
        "link_style": 1
      }
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/confirmation-page"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode([
    "confirmation_page" => [
        "heading"      => "You are subscribed!",
        "content"      => "<div>Thanks for confirming.</div>",
        "body_font"    => "Lato",
        "heading_font" => "Oswald",
        "link_colour"  => "#0066cc",
        "link_style"   => 1,
    ],
]);

$ch = curl_init(getenv("API") . "/account/confirmation-page");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

16.3 Upload a background image

PUT /api/v1/account/{page-type}/background-image

Uploads or replaces the background image for the specified page type. Multipart PUT with a single file part named background_image. Allowed content types: image/gif, image/jpeg, image/png, image/webp. Maximum file size: 10 MiB. The file’s actual contents must match the declared Content-Type, or the request is rejected with 415 unsupported_media_type.

curl -X PUT "$API/account/subscribe-page/background-image" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -F "background_image=@path/to/hero.png;type=image/png"
import os, requests

with open("hero.png", "rb") as f:
    response = requests.put(
        f"{os.environ['API']}/account/subscribe-page/background-image",
        headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
        files={"background_image": ("hero.png", f, "image/png")},
    )
response.raise_for_status()
print(response.json())
import { readFileSync } from "node:fs";

const body = new FormData();
body.append(
  "background_image",
  new Blob([readFileSync("hero.png")], { type: "image/png" }),
  "hero.png"
);

const response = await fetch(
  `${process.env.API}/account/subscribe-page/background-image`,
  { method: "PUT", headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }, body }
);
console.log(await response.json());
var body bytes.Buffer
writer := multipart.NewWriter(&body)

part, _ := writer.CreatePart(textproto.MIMEHeader{
    "Content-Disposition": []string{`form-data; name="background_image"; filename="hero.png"`},
    "Content-Type":        []string{"image/png"},
})
file, _ := os.Open("hero.png")
defer file.Close()
io.Copy(part, file)
writer.Close()

req, _ := http.NewRequest("PUT", os.Getenv("API")+"/account/subscribe-page/background-image", &body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", writer.FormDataContentType())

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

data, _ := io.ReadAll(response.Body)
fmt.Println(string(data))
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/subscribe-page/background-image")
request = Net::HTTP::Put.new(uri, { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" })
File.open("hero.png") do |file|
  request.set_form([ [ "background_image", file, { filename: "hero.png", content_type: "image/png" } ] ], "multipart/form-data")
end

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
String boundary = "----BandToolsBoundary" + System.currentTimeMillis();
byte[] fileBytes = Files.readAllBytes(Path.of("hero.png"));

ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.writeBytes(("--" + boundary + "\r\n").getBytes());
buffer.writeBytes("Content-Disposition: form-data; name=\"background_image\"; filename=\"hero.png\"\r\n".getBytes());
buffer.writeBytes("Content-Type: image/png\r\n\r\n".getBytes());
buffer.writeBytes(fileBytes);
buffer.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/subscribe-page/background-image"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
    .PUT(HttpRequest.BodyPublishers.ofByteArray(buffer.toByteArray()))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/subscribe-page/background-image");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "background_image" => new CURLFile("hero.png", "image/png", "hero.png"),
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

16.4 Remove a background image

DELETE /api/v1/account/{page-type}/background-image

Removes the background image from the specified page type.

curl -X DELETE "$API/account/subscribe-page/background-image" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/account/subscribe-page/background-image",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/subscribe-page/background-image`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/account/subscribe-page/background-image", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/subscribe-page/background-image")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/subscribe-page/background-image"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/account/subscribe-page/background-image");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Returns 204 no_content. If no image is currently attached, returns 404 not_found with error.code = “not_found”.

Top

17.Confirmation email

Read and update the double-opt-in confirmation email that subscribers receive after filling in the subscribe form. The resource has only a subject and a rich-text message. No layout or colour settings.

VerbPathScopePurpose
GET/api/v1/account/confirmation-emailreadFetch the current confirmation email.
PATCH/api/v1/account/confirmation-emailwriteUpdate the confirmation email.

Fields

{
  "data": {
    "subject":    "Please confirm your subscription",
    "message":    "<div>Hi, please click the link below to confirm your subscription to <strong>*|USER:NEWSLETTER_NAME|*</strong>.</div>",
    "updated_at": "2026-04-22T12:34:56Z"
  },
  "meta": { "request_id": "..." }
}
  • subject: plain string, 5–100 characters
  • message: HTML string, capped at 256 KiB. Merge tags like *|USER:NEWSLETTER_NAME|* are preserved verbatim and substituted at send time. An oversize value returns 413 payload_too_large

17.1 Get the confirmation email

GET /api/v1/account/confirmation-email

Returns the current confirmation email subject and message.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/confirmation-email"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/confirmation-email",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/confirmation-email`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/confirmation-email", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/confirmation-email")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/confirmation-email"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/confirmation-email");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

17.2 Update the confirmation email

PATCH /api/v1/account/confirmation-email

Updates the confirmation email subject and message.

curl -X PATCH "$API/account/confirmation-email" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "confirmation_email": {
      "subject": "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
      "message": "<div>Hi!<br><br>Click the link below to finish subscribing.</div>"
    }
  }'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account/confirmation-email",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"confirmation_email": {
        "subject": "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
        "message": "<div>Hi!<br><br>Click the link below to finish subscribing.</div>",
    }},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/confirmation-email`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    confirmation_email: {
      subject: "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
      message: "<div>Hi!<br><br>Click the link below to finish subscribing.</div>"
    }
  })
});
payload, _ := json.Marshal(map[string]any{
    "confirmation_email": map[string]any{
        "subject": "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
        "message": "<div>Hi!<br><br>Click the link below to finish subscribing.</div>",
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account/confirmation-email", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/confirmation-email")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  confirmation_email: {
    subject: "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
    message: "<div>Hi!<br><br>Click the link below to finish subscribing.</div>"
  }
}.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = """
    {
      "confirmation_email": {
        "subject": "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
        "message": "<div>Hi!<br><br>Click the link below to finish subscribing.</div>"
      }
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/confirmation-email"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode([
    "confirmation_email" => [
        "subject" => "Please confirm your subscription to *|USER:NEWSLETTER_NAME|*",
        "message" => "<div>Hi!<br><br>Click the link below to finish subscribing.</div>",
    ],
]);

$ch = curl_init(getenv("API") . "/account/confirmation-email");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Returns the updated resource. Validation failures (for example a subject shorter than 5 characters) surface as 422 validation_failed with per-field details.

Top

18.Account

Read and update the current user’s profile, account picture, and app-level settings. The authenticated user is always resolved from the bearer token, so there is no :id or :username in any of these paths.

Verb Path Scope Purpose
GET/api/v1/accountreadProfile (allow-listed fields).
PATCH/api/v1/accountwriteUpdate name, username, and website URL. email_address is read-only via the API.
GET/api/v1/account/picturereadDownload the account picture (302 to a short-lived signed URL).
PUT/api/v1/account/picturewriteUpload or replace the account picture (multipart).
DELETE/api/v1/account/picturewriteRemove the account picture.
GET/api/v1/account/settingsreadApp-level settings (allow-listed).
PATCH/api/v1/account/settingswriteUpdate app-level settings.
GET/api/v1/account/newsletter-settingsreadNewsletter-level settings (allow-listed).
PATCH/api/v1/account/newsletter-settingswriteUpdate newsletter-level settings (per-field plan gates).
GET/api/v1/account/themesreadList user and system themes.
POST/api/v1/account/themeswriteCreate a user-owned theme.
GET/api/v1/account/themes/:idreadFetch a single theme (user or system).
PATCH/api/v1/account/themes/:idwriteUpdate a user-owned theme.
DELETE/api/v1/account/themes/:idwriteDelete a user-owned theme.

See also the dedicated Page designs and Confirmation email sections for the four subscriber-facing page designs and the double-opt-in confirmation email template.

18.1 Get the current account

GET /api/v1/account

Returns the authenticated user’s profile, plan, and basic stats.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Example response body:

{
  "data": {
    "id": "testusertwo",
    "name": "Test User Two",
    "email_address": "two@example.com",
    "username": "testusertwo",
    "website_url": "https://example.com",
    "verified": true,
    "plan": { "name": "artist", "description": "Artist" },
    "features": {
      "automatic_newsletters": true,
      "duplicate_newsletter": true,
      "subscriber_limit": 1000,
      "unlimited_newsletters": true
    },
    "newsletters_count": 0,
    "picture": null,
    "created_at": "2026-01-02T12:00:00Z",
    "last_signed_in_at": "2026-04-21T08:12:34Z"
  },
  "meta": { "request_id": "..." }
}

The response is an explicit allow-list. The password is not exposed and is not mutable through the API. email_address is exposed for read but is not writable; password and email-address changes must be made in the web app. The features object lists plan features for the authenticated account and is not mutable through the API. Keys are feature slugs; values are true for presence-only features or an integer for valued limits. Absent keys mean the account does not have that feature.

Top

18.2 Update the current account

PATCH /api/v1/account

Updates the authenticated user’s profile. Only name, username, and website_url can be updated. email_address is read-only via the API; change it from the web app under Settings → Account. An email_address field in the request body is silently ignored; the other fields continue to apply.

curl -X PATCH "$API/account" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"account":{"name":"New Display Name","website_url":"https://example.com"}}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"account": {"name": "New Display Name", "website_url": "https://example.com"}},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    account: { name: "New Display Name", website_url: "https://example.com" }
  })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "account": map[string]any{
        "name":        "New Display Name",
        "website_url": "https://example.com",
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { account: { name: "New Display Name", website_url: "https://example.com" } }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"account\":{\"name\":\"New Display Name\",\"website_url\":\"https://example.com\"}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "account" => [
        "name"        => "New Display Name",
        "website_url" => "https://example.com",
    ],
]);

$ch = curl_init(getenv("API") . "/account");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

A validation failure surfaces as 422 validation_failed:

{
  "error": {
    "code": "validation_failed",
    "message": "Username has already been taken",
    "details": [ { "field": "username", "code": "taken", "message": "has already been taken" } ]
  },
  "meta": { "request_id": "..." }
}

Top

18.3 Account picture

Accepted types: image/gif, image/jpeg, image/png, image/webp. Maximum size: 5 MiB. Downloading returns 302 with a Location to a short-lived signed URL when a picture is attached, or 404 not_found when not. Replacing is done by PUTing again. The previous image is removed automatically.

The file’s actual contents must match the declared Content-Type, or the upload is rejected with 415 unsupported_media_type.

Download

GET /api/v1/account/picture

Redirects to a short-lived signed URL for the account picture.

curl -L -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/picture" \
  -o avatar.bin
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/picture",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    allow_redirects=True,
)
response.raise_for_status()
with open("avatar.bin", "wb") as f:
    f.write(response.content)
import { writeFile } from "node:fs/promises";

const response = await fetch(`${process.env.API}/account/picture`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` },
  redirect: "follow"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await writeFile("avatar.bin", new Uint8Array(await response.arrayBuffer()));
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/picture", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

out, _ := os.Create("avatar.bin")
defer out.Close()
io.Copy(out, response.Body)
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/picture")
# Net::HTTP does not auto-follow; fetch then chase the redirect once.
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

response = Net::HTTP.get_response(URI(response["Location"])) if response.is_a?(Net::HTTPRedirection)
File.binwrite("avatar.bin", response.body)
HttpClient client = HttpClient.newBuilder()
    .followRedirects(HttpClient.Redirect.NORMAL)
    .build();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/picture"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<Path> response = client.send(
    request, HttpResponse.BodyHandlers.ofFile(Path.of("avatar.bin")));
$ch = curl_init(getenv("API") . "/account/picture");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

$fp = fopen("avatar.bin", "wb");
curl_setopt($ch, CURLOPT_FILE, $fp);

curl_exec($ch);
curl_close($ch);
fclose($fp);

The samples write to avatar.bin because the original upload could have been any accepted type; rename the file to match the response Content-Type (for example .png, .jpg, .gif, or .webp) if you need a recognisable extension.

Upload or replace

PUT /api/v1/account/picture

Uploads or replaces the account picture via multipart form data.

curl -X PUT "$API/account/picture" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -F "picture=@./avatar.png"
import os, requests

with open("avatar.png", "rb") as f:
    response = requests.put(
        f"{os.environ['API']}/account/picture",
        headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
        files={"picture": ("avatar.png", f, "image/png")},
    )
response.raise_for_status()
print(response.json())
import { readFileSync } from "node:fs";

const body = new FormData();
body.append("picture", new Blob([readFileSync("avatar.png")], { type: "image/png" }), "avatar.png");

const response = await fetch(`${process.env.API}/account/picture`, {
  method: "PUT",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` },
  body
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
var body bytes.Buffer
writer := multipart.NewWriter(&body)

part, _ := writer.CreatePart(textproto.MIMEHeader{
    "Content-Disposition": []string{`form-data; name="picture"; filename="avatar.png"`},
    "Content-Type":        []string{"image/png"},
})
file, _ := os.Open("avatar.png")
defer file.Close()
io.Copy(part, file)
writer.Close()

req, _ := http.NewRequest("PUT", os.Getenv("API")+"/account/picture", &body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", writer.FormDataContentType())

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

data, _ := io.ReadAll(response.Body)
fmt.Println(string(data))
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/picture")
request = Net::HTTP::Put.new(uri, { "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}" })
File.open("avatar.png") do |file|
  request.set_form([ [ "picture", file, { filename: "avatar.png", content_type: "image/png" } ] ], "multipart/form-data")
end

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts response.body
String boundary = "----BandToolsBoundary" + System.currentTimeMillis();
byte[] fileBytes = Files.readAllBytes(Path.of("avatar.png"));

ByteArrayOutputStream buffer = new ByteArrayOutputStream();
buffer.writeBytes(("--" + boundary + "\r\n").getBytes());
buffer.writeBytes("Content-Disposition: form-data; name=\"picture\"; filename=\"avatar.png\"\r\n".getBytes());
buffer.writeBytes("Content-Type: image/png\r\n\r\n".getBytes());
buffer.writeBytes(fileBytes);
buffer.writeBytes(("\r\n--" + boundary + "--\r\n").getBytes());

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/picture"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "multipart/form-data; boundary=" + boundary)
    .PUT(HttpRequest.BodyPublishers.ofByteArray(buffer.toByteArray()))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/picture");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "picture" => new CURLFile("avatar.png", "image/png", "avatar.png"),
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
  "data": {
    "content_type": "image/png",
    "filename": "avatar.png",
    "byte_size": 42384,
    "url": "https://bandtools.app/api/v1/account/picture"
  },
  "meta": { "request_id": "..." }
}

Remove

DELETE /api/v1/account/picture

Removes the account picture.

curl -X DELETE "$API/account/picture" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/account/picture",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(`${process.env.API}/account/picture`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok && response.status !== 404) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/account/picture", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/picture")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/picture"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/account/picture");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Returns 204 no_content on success; 404 not_found when no picture is attached.

Top

18.4 App settings

Six app-level preferences are exposed. Newsletter-level settings are covered by the separate /api/v1/account/newsletter-settings resource.

Read

GET /api/v1/account/settings

Returns the current app-level settings.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/settings"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/settings",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/settings`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/settings", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/settings")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/settings"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/settings");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
  "data": {
    "blog_subscription": false,
    "dark_mode": false,
    "draft_newsletters_order": 2,
    "items_per_page": 10,
    "sent_newsletters_order": 2,
    "subscribers_order": 2
  },
  "meta": { "request_id": "..." }
}

The three *_order fields are integer enums accepting values 0..3. The table below lists what each value means. The defaults are draft_newsletters_order = 2, sent_newsletters_order = 2, and subscribers_order = 2; submitting any other integer returns 422 validation_failed with details[0].code = "inclusion".

Value draft_newsletters_order sent_newsletters_order subscribers_order
0Subject A→ZSubject A→ZEmail address A→Z
1Subject Z→ASubject Z→AEmail address Z→A
2Most recently saved first (default)Most recently sent first (default)Most recently subscribed first (default)
3Oldest saved firstOldest sent firstOldest subscribed first

Update

PATCH /api/v1/account/settings

Updates one or more app-level settings. Valid items_per_page is 5..50; out-of-range values return 422 validation_failed. Unknown keys are silently dropped. Only the six allow-listed fields are ever persisted.

curl -X PATCH "$API/account/settings" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"settings":{"items_per_page":25,"dark_mode":true}}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account/settings",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"settings": {"items_per_page": 25, "dark_mode": True}},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/settings`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ settings: { items_per_page: 25, dark_mode: true } })
});
payload, _ := json.Marshal(map[string]any{
    "settings": map[string]any{
        "items_per_page": 25,
        "dark_mode":      true,
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account/settings", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/settings")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { settings: { items_per_page: 25, dark_mode: true } }.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = "{\"settings\":{\"items_per_page\":25,\"dark_mode\":true}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/settings"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode([
    "settings" => ["items_per_page" => 25, "dark_mode" => true],
]);

$ch = curl_init(getenv("API") . "/account/settings");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

18.5 Newsletter settings

The read side always returns all five fields so clients can display current state regardless of plan. newsletter_description is an HTML string, or null when empty.

Read

GET /api/v1/account/newsletter-settings

Returns the current newsletter-level settings.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/newsletter-settings"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/newsletter-settings",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/newsletter-settings`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/newsletter-settings", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/newsletter-settings")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/newsletter-settings"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/newsletter-settings");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;
{
  "data": {
    "newsletter_name":            "My Newsletter",
    "newsletter_description":     "<div>Stories from the studio.</div>",
    "block_ai_user_agents":       false,
    "subscribe_notifications":    true,
    "unsubscribe_notifications":  true
  },
  "meta": { "request_id": "..." }
}

Update

PATCH /api/v1/account/newsletter-settings

Updates one or more newsletter-level settings (some fields are plan-gated).

FieldTypeRequired plan
newsletter_namestring (4..50, unique across all users)any
newsletter_descriptionHTML stringany
block_ai_user_agentsbooleanHeadliner
subscribe_notificationsbooleanArtist or Headliner
unsubscribe_notificationsbooleanArtist or Headliner
  • Attempting to change a gated boolean on a lower plan returns 403 plan_feature_required with the offending field in error.details.
  • Setting a gated field to its current value is a no-op and does not trip the gate.
  • Submitting a duplicate newsletter_name returns 422 validation_failed.
curl -X PATCH "$API/account/newsletter-settings" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "newsletter_settings": {
      "newsletter_name":         "Updated Name",
      "newsletter_description":  "<div>New blurb</div>",
      "subscribe_notifications": false
    }
  }'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account/newsletter-settings",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"newsletter_settings": {
        "newsletter_name":         "Updated Name",
        "newsletter_description":  "<div>New blurb</div>",
        "subscribe_notifications": False,
    }},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/newsletter-settings`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    newsletter_settings: {
      newsletter_name:         "Updated Name",
      newsletter_description:  "<div>New blurb</div>",
      subscribe_notifications: false
    }
  })
});
payload, _ := json.Marshal(map[string]any{
    "newsletter_settings": map[string]any{
        "newsletter_name":         "Updated Name",
        "newsletter_description":  "<div>New blurb</div>",
        "subscribe_notifications": false,
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account/newsletter-settings", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/newsletter-settings")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  newsletter_settings: {
    newsletter_name:         "Updated Name",
    newsletter_description:  "<div>New blurb</div>",
    subscribe_notifications: false
  }
}.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
String body = """
    {
      "newsletter_settings": {
        "newsletter_name": "Updated Name",
        "newsletter_description": "<div>New blurb</div>",
        "subscribe_notifications": false
      }
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/newsletter-settings"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$payload = json_encode([
    "newsletter_settings" => [
        "newsletter_name"         => "Updated Name",
        "newsletter_description"  => "<div>New blurb</div>",
        "subscribe_notifications" => false,
    ],
]);

$ch = curl_init(getenv("API") . "/account/newsletter-settings");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

18.6 Themes

Every action on /api/v1/account/themes requires the Headliner plan. Callers on lower plans receive 403 plan_feature_required.

List themes

GET /api/v1/account/themes

Returns a paginated list of user-owned and system themes.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/themes?filter=all&sort=name_asc&page=1&per_page=25"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/themes",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    params={"filter": "all", "sort": "name_asc", "page": 1, "per_page": 25},
)
response.raise_for_status()
print(response.json())
const url = new URL(`${process.env.API}/account/themes`);
url.search = new URLSearchParams({ filter: "all", sort: "name_asc", page: "1", per_page: "25" });

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
u, _ := url.Parse(os.Getenv("API") + "/account/themes")
query := u.Query()
query.Set("filter", "all")
query.Set("sort", "name_asc")
query.Set("page", "1")
query.Set("per_page", "25")
u.RawQuery = query.Encode()

req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/themes")
uri.query = URI.encode_www_form(filter: "all", sort: "name_asc", page: 1, per_page: 25)

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
String query = "filter=all&sort=name_asc&page=1&per_page=25";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/themes?" + query))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$query = http_build_query([
    "filter"   => "all",
    "sort"     => "name_asc",
    "page"     => 1,
    "per_page" => 25,
]);

$ch = curl_init(getenv("API") . "/account/themes?" . $query);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Query parameters:

  • filter: all (default), user, or system.
  • sort: name_asc (default), name_desc, created_asc, or created_desc.
  • Standard page / per_page pair.

The in_use field on each theme is true when at least one page references it, so clients can pre-empt the in-use-on-delete case without an extra round-trip.

Create a theme

POST /api/v1/account/themes

Creates a new user-owned theme with the given colour and font settings.

curl -X POST "$API/account/themes" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "theme": {
      "name": "Sunset",
      "body_background_colour":    "#fff8f0",
      "body_font_family":          "Inter",
      "body_font_size":            16,
      "body_text_colour":          "#222222",
      "heading_background_colour": "#e76f51",
      "heading_font_family":       "Inter",
      "heading_font_size":         32,
      "heading_text_colour":       "#ffffff",
      "link_colour":               "#e76f51",
      "link_style":                0
    }
  }'
import os, requests

theme = {
    "name": "Sunset",
    "body_background_colour":    "#fff8f0",
    "body_font_family":          "Inter",
    "body_font_size":            16,
    "body_text_colour":          "#222222",
    "heading_background_colour": "#e76f51",
    "heading_font_family":       "Inter",
    "heading_font_size":         32,
    "heading_text_colour":       "#ffffff",
    "link_colour":               "#e76f51",
    "link_style":                0,
}

response = requests.post(
    f"{os.environ['API']}/account/themes",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"theme": theme},
)
response.raise_for_status()
print(response.json()["data"]["id"])
const theme = {
  name: "Sunset",
  body_background_colour:    "#fff8f0",
  body_font_family:          "Inter",
  body_font_size:            16,
  body_text_colour:          "#222222",
  heading_background_colour: "#e76f51",
  heading_font_family:       "Inter",
  heading_font_size:         32,
  heading_text_colour:       "#ffffff",
  link_colour:               "#e76f51",
  link_style:                0
};

const response = await fetch(`${process.env.API}/account/themes`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ theme })
});
console.log((await response.json()).data.id);
payload, _ := json.Marshal(map[string]any{
    "theme": map[string]any{
        "name":                      "Sunset",
        "body_background_colour":    "#fff8f0",
        "body_font_family":          "Inter",
        "body_font_size":            16,
        "body_text_colour":          "#222222",
        "heading_background_colour": "#e76f51",
        "heading_font_family":       "Inter",
        "heading_font_size":         32,
        "heading_text_colour":       "#ffffff",
        "link_colour":               "#e76f51",
        "link_style":                0,
    },
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/account/themes", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

theme = {
  name: "Sunset",
  body_background_colour:    "#fff8f0",
  body_font_family:          "Inter",
  body_font_size:            16,
  body_text_colour:          "#222222",
  heading_background_colour: "#e76f51",
  heading_font_family:       "Inter",
  heading_font_size:         32,
  heading_text_colour:       "#ffffff",
  link_colour:               "#e76f51",
  link_style:                0
}

uri = URI("#{ENV.fetch("API")}/account/themes")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { theme: }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body).dig("data", "id")
String body = """
    {
      "theme": {
        "name": "Sunset",
        "body_background_colour": "#fff8f0",
        "body_font_family": "Inter",
        "body_font_size": 16,
        "body_text_colour": "#222222",
        "heading_background_colour": "#e76f51",
        "heading_font_family": "Inter",
        "heading_font_size": 32,
        "heading_text_colour": "#ffffff",
        "link_colour": "#e76f51",
        "link_style": 0
      }
    }
    """;

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/themes"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "theme" => [
        "name"                      => "Sunset",
        "body_background_colour"    => "#fff8f0",
        "body_font_family"          => "Inter",
        "body_font_size"            => 16,
        "body_text_colour"          => "#222222",
        "heading_background_colour" => "#e76f51",
        "heading_font_family"       => "Inter",
        "heading_font_size"         => 32,
        "heading_text_colour"       => "#ffffff",
        "link_colour"               => "#e76f51",
        "link_style"                => 0,
    ],
]);

$ch = curl_init(getenv("API") . "/account/themes");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Returns 201 created with the serialised theme. user_id and username keys in the payload are silently dropped. Themes always belong to the authenticated user.

Get a theme

GET /api/v1/account/themes/:id

Fetches a single theme by ID. The caller can fetch any user-owned theme plus any system theme. A foreign user’s ID returns 404 not_found.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/account/themes/42"
import os, requests

response = requests.get(
    f"{os.environ['API']}/account/themes/42",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/themes/42`, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/account/themes/42", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/themes/42")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/themes/42"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/account/themes/42");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Update a theme

PATCH /api/v1/account/themes/:id

Updates a user-owned theme. Only user-owned themes are mutable; PATCH-ing a system theme ID returns 404 not_found. Colour-format and font-family validation failures surface as 422 validation_failed.

curl -X PATCH "$API/account/themes/42" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"theme":{"name":"Sunset v2","link_colour":"#d62828"}}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/account/themes/42",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"theme": {"name": "Sunset v2", "link_colour": "#d62828"}},
)
response.raise_for_status()
print(response.json())
const response = await fetch(`${process.env.API}/account/themes/42`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ theme: { name: "Sunset v2", link_colour: "#d62828" } })
});
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "theme": map[string]any{
        "name":        "Sunset v2",
        "link_colour": "#d62828",
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/account/themes/42", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/account/themes/42")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { theme: { name: "Sunset v2", link_colour: "#d62828" } }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"theme\":{\"name\":\"Sunset v2\",\"link_colour\":\"#d62828\"}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/themes/42"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "theme" => ["name" => "Sunset v2", "link_colour" => "#d62828"],
]);

$ch = curl_init(getenv("API") . "/account/themes/42");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Delete a theme

DELETE /api/v1/account/themes/:id

Deletes a user-owned theme. Returns 204 no_content. A system theme ID returns 404 not_found. A theme attached to at least one page returns 422 validation_failed with details[0].code = “theme_in_use”.

curl -X DELETE "$API/account/themes/42" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/account/themes/42",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
await fetch(`${process.env.API}/account/themes/42`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/account/themes/42", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/account/themes/42")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/account/themes/42"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/account/themes/42");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

19.Webhooks

Webhooks let your own services react to events in your BandTools account in real time. When a subscribed event fires, BandTools delivers a signed JSON payload to the HTTPS URL you configure.

Every action on /api/v1/webhooks requires the Headliner plan. Callers on the Solo or Artist plans receive 403 plan_feature_required.

For payload shape, signature verification, retry policy, and the complete list of event types, see the webhooks reference. Each webhook is identified by an integer id; most snippets below refer to it as $WEBHOOK_ID.

The full signing secret is shown only when a webhook is created or its secret is rotated. Store it immediately; subsequent reads expose only signing_secret_preview.

Resource fields

Field Type Notes
idintegerRead-only. Path identifier.
namestring4–100 characters. Unique per account.
urlstringHTTPS URL the payload is POSTed to. Up to 2048 characters. Unique per account.
enabledbooleanToggle delivery on or off. Setting this back to true resets consecutive_failures to 0.
event_typesarray of stringOne or more of newsletter.scheduled, newsletter.sent, subscriber.confirmed, subscriber.created, subscriber.removed, subscriber.unsubscribed. Returned alphabetically sorted.
consecutive_failuresintegerRead-only. Reaching 10 auto-disables the webhook.
signing_secret_previewstringRead-only. The last four characters of the signing secret, prefixed with “...” (e.g. "...a1b2"). Use this to confirm which secret a record holds.
signing_secretstring64-character hex HMAC-SHA256 key. Only present in responses to POST /webhooks and POST /webhooks/:id/rotate-signing-secret. All other endpoints omit this field.
created_at / updated_atiso8601Read-only.

Each account is capped at 10 webhooks. Hitting the cap returns 422 validation_failed with error.details[].code = "limit_reached".

Unknown values in event_types are rejected with 422 validation_failed and error.details[].code = "invalid" — the request leaves your data unchanged so typos surface early.

19.1 List webhooks

GET /api/v1/webhooks

Returns a paginated list. Use the sort parameter to control sorting (see Sorting and filtering). The full signing_secret is never returned by this endpoint — only signing_secret_preview.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/webhooks?page=1&per_page=25&sort=created_desc"
import os, requests

response = requests.get(
    f"{os.environ['API']}/webhooks",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    params={"page": 1, "per_page": 25},
)
response.raise_for_status()
print(response.json())
const url = new URL(`${process.env.API}/webhooks`);
url.search = new URLSearchParams({ page: "1", per_page: "25" });

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
u, _ := url.Parse(os.Getenv("API") + "/webhooks")
query := u.Query()
query.Set("page", "1")
query.Set("per_page", "25")
u.RawQuery = query.Encode()

req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/webhooks")
uri.query = URI.encode_www_form(page: 1, per_page: 25)

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks?page=1&per_page=25"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$query = http_build_query(["page" => 1, "per_page" => 25]);

$ch = curl_init(getenv("API") . "/webhooks?" . $query);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

19.2 Get a webhook

GET /api/v1/webhooks/:id

Returns a single webhook by id. The full signing_secret is not included — only signing_secret_preview. To mint a new secret use Rotate signing secret.

curl -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  "$API/webhooks/$WEBHOOK_ID"
import os, requests

response = requests.get(
    f"{os.environ['API']}/webhooks/{os.environ['WEBHOOK_ID']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/webhooks/${process.env.WEBHOOK_ID}`,
  { headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
req, _ := http.NewRequest("GET", os.Getenv("API")+"/webhooks/"+os.Getenv("WEBHOOK_ID"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/webhooks/#{ENV.fetch("WEBHOOK_ID")}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks/" + System.getenv("WEBHOOK_ID")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .GET()
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/webhooks/" . getenv("WEBHOOK_ID"));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

19.3 Create a webhook

POST /api/v1/webhooks

Creates a new webhook. name, url, and a non-empty event_types array are required. enabled defaults to true. Requires write scope.

The response includes the freshly minted signing_secret — this is the only time the full secret is returned. Capture it immediately and store it alongside your integration. If it is lost, use Rotate signing secret to mint a new one; the previous secret will be invalidated immediately.
{
  "webhook": {
    "name": "Production sync",
    "url": "https://hooks.example.com/bandtools",
    "enabled": true,
    "event_types": ["newsletter.sent", "subscriber.confirmed"]
  }
}
curl -X POST "$API/webhooks" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook":{"name":"Production sync","url":"https://hooks.example.com/bandtools","event_types":["newsletter.sent","subscriber.confirmed"]}}'
import os, requests

response = requests.post(
    f"{os.environ['API']}/webhooks",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"webhook": {
        "name": "Production sync",
        "url": "https://hooks.example.com/bandtools",
        "event_types": ["newsletter.sent", "subscriber.confirmed"],
    }},
)
response.raise_for_status()
secret = response.json()["data"]["signing_secret"]
const response = await fetch(`${process.env.API}/webhooks`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    webhook: {
      name: "Production sync",
      url: "https://hooks.example.com/bandtools",
      event_types: ["newsletter.sent", "subscriber.confirmed"]
    }
  })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { data } = await response.json();
const secret = data.signing_secret;
payload, _ := json.Marshal(map[string]any{
    "webhook": map[string]any{
        "name":        "Production sync",
        "url":         "https://hooks.example.com/bandtools",
        "event_types": []string{"newsletter.sent", "subscriber.confirmed"},
    },
})

req, _ := http.NewRequest("POST", os.Getenv("API")+"/webhooks", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/webhooks")
request = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = {
  webhook: {
    name: "Production sync",
    url: "https://hooks.example.com/bandtools",
    event_types: ["newsletter.sent", "subscriber.confirmed"]
  }
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
secret = JSON.parse(response.body).dig("data", "signing_secret")
String body = "{\"webhook\":{\"name\":\"Production sync\","
            + "\"url\":\"https://hooks.example.com/bandtools\","
            + "\"event_types\":[\"newsletter.sent\",\"subscriber.confirmed\"]}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "webhook" => [
        "name"        => "Production sync",
        "url"         => "https://hooks.example.com/bandtools",
        "event_types" => ["newsletter.sent", "subscriber.confirmed"],
    ],
]);

$ch = curl_init(getenv("API") . "/webhooks");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

19.4 Update a webhook

PATCH /api/v1/webhooks/:id

Send only the fields you want to change. Omitting event_types keeps the current set; supplying it replaces the set entirely (full replacement, not a delta). Re-enabling a webhook by setting enabled: true resets consecutive_failures to 0. The response does not include the signing_secret. Requires write scope.

curl -X PATCH "$API/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhook":{"enabled":false,"event_types":["newsletter.sent"]}}'
import os, requests

response = requests.patch(
    f"{os.environ['API']}/webhooks/{os.environ['WEBHOOK_ID']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
    json={"webhook": {"enabled": False, "event_types": ["newsletter.sent"]}},
)
response.raise_for_status()
print(response.json())
const response = await fetch(
  `${process.env.API}/webhooks/${process.env.WEBHOOK_ID}`,
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      webhook: { enabled: false, event_types: ["newsletter.sent"] }
    })
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
payload, _ := json.Marshal(map[string]any{
    "webhook": map[string]any{
        "enabled":     false,
        "event_types": []string{"newsletter.sent"},
    },
})

req, _ := http.NewRequest("PATCH", os.Getenv("API")+"/webhooks/"+os.Getenv("WEBHOOK_ID"), bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/webhooks/#{ENV.fetch("WEBHOOK_ID")}")
request = Net::HTTP::Patch.new(uri, {
  "Authorization" => "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}",
  "Content-Type"  => "application/json"
})
request.body = { webhook: { enabled: false, event_types: ["newsletter.sent"] } }.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
puts JSON.parse(response.body)
String body = "{\"webhook\":{\"enabled\":false,\"event_types\":[\"newsletter.sent\"]}}";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks/" + System.getenv("WEBHOOK_ID")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .header("Content-Type", "application/json")
    .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$payload = json_encode([
    "webhook" => ["enabled" => false, "event_types" => ["newsletter.sent"]],
]);

$ch = curl_init(getenv("API") . "/webhooks/" . getenv("WEBHOOK_ID"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top

19.5 Delete a webhook

DELETE /api/v1/webhooks/:id

Permanently deletes the webhook and its event subscriptions. Deliveries already in progress will still be sent, but no new events will be dispatched. Returns 204 No Content. Requires write scope.

curl -X DELETE "$API/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.delete(
    f"{os.environ['API']}/webhooks/{os.environ['WEBHOOK_ID']}",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
const response = await fetch(
  `${process.env.API}/webhooks/${process.env.WEBHOOK_ID}`,
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
req, _ := http.NewRequest("DELETE", os.Getenv("API")+"/webhooks/"+os.Getenv("WEBHOOK_ID"), nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
response.Body.Close()
require "net/http"

uri = URI("#{ENV.fetch("API")}/webhooks/#{ENV.fetch("WEBHOOK_ID")}")
request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks/" + System.getenv("WEBHOOK_ID")))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .DELETE()
    .build();

HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.discarding());
$ch = curl_init(getenv("API") . "/webhooks/" . getenv("WEBHOOK_ID"));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

curl_exec($ch);
curl_close($ch);

Top

19.6 Rotate signing secret

POST /api/v1/webhooks/:id/rotate-signing-secret

Mints a new signing_secret for the webhook. The previous secret is invalidated immediately. Requires write scope.

The new signing_secret is returned in the response — this is the only time it is exposed in full. Capture it before you move on; subsequent reads expose only signing_secret_preview.
curl -X POST "$API/webhooks/$WEBHOOK_ID/rotate-signing-secret" \
  -H "Authorization: Bearer $BANDTOOLS_API_TOKEN"
import os, requests

response = requests.post(
    f"{os.environ['API']}/webhooks/{os.environ['WEBHOOK_ID']}/rotate-signing-secret",
    headers={"Authorization": f"Bearer {os.environ['BANDTOOLS_API_TOKEN']}"},
)
response.raise_for_status()
secret = response.json()["data"]["signing_secret"]
const response = await fetch(
  `${process.env.API}/webhooks/${process.env.WEBHOOK_ID}/rotate-signing-secret`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.BANDTOOLS_API_TOKEN}` }
  }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { data } = await response.json();
const secret = data.signing_secret;
req, _ := http.NewRequest("POST", os.Getenv("API")+"/webhooks/"+os.Getenv("WEBHOOK_ID")+"/rotate-signing-secret", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BANDTOOLS_API_TOKEN"))

response, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer response.Body.Close()

body, _ := io.ReadAll(response.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("#{ENV.fetch("API")}/webhooks/#{ENV.fetch("WEBHOOK_ID")}/rotate-signing-secret")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("BANDTOOLS_API_TOKEN")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
secret = JSON.parse(response.body).dig("data", "signing_secret")
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(System.getenv("API") + "/webhooks/" + System.getenv("WEBHOOK_ID") + "/rotate-signing-secret"))
    .header("Authorization", "Bearer " + System.getenv("BANDTOOLS_API_TOKEN"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
$ch = curl_init(getenv("API") . "/webhooks/" . getenv("WEBHOOK_ID") . "/rotate-signing-secret");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer " . getenv("BANDTOOLS_API_TOKEN"),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
curl_close($ch);
echo $response;

Top