A single POST /v1/admin/licenses per customer is the right shape for everyday fulfillment: a payment clears, your webhook handler issues one license, done. It stops being the right shape the day a sales team closes a 2,000-seat enterprise agreement and someone needs every one of those seats provisioned before the kickoff call — or when you onboard a reseller whose catalog seeds a few hundred licenses at once.
Run that as a loop of 2,000 individual POSTs and two problems surface immediately. It is slow — a few hundred milliseconds of round-trip each, serialized, is minutes of wall-clock time and a lot of connection churn. Worse, it is fragile in the middle: if the job dies at item 1,742 — a network blip, a process restart, a deploy — you are left guessing which licenses exist and which do not, and a naive re-run risks minting 1,741 duplicates. The fix is the admin API's batch endpoint, which issues many licenses in one request, reports a result for every item, and is safe to re-run.
Key takeaway: Provision in bulk with POST /v1/admin/licenses:batch instead of a POST-per-license loop. Give the request a batch-level Idempotency-Key and each item its own idempotencyKey (derived from your record id) plus an externalId for the join back. The endpoint processes items independently and returns a per-item result — created, duplicate, or failed — so a bad record never rolls back the good ones and you retry only the failures. For thousands of items, use the async form: the POST hands back a job id, you poll the job until completed, then read the same per-item results.
Why the naive loop hurts
The loop looks fine in a script and falls over in production:
// ANTI-PATTERN: one POST per customer, no idempotency, no partial-failure story. foreach (var seat in enterpriseDeal.Seats) // 2,000 iterations { var resp = await http.PostAsJsonAsync("/v1/admin/licenses", new { product = "acme-suite", edition = "pro", customer = seat.CustomerId }); resp.EnsureSuccessStatusCode(); // throws → loop dies mid-batch } EnsureSuccessStatusCode() turns the first transient 503 into an exception that aborts the loop, and because nothing records which seats already succeeded, your only recovery is to start over — re-issuing licenses for every customer processed so far. You can bolt per-item idempotency onto this loop (and you should, if a loop is all you have), but you are still paying 2,000 serialized round-trips and getting 2,000 independent success/failure signals you have to stitch together yourself.
The batch endpoint
One request carries an array of items. The request gets a batch-level Idempotency-Key; each item gets its own idempotencyKey and an externalId that ties it to your record.
POST /v1/admin/licenses:batch HTTP/1.1 Host: licensing.example.com Authorization: Bearer {admin_token} Idempotency-Key: deal-ACME-2026-11-15 Content-Type: application/json { "defaults": { "product": "acme-suite", "edition": "pro", "seats": 1 }, "items": [ { "externalId": "crm:row_8831", "licensee": "ana@acme.example", "entitlements": { "export": "true" }, "idempotencyKey": "crm:row_8831" }, { "externalId": "crm:row_8832", "licensee": "ben@acme.example", "idempotencyKey": "crm:row_8832" } ] } defaults applies to every item so you do not repeat product and edition 2,000 times; any field set on an item overrides the default. The response is a multi-status: an overall 207, with a result object per item in the same order you sent them.
HTTP/1.1 207 Multi-Status Content-Type: application/json { "created": 1, "duplicate": 1, "failed": 0, "results": [ { "externalId": "crm:row_8831", "status": "created", "licenseId": "lic_7f3a9c2e" }, { "externalId": "crm:row_8832", "status": "duplicate", "licenseId": "lic_41b0d8a7" } ] } Three per-item outcomes, and the distinction is the whole point:
-
created— a new license was minted;licenseIdis yours to persist. -
duplicate— an item with thisidempotencyKeywas already issued; the existing license id is returned and nothing new is minted. This is what makes a re-run safe. -
failed— the item was rejected (a missing required field, a malformedlicensee); areasonandcodesay why. The other items are unaffected.
A provisioning client that re-runs safely
The vendor-side helper does four things: chunk your records into batches, send each batch, collect the per-item results, and write the license ids back to your system of record. Chunking matters — one request of 2,000 items is a lot to hold open, and a few hundred per batch keeps each request quick while still cutting round-trips by two orders of magnitude.
public sealed class LicenseProvisioner(HttpClient http) { const int ChunkSize = 200; public async Task<ProvisionReport> ProvisionAsync( IReadOnlyList<SeatRecord> seats, CancellationToken ct) { var report = new ProvisionReport(); foreach (var chunk in seats.Chunk(ChunkSize)) { var body = new BatchRequest { Defaults = new() { Product = "acme-suite", Edition = "pro", Seats = 1 }, Items = chunk.Select(s => new BatchItem { ExternalId = s.CrmRowId, // join key back to your CRM Licensee = s.Email, IdempotencyKey = s.CrmRowId, // same key makes re-runs safe }).ToArray(), }; using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/admin/licenses:batch"); req.Headers.Add("Idempotency-Key", $"deal-{DealId}-{chunk[0].CrmRowId}"); req.Content = JsonContent.Create(body); // Retrying the whole chunk is safe: created items come back as `duplicate`. var resp = await Resilience.Pipeline.ExecuteAsync( async t => await http.SendAsync(req, t), ct); var batch = await resp.Content.ReadFromJsonAsync<BatchResponse>(ct); foreach (var r in batch!.Results) report.Record(r); // persist licenseId or log failure } return report; } } Because every item carries an idempotencyKey derived from a stable record id, re-running the whole ProvisionAsync call — after a crash, a half-finished batch, or simply because a nightly reconciliation runs again — never duplicates. Items that were already issued come back as duplicate with their original license id; only genuinely new records are created. You get the failed subset straight from report and can re-submit just those once the underlying record is fixed.
Large volumes: the async job
Above a few hundred items, prefer the async form so you are not holding a single HTTP request open while thousands of licenses are minted. Add "mode": "async" and the endpoint returns a job id instead of inline results:
POST /v1/admin/licenses:batch HTTP/1.1 Authorization: Bearer {admin_token} Idempotency-Key: deal-ACME-2026-11-15 { "mode": "async", "defaults": { "product": "acme-suite", "edition": "pro" }, "items": [ /* 2,000 items */ ] } HTTP/1.1 202 Accepted { "jobId": "job_b914f27c", "status": "pending", "total": 2000 } Poll the job until it settles, then read the same per-item results you would have gotten synchronously:
BatchResponse job; do { await Task.Delay(TimeSpan.FromSeconds(2), ct); job = await http.GetFromJsonAsync<BatchResponse>( $"/v1/admin/jobs/{jobId}", ct); } while (job!.Status is "pending" or "running"); foreach (var r in job.Results) report.Record(r); // created / duplicate / failed, as before The job is addressable by id, so polling survives a restart of your provisioning process: you persist the jobId, and if your worker dies you resume polling the same job rather than re-issuing. The batch-level Idempotency-Key guarantees that even submitting the same batch twice returns the same job rather than starting a second one.
The customer's side: nothing special
All of the above produces ordinary licenses — bulk issuance is a vendor convenience, not a different license type. Each provisioned seat validates through the SDK exactly like a single-issued one, offline against the embedded public key:
var client = KeyrightClient.Initialize(new KeyrightOptions { Product = "acme-suite", PublicKeyBase64 = Embedded.PublicKey, // ships in your binary; not a secret ServiceUrl = "https://licensing.example.com", }); LicenseInfo info = client.Validate(); // offline, never throws if (!info.IsValid || !info.IsEnabled("acme.export")) return Gate.Deny("Export requires an active license."); The end user never knows whether their seat was one of two or one of two thousand. That separation — bulk on the issuing side, identical verification on the client side — is what lets you onboard an enterprise deal in a single reconcilable operation without changing a line of the gating code your customers run.
Throughput, briefly
Two practical limits shape how you pace a large rollout. Keep chunks in the low hundreds so each request stays fast and the per-item result array stays easy to handle. And drive the batches with a small bounded concurrency rather than a thousand parallel requests — a short queue of two or three in-flight batches is plenty to saturate the issuing service's throughput without tripping its rate limits. When a batch comes back rate-limited, your resilience pipeline's retry-with-jitter handles it, and because the batch is idempotent the retry is free of duplicates.
That is the whole shape of bulk provisioning done right: one idempotent batch call per chunk, a per-item result you can reconcile against your records by externalId, an async job for the large rollouts, and a re-run that is always safe because every item knows its own identity. The 2,000-seat deal becomes one operation you can run, inspect, and run again — not a fragile loop you hope finishes.

