Table of Contents

Building a custom dashboard

BulkSharp ships a Blazor dashboard, but it is optional. The HTTP API lives in its own package with no UI dependency, so you can build a front end in React, Angular, Vue, a CLI, or anything else that speaks HTTP.

This guide covers what you need to drive the API from outside .NET.

Reference the API, not the dashboard

dotnet add package BulkSharp
dotnet add package BulkSharp.Api

BulkSharp.Api contains endpoint mappings and the JSON contract. It does not reference Razor or Blazor, so nothing UI-related is published with your application.

builder.Services.AddBulkSharp(b => b
    .UseFileStorage(fs => fs.UseFileSystem())
    .UseMetadataStorage(ms => ms.UseInMemory())
    .UseScheduler(s => s.UseChannels()));

builder.Services.AddBulkSharpEndpoints();

var app = builder.Build();
app.MapBulkSharpEndpoints();
app.Run();

To confirm nothing UI-related came along:

dotnet publish -c Release -o ./out
ls ./out | grep -iE 'razor|blazor|components'   # expect no matches

AddBulkSharpEndpoints() registers the HTTP contract. Do not confuse it with AddBulkSharpApi() in the BulkSharp package, which selects API-only mode for the processing services — the two are unrelated and are commonly used together.

Enable CORS

A front end served from another origin cannot call the API without it.

builder.Services.AddBulkSharpCors("https://admin.example.com");

app.UseCors(BulkSharpCorsExtensions.PolicyName);
app.MapBulkSharpEndpoints();

Content-Disposition is exposed by this policy. Without it the browser cannot read the filename from the file, export and template download endpoints, and your downloads will be named after the URL.

Wildcard origins are rejected: the policy allows credentials, and the two cannot be combined.

Generate a client

Serve the OpenAPI document outside production:

if (app.Environment.IsDevelopment())
{
    app.MapBulkSharpOpenApi();   // /openapi/v1.json
}

Then generate:

npx @openapitools/openapi-generator-cli generate \
  -i http://localhost:5000/openapi/v1.json \
  -g typescript-angular \
  -o ./src/api

Every endpoint carries a stable operationIdgetBulks, getBulkStatus, createBulk, retryBulk and so on — so regenerating does not churn your method names.

Enums are strings

Responses serialize enums as their names, and the document says so:

{ "status": "CompletedWithErrors", "processedRows": 42, "totalRows": 42 }

Property names are camelCase and null properties are omitted.

.NET clients must opt in. JsonSerializerDefaults.Web does not include a string enum converter, so deserializing with default options fails:

var operation = await http.GetFromJsonAsync<BulkOperation>(
    $"/api/bulks/{id}", BulkSharpJsonSerialization.Options);

Clients in other languages parse the JSON directly and need no special handling.

Build the submission form from the API

GET /api/operations describes each operation's metadata fields and file columns, so a form can be rendered without compile-time knowledge of the operation types:

{
  "name": "user-import",
  "description": "Import users from a CSV file.",
  "isStepBased": false,
  "metadataFields": [
    { "name": "ImportedBy", "type": "string", "required": true },
    { "name": "Department", "type": "string", "required": false },
    { "name": "BatchSize",  "type": "int?",   "required": false }
  ],
  "fileColumns": [
    { "name": "Email", "type": "string", "required": true }
  ]
}

type is a friendly name — string, int, decimal, bool, datetime, guid — with a trailing ? for nullable types. Map it to an input type and a validator.

Always validate before creating

required is derived from [Required] attributes. An operation can also enforce rules imperatively in ValidateMetadataAsync, and those are invisible to the descriptor.

POST /api/bulks/validate is therefore a mandatory pre-flight for any generated form, not an optimization. It takes the same multipart payload as create and returns:

{ "valid": false, "metadataErrors": ["ImportedBy is required"], "fileErrors": [] }

Surface those errors rather than submitting an operation that will fail.

Create an operation

POST /api/bulks, multipart form data:

Field Required Notes
operationName yes From the discovery endpoint
file yes CSV or JSON
metadata yes JSON object matching metadataFields
notifications no JSON notification options
createdBy conditional Ignored when the request is authenticated

