Replacing Polling with Event-Driven Integrations (Using Virtual Webhooks)
June 8, 2025
Last updated: September 20 2026
You migrate from polling to webhooks by inventorying your polling jobs, mapping each to a webhook subscription after checking what the integration supports, running both paths into one idempotent processor during cutover, and only then deleting the scheduler, cursor store and retry loop.
On Unified.to, a virtual webhook covers the integrations whose APIs have no webhooks, so the migration doesn't stop at the ones with native support, and include_all delivers the historical records through the same subscription, so there's no separate backfill job. Coverage is preserved by keeping a low-frequency reconciliation read for the cases webhooks can't reach, mainly deletions on integrations that don't support them.
For why polling becomes infrastructure at scale, see Polling vs Webhooks: When to Use One Over the Other. For what a virtual webhook does under the hood, see Virtual Webhooks vs Polling Jobs: How Integrations Handle Change Detection.
Step 1: What polling jobs do you have, and what does each one do?
Start by listing every scheduled read, one row per integration and object, with its interval, cursor, page size, the fields you keep, and what the downstream code does with the result. Most teams find the list is longer than they thought, because polling is duplicated per object, per customer and per integration, and the interval was chosen once and never revisited.
For each row, record three things you'll need in step 3: whether you only need new and updated records or deletions too, whether you filter the results (by company, folder, calendar, status), and which fields you actually read. Anything you fetch and discard is cost you're about to stop paying.
Step 2: Which of those objects support webhooks, and which type?
Check each object on its integration's Feature Support tab in the Unified.to dashboard, which lists supported events by type: "native & virtual created event," "native deleted event," and so on. This is the step that decides the shape of the migration, because three outcomes are possible per object.
- Native and virtual both listed. You choose. Native delivers events as the integration sends them; virtual checks on an interval you set. Pick native when the product has to react as changes happen; pick virtual when you want to control the interval and cost, or when the integration's native webhooks are limited.
- Virtual only. The integration's API has no webhooks; Unified.to detects changes for you. Many of the objects on your polling list will be here.
- Neither. Some objects on some integrations have no webhook of either type. Those rows stay on polling, against Unified.to's list endpoint with
updated_gte, and the reconciliation pass in step 7 covers them. Creating a webhook on an unsupported object returns an error saying so.
Also note per object whether deleted is listed, and for which type. As of September 2026, deletions are supported on native webhooks and on virtual webhooks where the integration supports it; where they aren't, step 7 finds them.
Step 3: How do you map a polling job to a subscription?
Each polling row becomes one webhook created with POST /unified/webhook, and the fields map directly: the object you polled is object_type, the events you need are event, your filter is filters, the fields you kept are fields, and your polling interval becomes interval if the webhook is virtual.
POST /unified/webhook?include_all=true
{
"connection_id": "CONNECTION_ID",
"hook_url": "https://example.com/webhooks/unified",
"object_type": "crm_contact",
"event": "updated",
"webhook_type": "virtual",
"interval": 5,
"fields": "id,name,emails,updated_at,raw.customerSegment",
"filters": { "company_id": "12345" }
}
Four decisions inside that request, as of September 2026:
event****. Anupdatedevent also fires for newly created records, so oneupdatedsubscription covers both; comparecreated_atandupdated_atif you need to tell them apart. Add adeletedsubscription where the object supports it.interval****. Virtual only; native ignores it. Minimum 1 minute on paid plans, 60 on free. You aren't billed for checks that find nothing, so a shorter interval on a quiet object costs little, but every check still calls the integration's API and counts against its rate limit. Match it to how quickly the product has to react, not to what you polled before.fields****. Default is every field exceptraw. Addrawfor the integration's original object, orraw.fieldnamefor specific custom fields, exactly as on API reads. The same slow-field handling applies; see Working with Custom & Original Fields and Slow fields.filters****. Supported on virtual webhooks and on native webhooks for many integrations; the Feature Support tab lists the parameters. Some integrations require a filter, and create returns an error naming it if it's missing. Unsupported filters are accepted but have no effect, so check rather than assume. See How to filter webhook events.
webhook_type can be left out, and Unified.to picks a type the object supports. It can't be changed after creation; delete and recreate to switch. The full field reference is on Create webhook subscription.
Step 4: Do you backfill, or reuse what you already hold?
If your polling jobs have been running, you already hold the data, and you don't need a backfill: create the webhook without include_all and it starts delivering changes from now. If you're connecting a new customer, or you don't trust the existing copy, create it with include_all=true and Unified.to delivers the existing records in pages before switching to ongoing events.
A backfill is billable per page delivered and re-delivers everything, so it's the blunt option. When you do use it, the type field on each delivery tells your handler where it is: INITIAL-PARTIAL for each page, INITIAL-COMPLETE on the last (which may be empty), then VIRTUAL or NATIVE for ongoing events. That's enough to drive a "syncing" state in your product until the initial sync completes. How long it takes depends on the volume, the integration's rate limits and how fast your endpoint responds; Unified.to handles the pagination and backoff.
Initial sync works the same for native and virtual webhooks, and the interval has no effect on it.
Step 5: How do you run polling and webhooks at the same time during cutover?
Run both into one processor that applies a single rule: update a record only when the incoming updated_at is newer than what you hold, atomically, and ignore anything older. With that rule, the polling job and the webhook can both write without either corrupting the other, and duplicates, out-of-order arrivals and retries all resolve the same way.
sql
INSERT INTO contacts (id, name, emails, updated_at)VALUES ($1, $2, $3, $4)ON CONFLICT (id) DO UPDATESET name = EXCLUDED.name, emails = EXCLUDED.emails, updated_at = EXCLUDED.updated_atWHERE contacts.updated_at < EXCLUDED.updated_at;
Before that write, verify each delivery. The signature is an HMAC-SHA256 over the serialized data array followed by the nonce, keyed with your workspace secret, in sig256. Reject anything that fails, and route by external_xref, which carries the value you set on the connection, to the right account. Acknowledge with a success status once you've accepted the payload and do the heavy work afterwards; a delivery your endpoint processed but didn't acknowledge in time is delivered again.
Leave both paths running for at least one full polling cycle per object, and compare: records the webhook delivered that polling also found, and records polling found that the webhook didn't. The second set is what step 7 is for.
Step 6: What do you set up before you turn polling off?
Set up health monitoring before cutover, not after the first silent gap: every Unified.to webhook has an is_healthy field, and once it's false the webhook stops reading and delivering and does not resume on its own. There's no re-enable; you fix the endpoint or the connection's auth and recreate the webhook.
To be told when it happens, set a notifications webhook URL under Settings > Workspace in the dashboard and subscribe to the WEBHOOK_UNHEALTHY event. Alert on it the way you'd alert on a failed cron job, because it's the same failure with a different name. The dashboard's audit trail shows each webhook's recent runs and records delivered, and How to troubleshoot unhealthy webhooks covers diagnosis.
Know the retry model for the type you chose. If your endpoint doesn't respond, delivery is retried three times immediately. A virtual webhook then keeps retrying with backoff for up to roughly two weeks, re-reading the same page from the integration each time; a native webhook is marked unhealthy and the event isn't redelivered unless the integration sends it again. If your endpoint can be down for longer than a few seconds during deploys, that's an argument for virtual where both are offered.
Step 7: What reconciliation do you keep, and how often?
Keep one scheduled read per object, at a low frequency, that lists records changed since your last reconciliation time (using updated_gte where the object's List Options include it) and repairs anything the webhook missed; it replaces the polling fleet with a safety net that runs hourly or daily rather than every minute. This is the same advice Stripe and Microsoft give for their own webhooks: treat the event as the prompt to sync, and let a periodic read establish completeness.
Two cases make it necessary rather than optional. Deletions, on virtual webhooks for integrations that don't support them: compare the IDs you hold against the list and remove what's gone. And the objects from step 2 that have no webhook of either type, which stay on this read entirely.
For an on-demand refresh, for example a user clicking "refresh now," a virtual webhook can be triggered manually from the dashboard or the API; it runs its next check immediately and delivers only if something changed. It costs nothing if the check finds no changes, and it doesn't touch your reconciliation schedule. It's virtual-only; native webhooks return 400.
Step 8: What do you delete, and what stays?
Once the comparison in step 5 shows the webhook path covering what polling found, delete per object: the cron entry, the cursor row, the pagination loop and the retry and backoff code. What stays is smaller and mostly shared: the endpoint, signature verification, the idempotent write, the is_healthy alert, and the reconciliation read from step 7.
Do the deletion per object as each one clears, not all at once. The objects that stay on polling in step 2 keep their jobs, and it's fine for the two models to coexist for as long as the integration landscape requires it.
What does the migration change in numbers?
The change is in requests you make and code you own, not in latency, which the interval still sets. A one-minute polling loop across 1,000 connections is 1,000 requests a minute from your infrastructure, or 1.44 million a day, most of them finding nothing. The same objects on a one-minute virtual webhook still generate those checks against the integration's API, but from Unified.to, and you're billed only for pages of changes successfully delivered. On an object where 2% of checks find changes and each fits in one page, that's 28,800 billed deliveries a day against 1.44 million requests you no longer make. The figure is illustrative; the arithmetic is the point.
Frequently asked questions
Can I change the interval after the webhook is created?interval is listed as writable on the update endpoint; confirm the change takes effect on a test webhook before relying on it. webhook_type is not writable; delete and recreate the webhook to change type.
What if my polling job filtered on a field the webhook can't?
Check the object's List Options and Virtual Webhook Options on the Feature Support tab; filter parameters are typically those ending in _id or type. If your filter isn't supported, subscribe without it and filter on receipt, discarding what you don't need before it reaches storage.
Does migrating force me to store data I didn't store before? No. A webhook delivers records; what you do with them is yours. Teams that only need a subset filter on receipt and keep nothing else, exactly as they did with polling results.
What happens to my webhooks if I delete the connection? They're deleted with it. For native webhooks, the subscription registered with the integration is removed as well.
What if the backfill fails partway through?
The documented completion signal is INITIAL-COMPLETE. If it doesn't arrive and the webhook is marked unhealthy, fix the cause and recreate it with include_all; the expected behaviour is that the backfill runs again from the start, so plan for re-delivered records.