Skip to content

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
StatusMeaningAction
pendingAccepted, work being queuedKeep polling
processingRendering and printing. Not cancellableKeep polling
completedAll documents rendered and dispatchedDone
failedCould not finish. Some mail may already be printedInvestigate — 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:

  1. Do not resend automatically.
  2. Note the progress value — it indicates roughly how much was dispatched.
  3. Check the campaign in the web app.
  4. If a resend is warranted, decide who to resend to. A fresh Idempotency-Key creates 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.

ResponseMeaning
201It did not exist; created now
200It 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 ​

StatuscodeMeaning
400validation_failedThe id is not a UUID
401unauthorizedKey problem — stop polling, alert
403forbiddenKey lacks campaigns:read — stop polling
404not_foundCampaign not found — wrong id or wrong account
429rate_limitedWait Retry-After seconds, keep polling
500internal_errorTransient; retry with backoff
503service_unavailableDraining; 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 ​