Skip to content

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:

http
Authorization: Bearer lm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
http
X-Monitor-Key: lm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The key is matched by its SHA-256 hash and resolves to a single project. Authentication failures return 401:

json
{ "message": "Missing API key." }
json
{ "message": "Invalid API key." }

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:

json
{ "message": "Ingestion is suspended while a payment is outstanding. Your existing data stays readable in the dashboard." }

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.

json
{ "message": "Backup uploads are suspended while a payment is outstanding. Existing backups stay downloadable." }

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

json
{
  "exception": {
    "class": "RuntimeException",
    "message": "Undefined array key \"id\"",
    "code": 0,
    "file": "/app/Http/Controllers/OrderController.php",
    "line": 88,
    "trace": [
      {
        "file": "/app/Http/Controllers/OrderController.php",
        "line": 88,
        "function": null,
        "class": null,
        "type": null,
        "code": { "start": 83, "lines": ["    public function show(int $id)", "    {", "        $order = $this->orders[$id];", "", "        return view('orders.show', [", "            'order' => $order,", "        ]);", "    }", "", "    public function store(Request $request)", "    {"] }
      },
      { "file": "/vendor/laravel/framework/src/Illuminate/Routing/Controller.php", "line": 54, "function": "show", "class": "App\\Http\\Controllers\\OrderController", "type": "->" }
    ]
  },
  "context": {
    "environment": "production",
    "release": "9f2c1ab3d4e5",
    "occurred_at": "2026-06-28T10:15:00+00:00"
  }
}

Field rules

FieldRequiredType / limit
exception.classyesstring, max 255
exception.messagenostring, max 8192
exception.codenoany
exception.filenostring, max 1024
exception.linenointeger ≥ 0
exception.tracenoarray
exception.trace[].code.startwith codeinteger ≥ 1, number of the snippet's first line
exception.trace[].code.lineswith codearray of strings, max 40 lines of max 500 characters
context.environmentnostring, max 100
context.releasenostring, max 255 (hex SHA enables commit correlation)
context.occurred_atnostring, 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:

  1. 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_key masks apikey and apiKey), 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 a user: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;
  2. 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 json column: 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

json
{
  "logs": [
    {
      "level": "error",
      "message": "Payment gateway timed out",
      "context": { "order_id": 1234 },
      "channel": "payments",
      "environment": "production",
      "release": "9f2c1ab",
      "logged_at": "2026-06-28T10:15:00+00:00"
    }
  ]
}

Field rules

FieldRequiredType / limit
logsyesarray, 1–500 entries
logs.*.levelnostring, max 20
logs.*.messageyesstring, max 8192
logs.*.contextnoarray
logs.*.channelnostring, max 100
logs.*.environmentnostring, max 100
logs.*.releasenostring, max 255
logs.*.logged_atnostring, 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

json
{
  "packages": [
    { "name": "laravel/framework", "version": "11.9.0", "is_dev": false },
    { "name": "phpunit/phpunit", "version": "11.2.0", "is_dev": true }
  ]
}

Field rules

FieldRequiredType / limit
packagesyesarray, 1–5000 entries
packages.*.nameyesstring, max 255
packages.*.versionyesstring, max 100
packages.*.is_devnoboolean

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:

json
{ "auto": true, "kind": "queue", "period": 1, "grace": 9, "queue": "redis/emails", "lag_ms": 1840 }

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.

json
{
  "public_key": "base64...",
  "private_key_wrapped": "base64...",
  "kdf_salt": "base64..."
}
  • 403 if the plan does not include backups.
  • 404 if the team has not configured encryption keys.

GET /api/v1/backups

json
{
  "backups": [
    {
      "id": "9b1c...uuid",
      "type": "database",
      "name": "nightly",
      "size_bytes": 10485760,
      "checksum": "sha256...",
      "created_at": "2026-06-28T03:00:00+00:00"
    }
  ]
}

POST /api/v1/backups

multipart/form-data upload of an already-encrypted blob.

FieldRequiredNotes
typeyesdatabase or files
namenostring, max 255
fileyesthe encrypted archive
  • 201 Created returns the stored backup's metadata (same shape as a list entry).
  • 403 if the plan does not include backups.
  • 413 Request Entity Too Large if 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

StatusMeaning
202 AcceptedIngested (check stored to see whether/how much was kept).
201 CreatedBackup uploaded.
204 No ContentBackup deleted.
401 UnauthorizedMissing or invalid API key.
402 Payment RequiredIngestion and backup uploads suspended while a payment is outstanding.
403 ForbiddenFeature not on the team's plan.
404 Not FoundEncryption keys not configured (key endpoint).
413 Payload Too LargeBackup storage quota exceeded.
422 Unprocessable EntityValidation failed.
429 Too Many RequestsRate limit exceeded.

You are reading the Quiet Guard v1.0 documentation.