A .NET 8 Azure Function syncing tens of thousands of billing records into Dataverse: paginated, rate-limited, and safe to re-run
A C# / .NET 8 Azure Function that pulls tens of thousands of records from a third-party billing API and lands them in Dataverse. The API is paginated and rate-limited; the target has to end up correct whether the sync runs cleanly, dies halfway through, or gets run twice by mistake.
The whole design turns on one decision: writes are upserts keyed on a composite alternate key derived from the source system's own identifiers. That makes every write idempotent, which in turn makes the entire batch safe to replay from any point, and removes the need for the fragile "where did we get to" bookkeeping that this class of integration usually accumulates.
The billing data lived in a third-party platform and was needed against records in Dataverse. Bulk synchronisation across a boundary like that has three problems that all show up at scale and none of which show up in a five-record test.
The source paginates, so you are never holding the full set. The source rate-limits, so a naive loop will be throttled partway through a run and get slower the harder you push it. And the run will fail partway through at some point, a transient network fault, a deployment, a timeout, which means the interesting question is not how the sync behaves when it succeeds, but what state it leaves behind when it does not.
Answer that badly and you get duplicates, which are far more expensive than missing rows: a missing row gets picked up on the next run, while a duplicate has to be found and reconciled by a human.
A scheduled .NET 8 isolated Azure Function runs the sync as a four-stage loop, with the correctness properties pushed down into the write rather than managed in the orchestration.
One deployable, one direction, one write pattern. The function is triggered on a schedule, pages through the billing API, and upserts into Dataverse against the alternate key, which is what decides insert versus update, rather than any state the function keeps.
Pushing identity resolution into the alternate key is the whole trick. The alternative, querying for an existing record before every write, doubles the request count against the target, introduces a race between the check and the write, and still gets it wrong under retry. Letting the platform resolve identity from a key it already indexes is both faster and more correct.
The instinct on a large sync is to make the orchestration smart: checkpoint the position, track what has been processed, resume from the last known good page. All of that is state, and all of that state can be wrong, particularly after the kind of ungraceful failure it exists to protect against.
Making the write idempotent instead makes most of that machinery unnecessary. If running the same record twice is harmless, then "where did we get to" stops being a correctness question and becomes a performance one, and performance questions are far easier to be wrong about safely.
The second lesson is that a rate limit is a specification, not a failure. Integrations that treat throttling as an error end up either fragile or aggressive; treating it as expected back-pressure and building the retry path first produces something that runs unattended.
Billing data lands in Dataverse on a schedule with no manual intervention, and a failed run is recovered by running it again rather than by anyone reconciling records by hand.
The pattern has held up on every integration I have built since. Pagination, back-off and an idempotent write are not three separate features; they are the three things that decide whether a bulk sync is something you can leave running or something somebody has to watch.
Building agents on the Power Platform, or trying to get one past a pilot? I am happy to talk it through.
Get in Touch →