Attribution comes from the authenticated principal via IBulkUserResolver. The form value is only honoured for anonymous self-hosting. Register your own resolver to change how the identity is derived:

services.AddSingleton<IBulkUserResolver, MyResolver>();

Response:

{ "operationId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301" }

Track progress

Poll GET /api/bulks/{id}/status — the built-in dashboard polls every 2 seconds, and that cadence is a reasonable default:

{
  "status": "Running",
  "processedRows": 120,
  "totalRows": 500,
  "errorCount": 3,
  "completedAt": null,
  "progress": 24
}

Stop polling on a terminal status: Completed, CompletedWithErrors, Failed, Cancelled. Leaving an interval running against a finished operation is the most common bug in a hand-written client.

For detail, GET /api/bulks/{id}/errors returns failed rows and GET /api/bulks/{id}/rows returns per-row pipeline progress with per-step state. Both are paged with the same items / totalCount / page / pageSize / hasNextPage envelope.

Notifications

Operation events — created, status changed, completed, failed, row failed — are dispatched inside the processing pipeline. A front end is a different process, so it never observes them directly. Read them back instead:

GET /api/events?since={sequence}&limit=100
GET /api/bulks/{id}/events?since={sequence}
[
  {
    "sequence": 42,
    "operationId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "operationName": "user-import",
    "type": "Completed",
    "severity": "Warning",
    "message": "48/50 rows succeeded in 0:00:12",
    "timestamp": "2026-08-05T09:14:22Z"
  }
]

Poll with the highest sequence you have seen. Nothing is delivered twice and nothing is skipped. Adopt the cursor without rendering on the first poll, or an operator opening the page is shown every event since startup.

The store keeps a bounded recent tail, so sequence values keep increasing across eviction: a client that falls far behind observes a gap rather than silently re-reading. The built-in dashboard drives its toasts from exactly this feed.

Do not confuse this with IBulkNotificationChannel. Channels deliver outward — email, SMS, webhooks — and correctly execute in the worker. This feed is what a UI reads back.

Choosing a store

AddBulkSharp registers an in-memory store: a bounded tail with a per-process sequence counter. Fine for a single instance, wrong for more than one — each instance counts from 1 independently, so a client polling through a load balancer either stalls (passing a since one instance has not reached) or skips events (because the same number means a different event elsewhere). Events are also lost on restart.

AddBulkSharpEntityFramework replaces it with a durable store that delegates sequencing to a database identity column, so every instance shares one ordering:

services.AddBulkSharpEntityFramework<BulkSharpDbContext>();

Events accumulate — one per lifecycle transition plus one per failed row — so prune on a schedule. The library does not impose a retention window, because the right one depends on how your feed is consumed:

await eventStore.PruneAsync(DateTime.UtcNow.AddDays(-7), ct);

To back the feed with something else — Redis, a message log — implement IBulkOperationEventStore and register it; the built-in registration uses TryAdd.

Authorization

Reads and writes are governed separately, so viewers can be prevented from mutating:

app.MapBulkSharpEndpoints(new BulkSharpAuthorizationOptions
{
    ReadPolicy    = "bulk:read",
    OperatePolicy = "bulk:operate"
});

BulkSharp only knows the policy names; your host defines what they require. Passing nothing leaves the endpoints unauthorized — appropriate only when your own middleware enforces access, or for local development.

Aggregating several services

If bulk operations run in more than one service, BulkSharp.Gateway exposes the same routes and response shapes over all of them, so the client you generated works unchanged. Two additions, both additive:

  • sourceService on each operation descriptor, naming the owning backend
  • ?source={name} on GET /api/bulks, routing to one backend and skipping the fan-out

See the gateway guide.

Keeping the built-in dashboard

The Blazor dashboard is not deprecated. It remains the zero-effort option — one line to mount a working UI in-process — and it consumes exactly these endpoints, so anything it does is reproducible from outside.

builder.Services.AddBulkSharpDashboard();
app.UseBulkSharpDashboard();          // UI and API
app.UseBulkSharpDashboardUi();        // UI only, when the API comes from elsewhere