Complete reference for error responses returned by the NotifyChain listener HTTP API.
Every error response is JSON. Use this document to map a status code and body to a cause and a concrete fix.
Related: API Contract and Event Reference · API Versioning · Contributor Troubleshooting
- Error Response Format
- Diagnostic Headers
- HTTP Status Codes
- 400 — Bad Request
- 401 — Unauthorized
- 404 — Not Found
- 409 — Conflict
- 413 — Payload Too Large
- 429 — Too Many Requests
- 500 — Internal Server Error
- 503 — Service Unavailable
- Batch Validation Error Codes
- Quick Reference Table
Most errors return a single error field:
{
"error": "Template not found"
}Rate-limit responses add a human-readable message:
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 42 seconds."
}Batch validation returns a structured, per-item error array instead of a flat error string — see Section 12:
{
"valid": false,
"processedCount": 0,
"errors": [
{ "index": 2, "code": "MISSING_FIELD", "message": "Field 'recipient' is required." }
]
}Note: the API does not currently emit a stable machine-readable error code at the top level for non-batch errors. Match on the HTTP status code first, and treat the
errorstring as human-readable. Only the batch validationcodevalues in Section 12 are stable identifiers safe to branch on.
Every response — success or error — carries these headers. Include them in any bug report.
| Header | Description |
|---|---|
X-Request-Id |
Unique ID generated per request. Appears in listener logs for the same request. |
X-Correlation-Id |
Echoes an inbound X-Correlation-Id if supplied, otherwise a new UUID. Use it to trace one logical operation across services. |
X-API-Version |
Active API version — currently v1. |
Rate-limited endpoints additionally return X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and — on a 429 — Retry-After.
To correlate a failure with server logs, send your own correlation ID:
curl -i -H "X-Correlation-Id: debug-run-001" http://localhost:8787/api/events| Code | Meaning | Retry? |
|---|---|---|
200 |
Success | — |
201 |
Resource created (scheduled notification, template) | — |
202 |
Accepted — webhook received, processed asynchronously | — |
204 |
No content — CORS preflight (OPTIONS) |
— |
400 |
Bad Request — malformed or invalid input | No — fix the request |
401 |
Unauthorized — missing or invalid credentials/signature | No — fix credentials |
404 |
Not Found — unknown route or missing resource | No |
409 |
Conflict — resource already exists | No — use a different key |
413 |
Payload Too Large — body exceeds the size limit | No — shrink the payload |
429 |
Too Many Requests — rate limit exceeded | Yes — after Retry-After |
500 |
Internal Server Error — unhandled server failure | Yes — with backoff |
503 |
Service Unavailable — an optional subsystem is not enabled | No — enable the subsystem |
The request reached the right handler but the input was rejected.
{ "error": "Missing required fields: executeAt, payload, targetRecipient" }Cause: POST /api/schedule was called without all three required fields.
Resolution: Supply every listed field.
curl -X POST http://localhost:8787/api/schedule \
-H "Content-Type: application/json" \
-d '{
"executeAt": "2026-01-01T12:00:00Z",
"payload": { "message": "Reminder" },
"targetRecipient": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}'{ "error": "executeAt is not a valid date" }Cause: executeAt could not be parsed as a date.
Resolution: Use an ISO 8601 timestamp, e.g. 2026-01-01T12:00:00Z. A Unix epoch number or a locale-formatted string will be rejected.
{
"valid": false,
"processedCount": 0,
"errors": [
{ "index": -1, "code": "PARSE_ERROR", "message": "Request body must be valid JSON." }
]
}Cause: The body of POST /api/notifications/validate-batch was not valid JSON.
Resolution: Verify the payload parses, and that Content-Type: application/json is set. A common cause is shell quoting — prefer --data-binary @file.json over an inline heredoc.
{ "error": "Failed to read request body" }Cause: The connection dropped or the stream errored while POST /api/webhooks was reading the body.
Resolution: Retry. If it persists, check for a proxy timeout between client and listener.
{ "error": "Missing required fields" }{ "error": "Invalid template ID" }{ "error": "Missing unique key" }Cause: A template request omitted required fields, or passed a non-numeric/malformed template ID in the path.
Resolution: Check the request against listener/src/api/template-routes.ts. Template IDs are numeric; /api/templates/abc is rejected.
Returned by POST /api/webhooks (signature verification) and the scheduler admin routes.
| Response | Cause | Resolution |
|---|---|---|
{"error": "Missing signature header"} |
No signature header on the webhook request. | Sign the raw body and send the signature header. |
{"error": "Missing key-id header"} |
No key-id header supplied. | Send the key-id identifying which shared secret was used. |
{"error": "Unknown key-id"} |
The key-id is not registered with the listener. | Verify the key-id matches one configured in the listener's webhook key set. |
{"error": "Request signature expired"} |
The signed timestamp is outside the allowed freshness window. | Sign and send within the window. Check for clock skew — sync with NTP. |
{"error": "Invalid signature"} |
The computed HMAC does not match the supplied signature. | See below. |
{"error": "Unauthorized"} |
Scheduler admin route called without valid credentials. | Supply the configured API key. |
Debugging Invalid signature — the three usual causes, in order of likelihood:
- Signing the parsed body rather than the raw bytes. Re-serialising JSON changes key order and whitespace, producing a different digest. Sign the exact bytes you transmit.
- A secret mismatch between sender and listener — check for a trailing newline in the
.envvalue. - Encoding mismatch — confirm hex vs. base64 matches what the verifier expects.
See listener/src/services/webhook-verifier.ts for the exact verification logic.
{ "error": "Not found" }Cause: No route matched the method and path.
Resolution:
- Check the method —
GET /api/scheduledoes not exist; scheduling isPOST /api/schedule. - Check the version prefix. Both
/api/eventsand/api/v1/eventswork (/api/v1/*is rewritten to/api/*), but/v1/eventsdoes not. - Confirm the subsystem is registered. Template routes only exist when a
templateServiceis configured; without it,/api/templatesreturns 404 rather than 503.
{ "error": "Template not found" }Cause: The template ID or unique key does not exist.
Resolution: List templates with GET /api/templates and confirm the identifier. Note a deleted template returns 404 on subsequent reads.
{ "error": "Template with this unique key already exists" }Cause: POST /api/templates used a unique key already taken.
Resolution: Choose a different unique key, or update the existing template instead of creating a new one. To check first:
curl http://localhost:8787/api/templates{ "error": "Notification payload of 98304 bytes exceeds the 65536-byte limit. Reduce the payload size and retry." }Cause: The notification payload exceeded the maximum size — 64 KB (65,536 bytes) by default.
Resolution:
- Move large content out of the payload and reference it by URL.
- Split a batch into smaller requests.
- Strip redundant nesting or whitespace before sending.
Retrying unchanged will fail identically. See listener/src/utils/payload-size-validator.ts.
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 42 seconds."
}Cause: The client exceeded its allowed request count within the sliding window.
Response headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Maximum requests permitted per window. |
X-RateLimit-Remaining |
Requests left in the current window. |
X-RateLimit-Reset |
Unix timestamp (seconds) when the window resets. |
Retry-After |
Seconds to wait before retrying. |
Resolution: Honour Retry-After — do not retry immediately. Use exponential backoff with jitter for automated clients, and read X-RateLimit-Remaining proactively rather than waiting to be blocked.
GET /api/rate-limit/metrics is exempt from rate limiting by design, so you can always inspect the metrics that explain why you are being throttled:
curl http://localhost:8787/api/rate-limit/metrics{ "error": "Internal server error" }Some handlers surface the underlying exception message instead:
{ "error": "SQLITE_BUSY: database is locked" }Cause: An unhandled exception — commonly a database failure, a downstream provider timeout, or a bug.
Resolution:
- Capture the
X-Request-Idfrom the response. - Grep the listener logs for that ID — the full stack trace is logged server-side even when the response body is generic.
- For
SQLITE_BUSY, check whether another process holds the database file; see Contributor Troubleshooting. - Retry with exponential backoff — many 500s here are transient.
If reproducible, open an issue including the request ID, the request, and the surrounding log lines.
A 503 from this API almost always means an optional subsystem is not enabled, not that the server is overloaded. Retrying will not help — enable the subsystem.
| Response | Subsystem | Resolution |
|---|---|---|
{"error": "Scheduler not enabled"} |
Scheduled notifications | Configure and enable the scheduler; all /api/schedule* routes return this until it is running. |
{"error": "Rate limiting not enabled"} |
Rate limiter | Enable rate limiting to use /api/rate-limit/metrics. |
{"error": "Health monitor not configured or no report yet"} |
Notification health monitor | Configure the monitor, or wait for its first report — this also appears briefly right after startup. |
{"error": "Metrics history store unavailable"} |
Analytics history | Enable the metrics history store for /api/analytics/history. |
{"error": "Analytics aggregator unavailable"} |
Analytics aggregator | Enable the aggregator for /api/analytics. |
See ENVIRONMENT_VARIABLES_AND_SECRETS.md for the configuration each subsystem requires.
POST /api/notifications/validate-batch returns a structured error array. These code values are stable and safe to branch on.
{
"valid": false,
"processedCount": 0,
"errors": [
{ "index": 0, "code": "MISSING_FIELD", "message": "Field 'recipient' is required." },
{ "index": 3, "code": "INVALID_CHANNEL", "message": "Channel 'carrier-pigeon' is not supported." }
]
}index is the zero-based position in the submitted array; -1 means the error applies to the request as a whole.
| Code | Meaning | Resolution |
|---|---|---|
PARSE_ERROR |
Body is not valid JSON. Always index: -1. |
Validate the JSON and set Content-Type: application/json. |
INVALID_STRUCTURE |
Top-level payload is not the expected shape. | Send an array of notification objects (or the documented wrapper). |
EMPTY_BATCH |
The batch contains no items. | Include at least one notification. |
INVALID_ITEM |
An item is not an object. | Remove nulls, strings, or primitives from the array. |
MISSING_FIELD |
A required field is absent. | Add the field named in message. |
EMPTY_FIELD |
A required field is present but empty or whitespace-only. | Provide a non-empty value. |
INVALID_CHANNEL |
The channel is not supported. | Use a supported channel identifier. |
DUPLICATE_RECIPIENT |
The same recipient appears more than once in the batch. | Deduplicate before sending — see ADR-0005. |
VALIDATION_ERROR |
General validation failure from the batch service. | Read message for the specific constraint violated. |
Validate a batch before submitting it:
curl -X POST http://localhost:8787/api/notifications/validate-batch \
-H "Content-Type: application/json" \
--data-binary @batch.jsonYou can also run the validator locally without a server:
cd listener
npm run validate:batch| Status | Response error |
Endpoint(s) | Fix |
|---|---|---|---|
| 400 | Missing required fields: executeAt, payload, targetRecipient |
POST /api/schedule |
Add all three fields |
| 400 | executeAt is not a valid date |
POST /api/schedule |
Use ISO 8601 |
| 400 | Failed to read request body |
POST /api/webhooks |
Retry; check proxy timeouts |
| 400 | Missing required fields |
POST /api/templates |
Add required template fields |
| 400 | Invalid template ID |
/api/templates/:id |
Use a numeric ID |
| 400 | Missing unique key |
/api/templates |
Supply the unique key |
| 401 | Missing signature header |
POST /api/webhooks |
Sign the request |
| 401 | Missing key-id header |
POST /api/webhooks |
Send the key-id |
| 401 | Unknown key-id |
POST /api/webhooks |
Register/correct the key-id |
| 401 | Request signature expired |
POST /api/webhooks |
Fix clock skew; re-sign |
| 401 | Invalid signature |
POST /api/webhooks |
Sign raw bytes, verify secret |
| 401 | Unauthorized |
/api/schedule admin |
Supply the API key |
| 404 | Not found |
any | Check method, path, version prefix |
| 404 | Template not found |
/api/templates/* |
Verify the identifier |
| 409 | Template with this unique key already exists |
POST /api/templates |
Use a different key |
| 413 | …exceeds the 65536-byte limit… |
notification endpoints | Shrink the payload |
| 429 | Too Many Requests |
rate-limited routes | Wait Retry-After seconds |
| 500 | Internal server error |
any | Trace X-Request-Id in logs |
| 503 | Scheduler not enabled |
/api/schedule* |
Enable the scheduler |
| 503 | Rate limiting not enabled |
/api/rate-limit/metrics |
Enable rate limiting |
| 503 | Health monitor not configured or no report yet |
/api/notifications/health |
Configure the monitor |
| 503 | Metrics history store unavailable |
/api/analytics/history |
Enable the history store |
| 503 | Analytics aggregator unavailable |
/api/analytics |
Enable the aggregator |
If you hit an error not covered here, open an issue with:
- The full request (method, path, headers, body — redact secrets).
- The full response, including status code and the
X-Request-Id/X-Correlation-Idheaders. - Listener log lines matching that request ID.
- The listener version or commit SHA.
Source of truth for these responses: listener/src/api/events-server.ts, listener/src/api/template-routes.ts, listener/src/api/rate-limiter.ts, and listener/src/utils/batch-validator.ts.