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
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v1/subscribers/import | Create or update a recipient list |
| POST | /api/v1/campaigns/launch | Create and queue a campaign |
| GET | /api/v1/campaigns/{id}/stats | Read campaign statistics |
| GET | /api/v1/replies | Read replies and classifications |
| GET | /api/v1/account/balance | Check email and AI credits |
| GET | /api/v1/smtp/accesses | Inspect 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
| Status | Meaning | Recommended action |
|---|---|---|
| 400 | Invalid JSON or field value | Fix the request; do not retry unchanged |
| 401 | Missing, invalid, or revoked token | Check the Bearer header or rotate the key |
| 402 | Insufficient email credits | Check balance and add credits |
| 403 | Content blocked by safety controls | Review the message and policy |
| 404 | Owned resource was not found | Verify the ID and account |
| 429 | Rate limit exceeded | Wait 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.