ByteBeacon

API reference

Every response uses one envelope. Success: {"success":true,"data":…}. Error: {"success":false,"error":{"code":"…","message":"…"}} — codes are stable; branch on code, not message.

Base URL. Every path below is on the API origin: https://api.bytebeacon.com in production, http://localhost:8080 locally. $BASE in the examples means exactly that origin — the dashboard (https://app.bytebeacon.com) is a separate host and serves none of these routes.

Codes: invalid_email, invalid_request, unknown_form, invalid_key, not_found, tier_forbids_delete, last_active_key, key_limit_reached, payload_too_large, rate_limited, quota_exhausted, rate_restricted, internal_error, unsupported_content_type, content_type_mismatch, empty_submission, attachments_unsupported.

Public#

POST /v1/accounts#

Body: {"email":"[email protected]","name":"optional"}201 with user_id, organization_id, project_id, form_id, secret_key. The secret_key appears only in this response. Rate limit: 10/min per IP.

POST /s/{form_id} · POST /submit#

With /submit, include the form id as an access_key or form_id field. Max payload 64KiB.

Limits. Every account is unlimited by default. A coarse per-IP flood limit always applies to this endpoint; beyond that, a per-second rate and a rolling 24-hour quota can be set on your account by ByteBeacon. Read the ones that apply to you — and how much of them you have used — from GET /v1/me and GET /v1/forms; null there means unlimited. Every refusal is a 429, and they are different codes on purpose, because they ask for opposite behaviour. The two that clear on a schedule carry a Retry-After header in seconds; the one that does not, does not:

  • 429 rate_limited — too fast. Retry after Retry-After (about a second).
  • 429 quota_exhausted — out of submissions for the rolling 24 hours. Do NOT retry today; Retry-After says when the window next frees up.
  • 429 rate_restricted — ByteBeacon is holding this account's rate below its published rate_per_second. Do not retry. This one does not clear on a timer: it stays until a human at ByteBeacon lifts it, which is why it is the only 429 here that carries no Retry-After — there is no interval that would make a retry succeed. Stop sending and contact support. GET /v1/me reports the same state up front as limits.rate_restricted, so you can see it without being refused first.

That is also why these are three codes and not one message: branch on code. rate_limited means wait a second, quota_exhausted means wait for the window, rate_restricted means stop and talk to us.

Content-Type is required and is never guessed. Exactly three are accepted:

Content-Type Notes
application/json Body must be a JSON object of field names to values.
application/x-www-form-urlencoded The plain HTML <form> path.
multipart/form-data Text fields only — see attachments_unsupported.

Anything else — including a missing header — is 415 unsupported_content_type. The common trap: curl -d defaults to application/x-www-form-urlencoded, so a JSON body needs the header spelled out or you get 400 content_type_mismatch:

bash
curl -s -X POST $BASE/s/YOUR_FORM_ID \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]"}'

Success (200):

json
{"success":true,"data":{"id":"sub_…","stored":true,
 "fields_received":["email"],"message":"submission received"}}

id is the stored submission — fetch it back with GET /v1/submissions/{id} to prove your wiring end to end. fields_received lists exactly the field names that were stored, so a mis-encoded body is obvious at a glance. stored is false (and id absent) only on the honeypot path below.

Reserved fields (control behavior, never stored):

  • access_key / form_id — routing
  • redirect — on success respond 303 See Other to this http(s) URL whenever redirect is present (any content type); omit redirect to get a 200 JSON response instead. The 303 has no body, so no id.
  • botcheck — honeypot; keep it hidden and empty. Non-empty → the response is still 200 success:true (a bot must not learn what tripped it) but it carries "stored": false and no id, and nothing is written. If you are an agent filling a form, leave botcheck empty; if you see stored:false, your submission was discarded.

Everything else is stored as-is in fields. At least one non-reserved field is required — an otherwise-empty submission is 400 empty_submission rather than a stored empty row.

Ingest error codes:

  • 415 unsupported_content_type — missing or unrecognised Content-Type.
  • 400 content_type_mismatch — the body does not match the declared type; in practice, a JSON document sent as application/x-www-form-urlencoded.
  • 400 empty_submission — nothing left to store after reserved fields.
  • 400 attachments_unsupported — a multipart/form-data request carried a file part. File uploads are not supported; send text fields only.
  • 400 invalid_request — malformed body for the declared type, or a redirect that is not an http(s) URL.
  • 404 unknown_form, 413 payload_too_large.
  • 429 rate_limited / 429 quota_exhausted — see Limits above. Both carry Retry-After.
  • 429 rate_restricted — ByteBeacon has restricted this account's rate; see Limits above. No Retry-After, deliberately: retrying does not lift it, support does.

