Migrate historical usage data or process large event volumes offline. NDJSON file upload with async processing, validation reporting, and error recovery.
The real-time ingestor is designed for event-by-event streaming — one usage event per HTTP request, generated at the moment a billable action occurs. Batch upload is designed for the two situations where real-time ingestion doesn't apply:
Historical data migration
Moving to Aforo from another billing system. Upload months of historical usage so your invoices and analytics reflect the complete picture from day one.
Bulk backfill
A metering instrumentation bug caused events to be dropped for a time window. Reconstruct the events from your application logs and upload the corrected set.
Offline processing
Usage data generated in an air-gapped environment, exported on a schedule, and uploaded when connectivity is restored.
Third-party system export
Your gateway vendor or telemetry platform can export usage logs as CSV or JSON. Convert to NDJSON and upload directly — no streaming pipeline required.
WARNING
Batch upload does not replace real-time ingestion for live traffic. Batch jobs are processed asynchronously with up to 5-minute latency. If a customer hits their quota at 11:59pm, a batch job you upload at midnight will not enforce that quota retroactively — it will apply from the next quota evaluation cycle. Use real-time ingestion for any usage that must affect entitlement checks immediately.
Aforo batch upload uses NDJSON (Newline-Delimited JSON, also known as JSON Lines). Each line in the file is a complete, self-contained JSON object representing a single usage event. Lines are separated by a Unix newline (\n). The file must be UTF-8 encoded.
Why NDJSON Instead of JSON Arrays
A JSON array of a million events requires loading the entire document into memory before parsing can begin. NDJSON is streamed and parsed line-by-line: the processor starts validating and ingesting events from line 1 before it has read line 1,000. This allows files up to 500 MB with millions of events to be processed with a fixed memory footprint.
Event Object Schema
FieldTypeDescription
metric_idstring (required)The Aforo billable unit ID to credit. Must exist and be ACTIVE in your organization.
quantitynumber (required)The billable usage amount. Must be a positive number. Fractional values are allowed.
tenant_idstring (required)The Aforo tenant (organization) this event belongs to. Must be a provisioned tenant.
event_timeISO 8601 (required)When the usage occurred. Cannot be more than 90 days in the past or more than 5 minutes in the future.
idempotency_keystring (recommended)A globally unique key for this event. Duplicate keys within 72 hours are silently discarded.
customer_idstring (optional)Customer within the tenant. Used for per-customer usage attribution and invoice generation.
propertiesobject (optional)Arbitrary string key-value pairs. Indexed as custom dimensions for analytics filtering.
The idempotency_key field is the safest way to handle upload retries. If a network error cuts your upload in half and you re-upload the full file, Aforo deduplicates against the keys seen in the previous 72 hours. The second upload produces zero duplicate events.
Upload the NDJSON file as a multipart/form-data POST. Aforo immediately validates the request headers and file size, then enqueues the file for async processing. The response contains a job ID you use to poll for processing status.
Job status values: QUEUED → PROCESSING → COMPLETED or FAILED. A FAILED status means the file itself could not be parsed (not a schema error) — for example, a truncated file or invalid UTF-8. Individual event validation errors result in COMPLETED with a non-zero events_rejected count.
Aforo processes batch jobs in a dedicated async worker pool, separate from the real-time ingestion hot path. Jobs are processed in FIFO order per tenant. A single tenant cannot starve other tenants' processing by uploading large files — each tenant's queue is isolated.
Validation Rules
Each event line is validated independently. A validation failure on line 500 does not affect events on lines 1-499 or 501-10000. Accepted events are ingested immediately; rejected events are written to the error file.
RuleDetail
Required fieldsmetric_id, quantity, tenant_id, and event_time must all be present and non-null.
metric_id must existThe metric_id must reference an ACTIVE billable unit in the uploading tenant's organization. ARCHIVED units are rejected.
quantity > 0Zero and negative quantities are rejected. If you need to reverse usage, use a credit note rather than a negative event.
Timestamp rangeevent_time must be between 90 days ago and 5 minutes from now (UTC). Events outside this window are rejected.
Timestamp formatevent_time must be ISO 8601 with timezone offset or "Z". Bare date strings ("2026-04-15") are rejected.
Valid JSON per lineEach line must be valid JSON. A line that fails JSON parsing is rejected and counted separately from schema violations.
Max line lengthA single event line may not exceed 64 KB. Events with payloads larger than this (e.g., embedded blobs) are rejected.
tenant_id must be provisionedThe tenant_id in each event must match a provisioned Aforo tenant. Cross-tenant events in a single file are rejected — each file must be scoped to one tenant.
PRO TIP
Always run a dry_run=true upload first on large files. Dry runs complete in the same time as a real upload and generate the full error report — without touching your billing data. Fix the errors, re-validate, then submit for real ingestion.
When a job completes with rejected events, Aforo generates an error file — itself an NDJSON file — available at the URL in the error_file_url field of the job status response. The URL is authenticated and expires after 24 hours.
Error File Format
Each line in the error file is a JSON object containing the original event (verbatim, as submitted), the 1-based line number from the source file, the error code, and a human-readable error message.
errors.ndjson
{"line":7,"error_code":"INVALID_METRIC_ID","error_message":"metric_id 'api_requests' not found. Did you mean 'api_calls'?","original":{"metric_id":"api_requests","quantity":1,"tenant_id":"tenant_acme","event_time":"2026-04-15T10:22:00Z"}}
{"line":23,"error_code":"TIMESTAMP_TOO_OLD","error_message":"event_time '2025-12-01T00:00:00Z' is 139 days in the past. Maximum lookback is 90 days.","original":{"metric_id":"api_calls","quantity":1,"tenant_id":"tenant_acme","event_time":"2025-12-01T00:00:00Z"}}
{"line":91,"error_code":"QUANTITY_NOT_POSITIVE","error_message":"quantity must be > 0, got: -5","original":{"metric_id":"storage_gb","quantity":-5,"tenant_id":"tenant_acme","event_time":"2026-04-15T10:44:00Z"}}
Common Error Codes
Error CodeFix
INVALID_METRIC_IDCheck the metric_id against the list at GET /api/v1/metrics. Archived metrics return this error — restore or use an active metric.
TIMESTAMP_TOO_OLDMove to a different ingestion strategy for data older than 90 days, or contact support to request an extended backfill window.
TIMESTAMP_IN_FUTURECorrect the event_time to the actual event occurrence time, not the current time or a scheduled time.
QUANTITY_NOT_POSITIVERemove or correct events with zero or negative quantities. Use credit notes for reversals.
MISSING_REQUIRED_FIELDAdd the missing field. The error message names the specific field.
INVALID_JSONThe line could not be parsed as JSON. Check for unescaped quotes, trailing commas, or non-UTF-8 bytes.
DUPLICATE_IDEMPOTENCY_KEYRaised only when skip_duplicates=false. Remove the duplicate or let the default dedup behavior handle it.
UNKNOWN_TENANTVerify the tenant_id is provisioned and active. All events in a batch file must belong to the same tenant as the X-Tenant-Id header.
CSV to NDJSON Conversion Script
Most telemetry exporters and legacy billing systems produce CSV. The following Node.js script converts a standard usage CSV export to the NDJSON format Aforo expects. Adjust the column mapping to match your CSV headers.
# Convert and validate before uploading
node csv-to-ndjson.js legacy-usage-export.csv usage-events.ndjson
# Dry run first — validates without ingesting
curl -X POST https://api.aforo.ai/v1/ingest/batch \
-H "Authorization: Bearer $AFORO_API_KEY" \
-H "X-Tenant-Id: tenant_acme" \
-F "file=@usage-events.ndjson;type=application/x-ndjson" \
-F "dry_run=true"
# If dry_run shows 0 rejected events, submit for real
curl -X POST https://api.aforo.ai/v1/ingest/batch \
-H "Authorization: Bearer $AFORO_API_KEY" \
-H "X-Tenant-Id: tenant_acme" \
-F "file=@usage-events.ndjson;type=application/x-ndjson" \
-F "notify_email=ops@acme.com"
INFO
Batch jobs are retained for 30 days after completion. The error file URL expires after 24 hours — download it promptly if you need to review rejected events. Job metadata (counts, status, timestamps) remains accessible via the status endpoint for the full 30-day retention window.