Campaigns
Understand campaign behavior, statuses, and how they interact with your contacts and audiences.
Campaigns are created and managed in the Keplars dashboard. This page documents campaign behavior that affects your contacts and API integrations. For a step-by-step walkthrough, see the Campaign Guide.
Campaign Statuses
| Status | Description |
|---|---|
DRAFT | Campaign created but not yet sent or scheduled |
SCHEDULED | Set to send at a future date and time |
SENDING | Currently dispatching emails to contacts |
PAUSED | Sending paused mid-dispatch |
SENT | All emails dispatched |
COMPLETED | Dispatch finished, all delivery statuses resolved |
FAILED | Campaign failed before any emails were sent |
CANCELLED | Manually cancelled before completion |
Who Receives a Campaign
When a campaign is sent, Keplars filters the audience automatically. A contact receives the email only if both conditions are true:
- Their per-audience status is
subscribed- they have not unsubscribed from this audience - Their global status is
subscribed- they have not been globally blocked (hard bounce, spam complaint)
Contacts that fail either check are silently skipped - they are never removed from the audience.
Unsubscribe Behavior
Keplars uses per-audience unsubscribes. When a contact clicks an unsubscribe link in a campaign email:
- Only their membership in that campaign's audience is set to
unsubscribed - Their memberships in other audiences are not affected
- The contact record is not deleted
- They can re-subscribe via the audience's subscribe form (
subscribeUrl)
This means a contact can be unsubscribed from your Newsletter audience while remaining subscribed to your Product Updates audience.
Template requirement
Every campaign template must contain {"{{"}unsubscribeUrl{"}}"} somewhere in the body. Keplars will block sending if this placeholder is missing. This ensures every recipient always has a way to unsubscribe.
Subscribe Form
Every audience has a public signup form at its subscribeUrl. No authentication is required - share it anywhere.
GET /s/:subscribeToken → Shows HTML signup form
POST /s/:subscribeToken → Processes subscriptionThe subscribeUrl is returned in every audience API response:
{
"id": "019dd568-...",
"name": "Newsletter",
"subscribeUrl": "https://keplars.com/s/a3f9bc4d...",
...
}Programmatic Subscribe
You can POST to the subscribe URL directly from your own signup form or backend - no API key required.
curl -X POST "https://keplars.com/s/a3f9bc4d..." \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","first_name":"Jane","last_name":"Smith"}'const res = await fetch(
'https://keplars.com/s/a3f9bc4d...',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: '[email protected]',
first_name: 'Jane',
last_name: 'Smith',
}),
},
);
const { data } = await res.json();import requests, os
res = requests.post(
'https://keplars.com/s/a3f9bc4d...',
json={
'email': '[email protected]',
'first_name': 'Jane',
'last_name': 'Smith',
},
)
data = res.json()['data']package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body := bytes.NewBufferString(`{"email":"[email protected]","first_name":"Jane","last_name":"Smith"}`)
req, _ := http.NewRequest("POST",
"https://keplars.com/s/a3f9bc4d...", body)
req.Header.Set("Content-Type", "application/json")
resp, _ := (&http.Client{}).Do(req)
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
fmt.Println(string(b))
}<?php
$ch = curl_init('https://keplars.com/s/a3f9bc4d...');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'email' => '[email protected]',
'first_name' => 'Jane',
'last_name' => 'Smith',
]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
],
]);
$data = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);using System.Net.Http;
using System.Net.Http.Json;
var client = new HttpClient();
var response = await client.PostAsJsonAsync(
"https://keplars.com/s/a3f9bc4d...",
new
{
email = "[email protected]",
first_name = "Jane",
last_name = "Smith"
}
);
var body = await response.Content.ReadAsStringAsync();use reqwest::Client;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let res = client
.post("https://keplars.com/s/a3f9bc4d...")
.json(&json!({
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith"
}))
.send()
.await?;
let data: serde_json::Value = res.json().await?;
println!("{}", data);
Ok(())
}#include <curl/curl.h>
#include <cstdlib>
#include <string>
int main() {
CURL* curl = curl_easy_init();
const char* payload = "{\"email\":\"[email protected]\",\"first_name\":\"Jane\",\"last_name\":\"Smith\"}";
struct curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_URL, "https://keplars.com/s/a3f9bc4d...");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload);
curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
}#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
CURL* curl = curl_easy_init();
const char* payload = "{\"email\":\"[email protected]\",\"first_name\":\"Jane\",\"last_name\":\"Smith\"}";
struct curl_slist* headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_URL, "https://keplars.com/s/a3f9bc4d...");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload);
curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return 0;
}The endpoint also accepts application/x-www-form-urlencoded for plain HTML forms:
<form action="https://keplars.com/s/a3f9bc4d..." method="POST">
<input type="email" name="email" required />
<input type="text" name="first_name" />
<input type="text" name="last_name" />
<button type="submit">Subscribe</button>
</form>How it works
- A visitor submits their email (and optionally first/last name)
- If they are new, a contact is created and added to the audience as
subscribed - If they were previously
unsubscribed, their status is restored tosubscribed - If they are already
subscribed, the request is a no-op - no duplicate entry
Contact Status in Responses
When you fetch contacts via the API, the status field reflects the contact's state in the queried audience, not a global state:
{
"id": "019dd571-...",
"email": "[email protected]",
"status": "unsubscribed",
...
}The same contact may show subscribed when fetched against a different audience.
Campaign Contact Tracking
The dashboard shows per-contact delivery data for every campaign:
| Metric | Description |
|---|---|
dispatched | Email was handed to the mail provider |
delivered | Provider confirmed delivery to inbox |
opened | Recipient opened the email (pixel tracked) |
clicked | Recipient clicked a tracked link |
bounced | Email bounced (hard or soft) |
failed | Delivery failed after retries |
Bounced contacts are not automatically unsubscribed - manage global blocks via the suppression list in the dashboard.