Headers, errors & limits
Applies to both POST /api/v1/ingest (Surface 1a) and POST /api/v1/ingest/:slug (Surface 1b).
Request headers
| Header | Surface | Required | Value |
|---|---|---|---|
Authorization | 1a only | Yes | Bearer ingt_srv_<token> |
X-BGL-Timestamp | 1b only | Yes | Unix seconds, within ±5 minutes of server time |
X-BGL-Signature | 1b only | Yes | Hex HMAC-SHA256(secret, "{timestamp}.{rawBody}"), optional sha256= prefix |
X-BGL-Idempotency-Key | Both | No | 1–255 char string, dedups retries for 24h |
Content-Type | Both | Yes | application/json |
Response shapes
Both surfaces return exactly one of two shapes — there is no third status code:
| Status | Body | When |
|---|---|---|
202 Accepted | { "submission_id": "<uuid>" } | The submission was accepted and scored. |
200 OK | { "status": "rejected" } | Anything went wrong — auth, rate limit, signature, form state, body validity. |
Why rejections are opaque
A distinguishable response per failure mode (401 for a bad token, 429 for rate limiting, 400 for a bad signature) would let an attacker enumerate valid tokens or form slugs by watching which status code comes back. Every rejection reason — no matter how different internally — collapses to the same 200 { "status": "rejected" } on the wire. If you need to know why a specific submission was rejected, the real reason is logged and visible in Settings → Ingest tokens → Diagnostics, scoped to your own workspace.
Rate limits
Per workspace, tracked separately per surface (a burst on 1a doesn't count against 1b's bucket):
| Window | Limit |
|---|---|
| Per minute | 1,000 submissions |
| Per day | 50,000 submissions |
Exceeding either limit returns the same opaque 200 { "status": "rejected" } — there's no 429.
Body limits
Both surfaces cap the raw request body at 128 KB. Oversized bodies are rejected before any parsing happens.
Legacy endpoint sunset
The pre-v1 /api/ingest/:slug endpoint (optional signature, no replay protection) was deprecated on 2026-05-26 and permanently retired on 2026-06-25 — every request now returns 410 Gone. If you're still pointed at it, migrate to one of the two endpoints on this page; see the in-app migration notes for the full cutover checklist.