The Bulko REST API lets an application import recipients, launch a managed email campaign, read delivery statistics, retrieve classified replies, and check account credits. This guide uses the production v1 endpoints with Python and cURL.

The API is intended for server-side integrations. Keep the token out of browser code, mobile applications, public repositories, and logs. Sending remains subject to available credits, suppression checks, content controls, the Acceptable Use Policy, and applicable law.

What the API supports

MethodEndpointPurpose
POST/api/v1/subscribers/importCreate or update a recipient list
POST/api/v1/campaigns/launchCreate and queue a campaign
GET/api/v1/campaigns/{id}/statsRead campaign statistics
GET/api/v1/repliesRead replies and classifications
GET/api/v1/account/balanceCheck email and AI credits
GET/api/v1/smtp/accessesInspect separately purchased SMTP Access

Outgoing webhooks are not available in production yet. Poll the statistics and replies endpoints for current automation. The machine-readable OpenAPI 3.1 schema is the canonical contract.

1. Create an API token

Sign in and open API keys. Give the key a descriptive name and copy it immediately: the complete token is displayed only once. Requests use the header Authorization: Bearer bulko_live_.... Revoke a key from the same page if it is exposed.

export BULKO_API_TOKEN='bulko_live_replace_me'

The examples use an environment variable so the secret does not appear in source code.

2. Check authentication and credits

cURL

curl --fail-with-body \
  -H "Authorization: Bearer $BULKO_API_TOKEN" \
  https://bulko.io/api/v1/account/balance

Python

import os
import requests

BASE_URL = "https://bulko.io/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BULKO_API_TOKEN']}",
    "Content-Type": "application/json",
}

response = requests.get(f"{BASE_URL}/account/balance", headers=HEADERS, timeout=30)
response.raise_for_status()
print(response.json()["balance"])

3. Import a validated recipient list

An import accepts up to 10,000 entries. Each item may be a string or an object with email and name. Invalid, duplicate, and globally suppressed addresses are skipped and counted in the response.

import requests

payload = {
    "list_name": "Product demo requests",
    "emails": [
        {"email": "[email protected]", "name": "Alex"},
        "[email protected]",
    ],
}
response = requests.post(
    f"{BASE_URL}/subscribers/import",
    headers=HEADERS,
    json=payload,
    timeout=30,
)
response.raise_for_status()
list_id = response.json()["list"]["id"]
print("List ID:", list_id)

Validation by the endpoint is not permission to contact an address. Source recipients lawfully, document the applicable basis, and maintain an opt-out process.

4. Launch a campaign

Provide either subscriber_list_id or an inline recipients array. The required fields are name, subject, and body. A successful request returns HTTP 202 because delivery runs asynchronously.

campaign_payload = {
    "name": "Requested product update",
    "subject": "A useful update for your team",
    "body": "<p>Hello, here is the update you requested.</p>",
    "subscriber_list_id": list_id,
    "reply_to": "[email protected]",
    "language": "en",
    "enable_tracking": True,
}
response = requests.post(
    f"{BASE_URL}/campaigns/launch",
    headers=HEADERS,
    json=campaign_payload,
    timeout=30,
)
response.raise_for_status()
campaign = response.json()
campaign_id = campaign["id"]
print(campaign)

The launch response also reports eligible recipients, suppressed recipients, and the campaign statistics URL. Bulko always keeps unsubscribe handling enabled for API-created campaigns.

5. Poll campaign status safely

import time

for attempt in range(20):
    response = requests.get(
        f"{BASE_URL}/campaigns/{campaign_id}/stats",
        headers=HEADERS,
        timeout=30,
    )
    response.raise_for_status()
    stats = response.json()["campaign"]
    print(stats["status"], stats["sent"], stats["failed"])
    if stats["status"] in {"completed", "failed"}:
        break
    time.sleep(min(60, 5 * (attempt + 1)))

Use gradual backoff rather than polling every second. The current limit is 60 requests per minute per token; HTTP 429 includes a Retry-After header.

6. Retrieve replies

params = {"status": "positive", "limit": 50, "offset": 0}
response = requests.get(
    f"{BASE_URL}/replies",
    headers=HEADERS,
    params=params,
    timeout=30,
)
response.raise_for_status()
for reply in response.json()["items"]:
    print(reply["campaign_id"], reply["from"], reply["status"])

Supported filters are documented in OpenAPI. Replies are returned only for campaigns owned by the token's user.

Error handling

StatusMeaningRecommended action
400Invalid JSON or field valueFix the request; do not retry unchanged
401Missing, invalid, or revoked tokenCheck the Bearer header or rotate the key
402Insufficient email creditsCheck balance and add credits
403Content blocked by safety controlsReview the message and policy
404Owned resource was not foundVerify the ID and account
429Rate limit exceededWait for Retry-After, then retry

For network errors and temporary 5xx responses, use bounded retries with exponential backoff and an idempotency strategy in your application. Do not automatically repeat a campaign launch unless your system has confirmed that the first request was not accepted.

Production checklist

  • Store the API token in a secret manager or protected environment variable.
  • Use separate keys for separate systems and revoke unused keys.
  • Set connection and read timeouts on every request.
  • Log request IDs and campaign IDs, but never tokens or full recipient data.
  • Respect suppression results and never attempt to re-add opted-out recipients.
  • Use the OpenAPI schema to generate typed clients and validate payloads.

See the API and integrations overview for JavaScript and PHP examples, SMTP Access, current limitations, and the webhook roadmap.