Appearance
Polling & failures
There are no webhooks. After creating a campaign you poll GET /api/v1/campaigns/:id until it finishes.
Permissions needed: campaigns:read.
Lifecycle
pending ──► processing ──► completed
└─► failed| Status | Meaning | Action |
|---|---|---|
pending | Accepted, work being queued | Keep polling |
processing | Rendering and printing. Not cancellable | Keep polling |
completed | All documents rendered and dispatched | Done |
failed | Could not finish. Some mail may already be printed | Investigate — do not auto-retry |
progress runs 0–100.
A polling loop
Exponential backoff, capped, with an overall timeout.
ts
type Campaign = {
id: string;
status: "pending" | "processing" | "completed" | "failed";
progress: number;
publishedAt: string | null;
};
async function waitForCampaign(
id: string,
{ timeoutMs = 30 * 60_000 } = {},
): Promise<Campaign> {
const deadline = Date.now() + timeoutMs;
let delay = 2_000;
for (;;) {
const res = await fetch(`${BASE}/api/v1/campaigns/${id}`, {
headers: { "x-api-key": KEY },
});
if (res.status === 429) {
// Over the rate limit: wait longer, do not treat as a campaign failure.
await sleep(Math.min(delay * 2, 60_000));
delay = Math.min(delay * 2, 30_000);
continue;
}
const body = await res.json();
if (!res.ok) {
throw Object.assign(new Error(body.error.message), {
code: body.error.code,
});
}
const campaign: Campaign = body.data;
if (campaign.status === "completed") return campaign;
if (campaign.status === "failed") {
throw Object.assign(
new Error(`Campaign ${id} failed at ${campaign.progress}%`),
{ campaign },
);
}
if (Date.now() > deadline) {
throw new Error(
`Campaign ${id} still ${campaign.status} at ${campaign.progress}% after timeout`,
);
}
await sleep(delay + Math.random() * 500);
delay = Math.min(delay * 2, 30_000);
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));Do not poll every second
The rate limit is 100 requests per 60 seconds, so one-second polling burns the whole budget in under two minutes — for a value that changes a handful of times. Start at 2 s and back off. See Rate limits.
A timeout means your wait expired, not the campaign — it is still running. Keep the id and check later.
Handling failed
failed does not mean nothing was printed
A campaign can fail after some documents have already gone to print. Retrying blindly mails those recipients twice — the one outcome physical mail cannot take back.
failed is an operator event, not something to automate around.
ts
try {
await waitForCampaign(campaignId);
} catch (err) {
if ((err as { campaign?: Campaign }).campaign?.status === "failed") {
await alertOperator({
campaignId,
progress: (err as { campaign: Campaign }).campaign.progress,
message: "Campaign failed — manual review needed before any resend",
});
return; // deliberately no retry
}
throw err;
}What to do:
- Do not resend automatically.
- Note the
progressvalue — it indicates roughly how much was dispatched. - Check the campaign in the web app.
- If a resend is warranted, decide who to resend to. A fresh
Idempotency-Keycreates a genuinely new campaign, so scope it to the recipients that did not receive mail.
Reusing the original key only replays the failed campaign.
Uncertain creates
If the create request itself times out, you do not know whether the campaign exists. This is exactly what Idempotency-Key is for: retry with the same key.
| Response | Meaning |
|---|---|
| 201 | It did not exist; created now |
| 200 | It already existed; here it is |
ts
const res = await createCampaign(payload, idempotencyKey);
console.log(res.status === 201 ? "created" : "already existed");Without a stored key there is no way to ask "did my create succeed" — campaigns cannot be looked up by payload. Persist the key before the request.
How long it takes
Duration scales with recipient count — each is a rendered document. Small campaigns finish inside a minute, large ones take several. The API gives no completion estimate, so use a generous timeout (30 minutes is safe) and treat exceeding it as "check on this", not "it failed".
Poll one campaign, not all of them
For a dashboard, use GET /api/v1/campaigns/status-counts — one request for the whole account:
ts
const res = await fetch(`${BASE}/api/v1/campaigns/status-counts`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
// Absent keys mean zero.
const inFlight = (data.pending ?? 0) + (data.processing ?? 0);Cheaper than listing campaigns and counting client-side, and it will not exhaust the rate limit.
Background jobs
For a queue worker, poll in the job rather than holding a request open:
ts
// Enqueue a check instead of blocking.
async function checkCampaign(job: { campaignId: string; attempt: number }) {
const res = await fetch(`${BASE}/api/v1/campaigns/${job.campaignId}`, {
headers: { "x-api-key": KEY },
});
const { data } = await res.json();
if (data.status === "completed") return markDone(job.campaignId);
if (data.status === "failed") return alertOperator(job);
// Re-enqueue with growing delay, capped.
await enqueue(
{ ...job, attempt: job.attempt + 1 },
{ delayMs: Math.min(2 ** job.attempt * 1000, 60_000) },
);
}This survives restarts and does not tie up a connection for minutes.
Errors while polling
| Status | code | Meaning |
|---|---|---|
| 400 | validation_failed | The id is not a UUID |
| 401 | unauthorized | Key problem — stop polling, alert |
| 403 | forbidden | Key lacks campaigns:read — stop polling |
| 404 | not_found | Campaign not found — wrong id or wrong account |
| 429 | rate_limited | Wait Retry-After seconds, keep polling |
| 500 | internal_error | Transient; retry with backoff |
| 503 | service_unavailable | Draining; retry with backoff |
Only 429, 500 and 503 should keep the loop alive. The 4xx cases are configuration problems that will not resolve on their own.
Next
- Idempotency — safe retries
- Rate limits — backoff
- Errors — the full catalog
- Production checklist