← All field notes
Webhooks

Webhooks versus polling for operations that finish in the physical world

Push is nicer and polling is necessary. Rules for signing, retrying, and replaying webhooks, rules for backoff and filters when polling, and the hybrid that treats a webhook as a hint and the poll as the truth.

An operation that ends in the physical world does not finish when the API call returns. A print job finishes when a spooler accepts it seconds later, or when a machine that was asleep wakes up an hour later, or never, with the outcome unknown. Your integration needs to learn the result, and there are exactly two ways: the vendor tells you (webhooks), or you ask (polling).

The standard advice is "use webhooks, polling is wasteful." For slow physical operations that advice is half right. Push is a better experience. Polling is the only path that survives a missed delivery, and the design that works treats a webhook as a hint and a poll as the truth.

We do not ship webhooks today, so this post is partly an explanation of why polling is not a consolation prize, and partly the design we hold ourselves to when we do ship them.

Physical operations finish late, out of order, or not at all

Consider the shapes an outcome can take:

Operation Typical time to outcome Can the outcome be unknown?
Print a label on an online printer 1 to 5 seconds Yes, if the agent crashes after sending bytes
Print on a workstation that is asleep Minutes to hours Yes
SMS delivery Seconds to days (carrier retries) Yes, some carriers never confirm
Bank payout Hours to days Yes, until settlement
Robot arm move Sub-second Yes, if the controller loses power mid-move

Three properties fall out. The outcome can arrive long after the request, so the client cannot block on the response. Outcomes for different operations arrive in an order unrelated to the order of requests, so "the latest event" is not "the latest state." And some operations end in a state that is neither success nor failure, which your consumer must represent rather than retry. The state design is covered in what remote print job statuses actually mean.

Webhook design rules, for when you provide them or consume them

A webhook is an HTTP request the vendor makes to you when something changes. The mechanics are simple; the guarantees are the hard part. If you consume webhooks, verify the provider does each of these; if you provide them, do each of these.

Sign every delivery. An HMAC over the body with a shared secret, in a header, with a timestamp inside the signed payload to defeat replay of old deliveries. Unsigned webhooks are an open endpoint anyone can POST to.

Retry with backoff, and say how long. A consumer's endpoint will be down during a deploy. The provider should retry for a stated window (hours, not minutes) and expose the delivery log so the consumer can see what was attempted.

Make the handler idempotent. Retries mean duplicates. The handler must key on an event id or on the object's state, not on "an event arrived." The safe shape is: on receipt, fetch the object's current state from the API, then act on the state. Never act on the event body alone.

Do not assume order. Two deliveries for the same job can arrive completed then printing if the first attempt of printing failed and was retried. Compare timestamps or version numbers on the object, or, again, fetch the current state.

Offer a replay endpoint. When a consumer's handler had a bug for six hours, they need "re-send every event since 09:00" more than they need anything else about your webhooks.

Return quickly. The handler should enqueue and return 2xx in milliseconds. Doing the work inline means the provider's timeout becomes your failure.

None of these are exotic, and none of them are universal. Check each one against the provider's own docs before you assume it, because a missing replay endpoint or an unsigned delivery is something you find out about during an incident. That is why the polling section is not optional.

Polling design rules, so it is cheap and correct

Polling is asking GET /jobs/:id until the answer is terminal. Done naively it hammers the vendor and wastes your workers. Done well it costs little and never misses an outcome.

Back off. Start at the operation's typical latency (one second for an online printer), grow the interval, and cap it. A job for a sleeping workstation does not need to be checked every second for an hour.

Bound the wait. Every poll loop has a deadline after which the operation is handed to a slower reconciliation process or a human. An unbounded loop is a leaked worker.

Poll lists, not only items. For reconciliation, a single GET /jobs?status=pending newest-first is cheaper than a thousand item fetches and catches jobs your process forgot it created.

Respect rate limits. A 429 with Retry-After is an instruction. Sleep that long; do not add jitter on top and go early.

