# Managing campaigns

> Compose a marketing email against a sending domain, preview merge tags, then send it now or schedule it for later — with a per-contact unsubscribe and budget-capped async delivery.

A **campaign** is a single marketing email sent to every subscribed contact on one of your sending **domains** — the domain’s [contact pool](https://www.sendping.co/docs/audiences/overview). You compose it once — `from`, `subject`, and an HTML and/or text body — and SendPing fans it out, personalizing each copy with merge tags and appending a per-contact unsubscribe.

Campaigns are owner-scoped and created as a **draft**. A draft can be edited freely; once you send or schedule it, the body is locked and delivery proceeds asynchronously.

## The campaign object

A campaign is returned in this shape:

```json
{
  "object": "campaign",
  "id": "8f5c2a1e-7b3d-4f9a-9c12-2e6d4a7b8c90",
  "name": "June newsletter",
  "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf",
  "segment_id": null,
  "topic_id": null,
  "from": "Acme <news@yourdomain.com>",
  "subject": "What's new in June",
  "html": "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
  "text": "Hi {{FIRST_NAME}}, here's the latest…",
  "reply_to": null,
  "preview_text": null,
  "status": "draft",
  "scheduled_at": null,
  "sent_at": null,
  "created_at": "2026-06-23T10:00:00.000Z",
  "ab_test": { "enabled": false },
  "recurrence": null,
  "parent_campaign_id": null
}
```

## Compose against a domain

Create a draft with [`POST /campaigns`](https://www.sendping.co/docs/api/campaigns-create). The `domain` picks the recipients — the campaign fans out to that domain’s contact pool — and the `from` address must be on a **verified domain** you own (it does not have to match `domain`). Provide `html`, `text`, or both.

For a manual batch, send POST /campaigns/:id/send with {"recipient_limit":5000}. This selects up to 5,000 eligible unsent contacts and stops after that batch. Once completed, call the same endpoint with another recipient_limit, or use Send next batch in campaign details. Previous attempts are excluded even after delivery-log retention. GET /campaigns/:id/batches shows remaining recipients and batch progress. Manual batches support one-time campaigns without A/B testing. Email quotas and credits still apply; this is separate from daily_batch_size, which automatically continues delivery on later days.

**Node.js**

```js
import { SendPing } from 'sendping';

const mb = new SendPing('mb_xxxxxxxxx');

const { data, error } = await mb.campaigns.create({
  "domain": "yourdomain.com",
  "from": "Acme <news@yourdomain.com>",
  "subject": "What's new in June",
  "html": "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
  "name": "June newsletter"
});
console.log({ data, error });
```

**Ruby**

```ruby
require "sendping"

SendPing.api_key = "mb_xxxxxxxxx"

SendPing::Campaigns.create({
  "domain": "yourdomain.com",
  "from": "Acme <news@yourdomain.com>",
  "subject": "What's new in June",
  "html": "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
  "name": "June newsletter"
})
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

use SendPing\SendPing;

$sendping = SendPing::client('mb_xxxxxxxxx');

$sendping->campaigns->create([
  'domain' => "yourdomain.com",
  'from' => "Acme <news@yourdomain.com>",
  'subject' => "What's new in June",
  'html' => "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
  'name' => "June newsletter"
]);
```

**Python**

```python
import sendping

sendping.api_key = "mb_xxxxxxxxx"

sendping.Campaigns.create({
  "domain": "yourdomain.com",
  "from": "Acme <news@yourdomain.com>",
  "subject": "What's new in June",
  "html": "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
  "name": "June newsletter"
})
```

**Go**

```go
import "github.com/shekhu10/sendping-sdks/sendping-go"

client := sendping.NewClient("mb_xxxxxxxxx")

campaign, err := client.Campaigns.Create(&sendping.CreateCampaignRequest{
    Domain:  "yourdomain.com",
    From:    "Acme <news@yourdomain.com>",
    Subject: "What's new in June",
    Html:    "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
    Name:    "June newsletter",
})
```

**Rust**

```rust
use sendping::{CreateCampaignOptions, SendPing};

let mb = SendPing::new("mb_xxxxxxxxx");

let params = CreateCampaignOptions::new(
    "yourdomain.com",
    "Acme <news@yourdomain.com>",
    "What's new in June",
)
.with_html("<p>Hi {{FIRST_NAME}}, here's the latest…</p>")
.with_name("June newsletter");
let _campaign = mb.campaigns.create(params).await?;
```

**Java**

```java
import co.sendping.SendPing;
import co.sendping.SendPingResponse;
import co.sendping.requests.CreateCampaignRequest;

SendPing sendping = new SendPing("mb_xxxxxxxxx");

CreateCampaignRequest request = CreateCampaignRequest.builder()
        .domain("yourdomain.com")
        .from("Acme <news@yourdomain.com>")
        .subject("What's new in June")
        .html("<p>Hi {{FIRST_NAME}}, here's the latest…</p>")
        .name("June newsletter")
        .build();

SendPingResponse response = sendping.campaigns().create(request);
```

**.NET**

```csharp
using SendPing;

ISendPing sendping = SendPingClient.Create("mb_xxxxxxxxx");

var resp = await sendping.CampaignCreateAsync(new CampaignCreateOptions
{
    Domain = "yourdomain.com",
    From = "Acme <news@yourdomain.com>",
    Subject = "What's new in June",
    HtmlBody = "<p>Hi {{FIRST_NAME}}, here's the latest…</p>",
    Name = "June newsletter",
});
```

**cURL**

```bash
curl -X POST 'https://www.sendping.co/api/campaigns' \
  -H 'Authorization: Bearer mb_xxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -d '{
  "domain": "yourdomain.com",
  "from": "Acme <news@yourdomain.com>",
  "subject": "What'\''s new in June",
  "html": "<p>Hi {{FIRST_NAME}}, here'\''s the latest…</p>",
  "name": "June newsletter"
}'
```

**CLI**

```bash
sendping campaigns create \
  --domain 'yourdomain.com' \
  --from 'Acme <news@yourdomain.com>' \
  --subject 'What'\''s new in June' \
  --html '<p>Hi {{FIRST_NAME}}, here'\''s the latest…</p>' \
  --name 'June newsletter'
```

## Merge tags

Before each copy is sent, SendPing substitutes the following tags for that contact. Unknown or missing fields render as an empty string — substitution never fails.

| Tag | Replaced with |
| --- | --- |
| `{{FIRST_NAME}}` | The contact's first name (empty if unset). |
| `{{LAST_NAME}}` | The contact's last name (empty if unset). |
| `{{EMAIL}}` | The contact's email address. |
| `{{{SENDPING_UNSUBSCRIBE_URL}}}` | This contact's unique one-click unsubscribe URL. Use the triple-brace form so the URL is inserted raw. |

> **Note:** Whitespace inside the braces is tolerated — `{{ FIRST_NAME }}` works the same as `{{FIRST_NAME}}`.

## Unsubscribe is automatic

Every campaign copy gets a **per-contact unsubscribe footer** and an [RFC 8058](https://www.rfc-editor.org/rfc/rfc8058) `List-Unsubscribe` header injected automatically, so recipients can always opt out (and inbox providers can offer one-click unsubscribe). Unsubscribing flips the contact to `unsubscribed` in that audience, and they are skipped on future campaigns.

You can also place the unsubscribe link inline by including `{{{SENDPING_UNSUBSCRIBE_URL}}}` in your body — the automatic footer is added regardless.

## Send or schedule

When the draft is ready, call [`POST /campaigns/:id/send`](https://www.sendping.co/docs/api/campaigns-send). With no body the campaign sends as soon as possible (status → `queued`). Pass `scheduled_at` with a future ISO timestamp to schedule it (status → `scheduled`).

**Node.js**

```js
import { SendPing } from 'sendping';

const mb = new SendPing('mb_xxxxxxxxx');

const { data, error } = await mb.campaigns.send('CAMPAIGN_ID', { "scheduled_at": "2026-07-01T09:00:00Z" });
console.log({ data, error });
```

**Ruby**

```ruby
require "sendping"

SendPing.api_key = "mb_xxxxxxxxx"

SendPing::Campaigns.send("CAMPAIGN_ID", {
  "scheduled_at": "2026-07-01T09:00:00Z"
})
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

use SendPing\SendPing;

$sendping = SendPing::client('mb_xxxxxxxxx');

$sendping->campaigns->send('CAMPAIGN_ID', [
  'scheduled_at' => "2026-07-01T09:00:00Z"
]);
```

**Python**

```python
import sendping

sendping.api_key = "mb_xxxxxxxxx"

sendping.Campaigns.send("CAMPAIGN_ID", {
  "scheduled_at": "2026-07-01T09:00:00Z"
})
```

**Go**

```go
import "github.com/shekhu10/sendping-sdks/sendping-go"

client := sendping.NewClient("mb_xxxxxxxxx")

sent, err := client.Campaigns.Send("CAMPAIGN_ID", &sendping.SendCampaignRequest{
    ScheduledAt: "2026-07-01T09:00:00Z",
})
```

**Rust**

```rust
use sendping::SendPing;

let mb = SendPing::new("mb_xxxxxxxxx");

let _sent = mb.campaigns.send("CAMPAIGN_ID", Some("2026-07-01T09:00:00Z")).await?;
```

**Java**

```java
import co.sendping.SendPing;
import co.sendping.SendPingResponse;

SendPing sendping = new SendPing("mb_xxxxxxxxx");

SendPingResponse response = sendping.campaigns().send(
        "CAMPAIGN_ID",
        "2026-07-01T09:00:00Z");
```

**.NET**

```csharp
using SendPing;

ISendPing sendping = SendPingClient.Create("mb_xxxxxxxxx");

var resp = await sendping.CampaignSendAsync("CAMPAIGN_ID", "2026-07-01T09:00:00Z");
```

**cURL**

```bash
curl -X POST 'https://www.sendping.co/api/campaigns/CAMPAIGN_ID/send' \
  -H 'Authorization: Bearer mb_xxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -d '{ "scheduled_at": "2026-07-01T09:00:00Z" }'
```

**CLI**

```bash
sendping campaigns send CAMPAIGN_ID \
  --scheduled-at '2026-07-01T09:00:00Z'
```

> **Warning:** Sending checks the campaign has a `from`, `subject`, and a body, and that the from-domain is verified. A draft is the only state you can send — once `queued`, `scheduled`, or `sent`, the send endpoint returns a `validation_error`.

## Cancel or stop a campaign

Changed your mind before a scheduled send fires? Call [`POST /campaigns/:id/cancel`](https://www.sendping.co/docs/api/campaigns-cancel). This cancels the pending delivery and returns the campaign to `draft`, so you can edit it and send or schedule it again. A reputation-`paused` campaign can also be returned to draft; already-sent recipients remain in its deduplication ledger.

The same endpoint **stops** a campaign that is already `queued`. Copies already handed to the delivery provider cannot be recalled, but all remaining work is cancelled — for a staggered campaign (`daily_batch_size`) that means every future batch-day. A stopped campaign becomes `canceled` rather than `draft`, because it is partly mailed and a draft would be re-sendable; already-sent recipients stay in its deduplication ledger.

**Node.js**

```js
import { SendPing } from 'sendping';

const mb = new SendPing('mb_xxxxxxxxx');

const { data, error } = await mb.campaigns.cancel('CAMPAIGN_ID');
console.log({ data, error });
```

**Ruby**

```ruby
require "sendping"

SendPing.api_key = "mb_xxxxxxxxx"

SendPing::Campaigns.cancel("CAMPAIGN_ID")
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

use SendPing\SendPing;

$sendping = SendPing::client('mb_xxxxxxxxx');

$sendping->campaigns->cancel('CAMPAIGN_ID');
```

**Python**

```python
import sendping

sendping.api_key = "mb_xxxxxxxxx"

sendping.Campaigns.cancel("CAMPAIGN_ID")
```

**Go**

```go
import "github.com/shekhu10/sendping-sdks/sendping-go"

client := sendping.NewClient("mb_xxxxxxxxx")

campaign, err := client.Campaigns.Cancel("CAMPAIGN_ID")
```

**Rust**

```rust
use sendping::SendPing;

let mb = SendPing::new("mb_xxxxxxxxx");

let _campaign = mb.campaigns.cancel("CAMPAIGN_ID").await?;
```

**Java**

```java
import co.sendping.SendPing;
import co.sendping.SendPingResponse;

SendPing sendping = new SendPing("mb_xxxxxxxxx");

SendPingResponse response = sendping.campaigns().cancel("CAMPAIGN_ID");
```

**.NET**

```csharp
using SendPing;

ISendPing sendping = SendPingClient.Create("mb_xxxxxxxxx");

var resp = await sendping.CampaignCancelAsync("CAMPAIGN_ID");
```

**cURL**

```bash
curl -X POST 'https://www.sendping.co/api/campaigns/CAMPAIGN_ID/cancel' \
  -H 'Authorization: Bearer mb_xxxxxxxxx'
```

**CLI**

```bash
sendping campaigns cancel CAMPAIGN_ID
```

## Async, budget-capped delivery

Delivery is handled by a background fan-out job — the send call returns immediately. Each recipient is sent as an individual email. The complete campaign must fit inside the remaining monthly plan allowance plus prepaid credits before it is accepted; a 10,000-recipient campaign with only 500 sends available is rejected before sending any recipient. A **staggered** campaign (one with `daily_batch_size`) is budgeted one batch-day at a time instead, so it is accepted as long as the next day’s batch fits and it waits for the rolling window to refill when it does not. Daily and reputation warm-up capacity are pacing limits instead: eligible recipients are sent in safe batches and the untouched remainder resumes at the next rolling-window reset. Suppressed and unsubscribed contacts are skipped.

## Statuses

The API reports a campaign in one of the following states.

| Status | Meaning |
| --- | --- |
| `draft` | Created but not sent. Editable; the only state from which you can send. |
| `scheduled` | A future `scheduled_at` was set. Will start sending at that time. Canceling a scheduled campaign returns it to `draft` and prevents the send. |
| `recurring` | The campaign has a `recurrence` and acts as a template: each occurrence is sent as its own child campaign while the template stays in this status. Canceling it stops the recurrence and returns the template to `draft`. |
| `paused` | Reputation protection stopped the remaining fan-out. Contact support for review; an admin reinstatement resumes only recipients that have not already been sent. |
| `queued` | The send is in flight — the fan-out job is delivering copies to the audience. (Internally this is the `sending` phase; the API surfaces it as `queued`.) |
| `canceled` | A `queued` send was stopped before it finished. Copies already handed off were still delivered; no further recipients are mailed. Terminal — a canceled campaign cannot be resumed or re-sent (unlike a canceled `scheduled` campaign, which returns to `draft`). |
| `sent` | All copies have been handed off for delivery. |
| `failed` | The campaign could not be delivered. |

> **Note:** Only `draft`, `scheduled` and `recurring` campaigns can be deleted; any other status returns a `validation_error`. Deleting a scheduled or recurring campaign also cancels its pending job. A campaign that is already `queued` or `sent` cannot be removed. Individual copies already handed to the delivery provider cannot be recalled — but a `queued` campaign can be **stopped**, which cancels all remaining work (for a staggered campaign, every future batch-day) and moves it to `canceled`.