CORS: these two ingest routes are the only cross-origin surface — they send Access-Control-Allow-Origin: * and answer the OPTIONS preflight, so you can POST straight from a browser on your own domain. Safe because the routes are unauthenticated: no cookies or credentials are ever accepted, and the form id is public by design. The authenticated /v1/* routes deliberately send no CORS headers — a bb_sk_ key must never be used from a browser page. Note Location is not exposed to fetch, so redirect is for plain HTML form posts.

Authenticated — Authorization: Bearer bb_sk_…#

The key is project-scoped: it reads only its own project's data. Unknown ids in other projects return 404 (indistinguishable from nonexistent).

GET /v1/me#

{project_id, organization_id, tier, capabilities, limits}. Use to validate a key and to discover what your account may do and how much of it you have used.

capabilities is what this tier may do, resolved server-side — currently {delete_data}. Branch on a capability, not on tier: only the API knows which tiers unlock what, and it is the same table the DELETE routes enforce.

limits is your organization's limits and its real rolling-24h usage:

json
{"rate_per_second": null, "quota_per_day": 5000, "usage_last_24h": 412,
 "rate_restricted": false}
  • rate_per_second — submissions per second, per source address. 429 rate_limited when exceeded.
  • quota_per_day — stored submissions per rolling 24 hours. 429 quota_exhausted when exhausted.
  • null means unlimited. It is never 0: 0 would mean "none allowed", the opposite. Both levers are null by default on every tier — this is what "unlimited submissions on every tier" means literally. Only ByteBeacon can set them; there is no customer endpoint that raises or lowers a limit.
  • usage_last_24h — stored submissions in your organization over the rolling 24 hours, counted the same way the quota counts them (soft-deleted submissions still count: deleting data is not a way to buy quota).
  • rate_restrictedtrue while ByteBeacon is holding your organization's per-second rate below rate_per_second. It is false for every normal account. When it is true, rate_per_second is not the rate being enforced, and submissions over the restricted rate are refused with 429 rate_restricted (its own code, and no Retry-After — see Limits). Nothing you can call changes it — contact support. Retrying harder will not help. The threshold that caused it is deliberately not published, here or anywhere: this field reports the state, not the number.

Limits are per organization here and per form on GET /v1/forms; a request must satisfy both, so the binding value is the lower of the two. /v1/forms publishes that resolution as effective_* so you never have to compute it.

Capabilities and limits are two independent axes. A capability is a tier rule; a limit is a per-account lever whose defaults do not vary by tier. Do not read one to infer the other.

GET /v1/keys#

→ array of key metadata: {id, label, active, created_at, revoked_at, last_used_at}. Revoked keys stay listed (active:false) so you can see a leak was closed. The plaintext of a key is never here and never recoverable — only its sha256 hash is stored. last_used_at is refreshed at most once every 5 minutes per key, so treat it as coarse.

POST /v1/keys#

Body {"label":"optional string"} — optional, max 64 chars, defaults to unnamed. → 201 with {id, label, active, created_at, revoked_at, last_used_at, secret_key}. secret_key is shown in this response and nowhere else, ever. Store it now. Max 10 active keys per project → 409 key_limit_reached.

DELETE /v1/keys/{id}#

Revokes the key (soft: it stays listed as active:false). → 200 with the key's metadata.

  • The last active key cannot be revoked409 last_active_key. There is no email login, so it is the only way back into your data. Create a replacement first.
  • Revoking the key you are authenticating with is allowed while another active key exists. That is the rotation flow: POST /v1/keys → switch your client to the new key → DELETE /v1/keys/{old}.
  • Revoking an already-revoked key is idempotent 200 (the row is still in the list, so 404 would be a lie). A key id from another project is 404.
  • Not tier-gated: revoking a leaked key works on free.

GET /v1/forms · POST /v1/forms {"name":"…"} · DELETE /v1/forms/{id}#

A form carries its own copy of the two levers, plus the resolved effective values and its own usage:

json
{"id":"form_…","project_id":"proj_…","name":"Contact Form",
 "created_at":"2026-09-14T10:00:00Z",
 "rate_per_second":null,"quota_per_day":10000,
 "effective_rate_per_second":null,"effective_quota_per_day":1000,
 "usage_last_24h":412}
  • rate_per_second / quota_per_daythis form's levers. null means this form adds no limit of its own, not that the form is unlimited.
  • effective_*the value that actually applies: min(form, org), with null treated as infinity. This is the number to show a user and to reason about. In the example above the form's quota is 10000 but the organization's is 1000, so 1000 is what refuses you. Limits are never reconciled at write time, precisely so lowering an organization limit needs no cascade — the minimum is taken when a submission arrives.
  • usage_last_24h — this form's stored submissions over the rolling 24 hours.

POST /v1/forms returns the same shape. A new form has no levers of its own (null) and no usage, but its effective_* already reflects your organization's limits.

GET /v1/submissions?form_id=…&limit=50&cursor=…#

Newest first; keyset pagination: {data:[…], has_more, next_cursor} — pass next_cursor back as cursor until has_more is false. Item shape: {id, form_id, submitted_at, ip, user_agent, origin, fields}.

GET /v1/submissions/{id} · DELETE /v1/submissions/{id}#

GET /v1/forms/{id}/export.csv#

Streaming CSV: id, submitted_at, ip, origin + union of field names.

Tiers#

free: everything above except DELETE endpoints (403 tier_forbids_delete). Key management (/v1/keys) is exempt — revoking a leaked key is never paywalled. pro/team: deletion enabled. Unlimited submissions on every tier — the per-second and per-day levers default to null (unlimited) regardless of tier, and only ByteBeacon can set them. See GET /v1/me and GET /v1/forms.

Don't hard-code that mapping. Read capabilities from /v1/me and branch on it; the tier names are ours to change, the capability names are the contract.