Budget the cost. A poll every second for sixty seconds is sixty requests per job. At a few thousand jobs a day that is trivial; at a few million it is not, and that is the honest argument for webhooks at scale.

The hybrid: webhook as a hint, poll as the truth

The combination is straightforward once both halves are built correctly. A webhook delivery does not update your database directly. It wakes the poller early. The poller fetches the object's current state from the API and writes that. If the webhook never arrives, the poller's own backoff schedule fetches the same state a little later.

This gives you the latency of push in the common case and the correctness of pull in every case. It also means your system's behavior during a webhook outage (yours or the vendor's) is "slightly slower," which is the best possible degraded mode.

The one thing this design refuses to do is trust a single delivery of anything. Not the webhook, and not the 201 from the original request either, which is why the request carried an idempotency key in the first place (how idempotency prevents duplicate labels).

A waitFor loop with backoff and a deadline

This is the shape of the helper. It is close to what the RocketPrint Node client ships as printJobs.waitFor, with exponential backoff added, since the client's version uses a fixed interval by default (1,000 ms) and a 60 second timeout.

const TERMINAL = new Set(['completed', 'failed', 'canceled', 'expired', 'unknown']);

async function waitForJob(rp, id, { initialMs = 1000, maxMs = 15_000, timeoutMs = 120_000, signal } = {}) {
  const deadline = Date.now() + timeoutMs;
  let interval = initialMs;
  for (;;) {
    const job = await rp.printJobs.get(id, { signal });
    if (TERMINAL.has(job.status)) return job;
    if (Date.now() + interval > deadline) {
      return { ...job, status: 'timeout' }; // hand off to reconciliation, do not create another job
    }
    await new Promise((resolve, reject) => {
      const t = setTimeout(resolve, interval);
      signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true });
    });
    interval = Math.min(maxMs, interval * 2);
  }
}

const job = await waitForJob(rp, created.id);
switch (job.status) {
  case 'completed': /* mark label printed */ break;
  case 'failed':    /* surface job.error to an operator */ break;
  case 'unknown':   /* check the printer before printing again */ break;
  case 'expired':   /* the printer was offline past the deadline; decide whether to resubmit */ break;
  case 'timeout':   /* still not terminal; a reconciliation job will pick it up */ break;
}

Two decisions in that code are the whole point. The terminal set includes unknown and expired, because they are outcomes, not delays. And the timeout branch does not create a new job; it records that the outcome is still pending and lets a slower process handle it. (timeout there is a local marker, not a status the API returns. The API's nine statuses are pending, dispatching, sent, printing, completed, failed, canceled, expired, and unknown.) Creating a second job because the first was slow is how duplicate labels happen.

RocketPrint is a remote printing API and desktop Station for software platforms: your backend POSTs a print job, and a small app beside the printer prints it, with no inbound networking, no drivers for raw label formats, and a durable job record. The job record is what makes polling honest: every status transition is stored with timestamps, and GET /v1/print-jobs/:id returns the same truth regardless of how many times you ask.

What we offer today, and what we do not

Today: polling on GET /v1/print-jobs/:id, a list endpoint that returns newest first and filters by status and printerId with a limit that caps at 200 per request, per-key rate limits that return 429 with Retry-After, and the Node client's waitFor helper, which polls at a fixed interval until one of the five terminal states and throws once its deadline passes. Statuses and their meanings are in the API reference, and the client is documented on the SDK page.

Not today: webhooks. They are listed as still being built on our changelog, and when they ship they will follow the rules above (signed, retried, replayable) and be documented as a hint on top of the job record rather than a replacement for it. Until then, an integration that needs sub-second outcome latency at very high volume will find polling costs real requests, and that is a limit worth knowing before you choose us.

If you take one thing from this: build the poller first. It is the half that has to be correct. The webhook, when you get one, makes it fast.

END / webhooks-vs-polling-long-running-physical-operations

More field notes →