Ingestion API reference
This is the platform-neutral HTTP API that every client integrates against. If you are building or maintaining a client package, implement against this contract. All routes are JSON over HTTPS.
Base path and versioning
The ingestion endpoints live under /api/v1. Send and accept application/json (the backup upload is the one multipart/form-data exception).
Authentication
Every request authenticates with a project API key (lm_...). Two header styles are accepted; the bearer form is preferred:
The key is matched by its SHA-256 hash and resolves to a single project. Authentication failures return 401:
Rate limits
- Ingestion (
/ingest,/logs,/dependencies): 300 requests per minute, keyed by project token. - Heartbeats (
/heartbeats/*): 300 requests per minute, keyed by project token, on a budget separate from ingestion so an exception storm cannot delay heartbeats. - Backups (
/encryption-key,/backups*): 60 requests per minute.
Exceeding a limit returns 429 Too Many Requests.
When ingestion is suspended
While a payment is outstanding on the team's subscription, /ingest, /logs and /dependencies answer 402 Payment Required and store nothing:
Heartbeat pings keep being recorded during that window, so a suspended team's scheduled tasks do not start looking broken. Treat a 402 as a state to log and surface, not as an error to retry: it clears on its own when the payment goes through.
One vault endpoint answers 402 in the same window, with its own message: POST /backups. Reading stays open, the key material included, so a restore of what you have already paid for keeps working.
Listing, downloading and deleting a backup stay open, and so does the key endpoint, which serves the material monitor:restore needs: an archive already stored can still be fetched, and decrypted.
POST /api/v1/ingest, report an exception
Reports a single exception occurrence.
Request body
Field rules
| Field | Required | Type / limit |
|---|---|---|
exception.class | yes | string, max 255 |
exception.message | no | string, max 8192 |
exception.code | no | any |
exception.file | no | string, max 1024 |
exception.line | no | integer ≥ 0 |
exception.trace | no | array |
exception.trace[].code.start | with code | integer ≥ 1, number of the snippet's first line |
exception.trace[].code.lines | with code | array of strings, max 40 lines of max 500 characters |
context.environment | no | string, max 100 |
context.release | no | string, max 255 (hex SHA enables commit correlation) |
context.occurred_at | no | string, max 64 (parseable date) |
The context. The table names only the three context keys the server validates and files into columns of their own. The context is stored whole all the same, with every key the client sends (url, method, request, user...), and it does not reach the database as it came:
- it is first masked on arrival, with Quiet Guard's own list, which takes the secrets of the Laravel SDK's default list, and by the clients' rule: case-insensitive substring, a hyphen counting as an underscore, a name also looked for without its inner separators (
api_keymasksapikeyandapiKey), a JSON string opened, or masked whole when it does not decode and a quoted name followed by a:in it matches, the query parameters and token-shaped path segments of a URL, and the password of auser:password@, a DSN's (mysql://...) included (see the core's scrubbing page). A secret of that list which a client removed from its own is masked all the same, and so is what a client installed before version 0.3.1 could not mask under those names: it still sends the URL unmasked, a closed Livewire snapshot and hyphenated headers. The headers of that list that carry the visitor's IP address (x-forwarded-for,x-real-ip...) are not taken: they are personal data rather than secrets, and keeping or masking them is for the data controller to decide. The server stores them as they arrive; - it is then bounded to 65,535 bytes of encoded JSON and to 30 levels of nesting, since MariaDB refuses a JSON document 32 levels deep in a
jsoncolumn: past either bound, or when it cannot be encoded at all, the stored context is{"_truncated": true}.
environment, release and occurred_at are read before that bound and keep their columns: an oversized context does not take them with it. Sealed or in clear, what is written is this masked and bounded context, and a team whose encrypted storage is on never seals the raw value.
Frames. The server keeps only file (cut at 1024 characters), line, function and class (255 characters each), type (8), is_site and, of code, only start and lines of each frame. A value of another type than the one expected is dropped rather than converted, except for line, which accepts any number, even written as a string ("88"), and keeps it as an integer, and for code.start, which accepts an integer written as a string ("40", validation refusing any other number) and keeps it as an integer too; any other key, call arguments first of all, never reaches the database.
Frame zero and the source snippet. From version 0.3, the clients put the throw site at the head of trace: a frame reduced to the exception's own file and line, since PHP's trace starts at the caller and never held the failing line. A trace_limit counts that frame (1 = the site alone). Each frame may carry an optional code object: start, the number of its first line, and lines, the source lines around the frame's line. The clients send the five lines on each side (eleven at most), each cut at 500 characters, on at most ten frames per report, never for a path containing /vendor/, and mask whole as [scrubbed] any line that names a key of their scrub list and gives it a value. The server does not go over these lines again, only the context is masked on arrival; it refuses a snippet past 40 lines or 500 characters a line, and seals snippets at rest with the rest of the trace when the team's encrypted storage is on.
Responses
202 Accepted: stored:
``json { "message": "accepted", "stored": true, "issue_id": "01kw6tkrd8wjbfv2pktr7ssw43" } ``
202 Accepted: over quota, acknowledged but not stored:
``json { "message": "accepted", "stored": false } ``
422 Unprocessable Entity: validation failed.
issue_id is the issue's public identifier, a ULID. It is the same value the dashboard URL carries, so you can log it and paste it into the address bar. It is not a row number, and there is no event counter in the response: those would have told every project how many exceptions the whole platform had stored.
POST /api/v1/logs, report application logs (batch)
Stores a batch of log entries. Gated by the logs plan feature.
Request body
Field rules
| Field | Required | Type / limit |
|---|---|---|
logs | yes | array, 1–500 entries |
logs.*.level | no | string, max 20 |
logs.*.message | yes | string, max 8192 |
logs.*.context | no | array |
logs.*.channel | no | string, max 100 |
logs.*.environment | no | string, max 100 |
logs.*.release | no | string, max 255 |
logs.*.logged_at | no | string, max 64 |
A log's context. The server does not mask it: it is what your application chose to write in its own log, and your client's scrub list is what applies to it, before sending. The server bounds it by the same rule as an exception's context: past 65,535 bytes of encoded JSON it is replaced with {"_truncated": true}, and the entry is stored with the rest of the batch.
Responses
202 Accepted:
``json { "message": "accepted", "stored": 137 } ``
stored is how many entries were actually saved (a partial batch is stored when the quota is nearly full).
403 Forbidden: plan does not include logs:
``json { "message": "Application logs are not available on your current plan." } ``
POST /api/v1/dependencies, report composer.lock
Replaces the project's dependency snapshot and triggers a background vulnerability scan.
Request body
Field rules
| Field | Required | Type / limit |
|---|---|---|
packages | yes | array, 1–5000 entries |
packages.*.name | yes | string, max 255 |
packages.*.version | yes | string, max 100 |
packages.*.is_dev | no | boolean |
Response
202 Accepted:
``json { "message": "accepted", "dependencies": 142 } ``
POST /api/v1/heartbeats/{slug}, ping a scheduled task
Signals that a scheduled task has just run. The body is optional: without one, the ping behaves as it always has. The Laravel SDK sends, for its automatic heartbeats:
auto must be true to be taken into account; any other value leaves the ping behaving as if it carried no body. kind is scheduler or queue. queue carries up to 100 characters. A heartbeat created by a ping carrying auto, kind, period (1 to 1440) and grace (0 to 1440) is born armed; an existing heartbeat is never re-armed. lag_ms (0 or more; a value above 86,400,000, one day, is stored as one day) is recorded only for a queue heartbeat, and a queue ping with no lag_ms clears the previous value. An unreadable field is ignored, never refused: the ping is always recorded.
{slug} is normalised to lowercase letters, digits and dashes. The first ping on an unknown slug registers the heartbeat, armed when the body above is enough to arm it, unarmed otherwise: recorded either way, it never alerts until an expected period is set. See Heartbeats.
Responses
202 Accepted:
``json { "message": "pong", "armed": true } ``
armed says whether an expected period has been set, so a client can tell a recorded ping from a watched one.
422 Unprocessable Entity: the slug normalises to an empty string, or the project already holds the maximum number of heartbeats.
Backup vault endpoints
These power the encrypted backup vault. All require the backups plan feature. While a payment is outstanding, POST /backups answers 402 Payment Required; fetching the key material, listing, downloading and deleting stay open, so monitor:restore keeps working on the archives you already have.
GET /api/v1/encryption-key
Serves the team's key material so the client can seal and (with the passphrase, locally) unwrap.
403if the plan does not include backups.404if the team has not configured encryption keys.
GET /api/v1/backups
POST /api/v1/backups
multipart/form-data upload of an already-encrypted blob.
| Field | Required | Notes |
|---|---|---|
type | yes | database or files |
name | no | string, max 255 |
file | yes | the encrypted archive |
201 Createdreturns the stored backup's metadata (same shape as a list entry).403if the plan does not include backups.413 Request Entity Too Largeif the upload would exceed the storage quota:
``json { "message": "Backup storage quota exceeded.", "used_bytes": 5012345678, "limit_bytes": 5000000000 } ``
GET /api/v1/backups/{uuid}
Streams the encrypted blob as a download (<name>.enc).
DELETE /api/v1/backups/{uuid}
Deletes the backup. Returns 204 No Content.
Error summary
| Status | Meaning |
|---|---|
202 Accepted | Ingested (check stored to see whether/how much was kept). |
201 Created | Backup uploaded. |
204 No Content | Backup deleted. |
401 Unauthorized | Missing or invalid API key. |
402 Payment Required | Ingestion and backup uploads suspended while a payment is outstanding. |
403 Forbidden | Feature not on the team's plan. |
404 Not Found | Encryption keys not configured (key endpoint). |
413 Payload Too Large | Backup storage quota exceeded. |
422 Unprocessable Entity | Validation failed. |
429 Too Many Requests | Rate limit exceeded. |
You are reading the Quiet Guard v1.0 documentation.