Product
Stop polling for work that already finished
Be told when it ends, and know who told you.
By Ehsan Gazar, 19 September 2026
Somewhere in your codebase there is a loop that asks the same question every thirty seconds. Is it done yet? Most of the time the answer is no, and the loop is the only thing in your system that knows the work exists.
Polling works. It just puts the waiting in the wrong place.
The loop nobody owns
A model gateway is mostly request and response. You ask, you wait a second, you get an answer. The work that does not fit that shape is the work that matters most at three in the morning.
A batch of ten thousand classifications, handed to a provider with a 24 hour window. An agent that runs on a schedule while everybody sleeps. A workflow started by your own product through the API. A folder of documents being chunked and indexed before anybody can search it. A balance running down under all of them.
Each one ends at a moment you did not choose. So somebody writes a loop, and the loop becomes a small service nobody remembers owning. When it dies, nothing tells you. The batch finishes, the results sit there, and the first sign of trouble is a customer asking where their report went.
The fix is not a faster loop. It is turning the question around.
Told, not asked
A webhook is an address you give us. When something happens on your account, we POST to it.
Our gateway sends twelve events today. batch.finished, with how the batch ended and how many of its items failed. agent.run.succeeded and agent.run.failed, with the whole answer rather than a preview. workflow.run.succeeded and workflow.run.failed for runs your product started with a key. knowledge.collection.ready, the moment a collection you are filling becomes searchable, and knowledge.collection.attention when a document in it could not be read. Then the money ones, balance.low, balance.exhausted and key.budget_exhausted, and two about keys, key.expiring and key.unused.
Every delivery has the same envelope, whatever the event.
POST https://your-app.example.com/vatan
x-vatan-signature: 4c1f...
x-vatan-timestamp: 1789787529
x-vatan-event-id: evt_...
x-vatan-attempt: 1
{
"id": "evt_...",
"event": "batch.finished",
"at": "2026-09-19T03:12:09.112Z",
"data": {"batch_id": "...", "api_status": "completed", "total": 2, "completed": 2, "failed": 0}
}
A webhook can take every event, or only the ones you name. The pager wants balance.exhausted. The service that collects batch results wants batch.finished and nothing else.
A stranger with your URL
Your receiver is a public address, so anybody who learns it can POST to it. Without a check, a webhook is a stranger telling your systems that your balance is fine.
So every delivery is signed. We compute an HMAC-SHA256 over the timestamp, a dot, and the exact bytes of the body, keyed on a secret shown to you once when the webhook is made. Your receiver does the same and compares.
import { createHmac, timingSafeEqual } from 'node:crypto'
function fromVatan(secret, rawBody, headers) {
const ts = headers['x-vatan-timestamp']
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
const given = Buffer.from(headers['x-vatan-signature'] ?? '')
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300
return fresh && given.length === expected.length && timingSafeEqual(given, Buffer.from(expected))
}
The timestamp is inside the signed bytes on purpose. Somebody replaying an old delivery cannot move it without breaking the signature, so the age check means something.
When your receiver is down
Receivers restart. Deploys take a minute. A delivery that meets a timeout, a 408, a 429 or a 5xx is tried again after a minute, then five, thirty, two hours and eight. Six attempts over about ten and a half hours.
Every attempt carries the same event id and the same body, signed afresh with a new timestamp. That is what makes retrying safe: a receiver that has already acted on an id answers 200 and does nothing.
A 404 or a 401 is not retried. That is a receiver saying the address is gone or refuses us, and asking again changes nothing. Every attempt is in the delivery log with the status your receiver answered, and anything from the last seven days can be sent again by hand.
Seeing one arrive before you build anything
The hardest part of a webhook is the first one. You write a receiver, deploy it, and wait for an event you cannot easily cause.
So a webhook can point at a receiver of ours instead. It keeps the last hundred requests that reach it and checks each signature the way your code should. You can also send any event's sample to any webhook. It arrives marked "test": true, so nothing downstream acts on it.
The loop that asks is gone. What replaces it is one address that is told.