Errors
The error shapes the API and ingest endpoint return, and a reference for every error code with what to do about it.
The management API and the ingest endpoint use different envelopes. Both carry a machine-readable code, so branch on code, not on the message text.
Management API envelope
Errors from https://api.solidhooks.com/v1 come from oRPC and carry a code, a message and, for some errors, a data object with typed details:
{
"code": "QUOTA_EXCEEDED",
"status": 429,
"message": "Monthly delivery quota exceeded (10000/10000). Resets at 2026-11-01T00:00:00.000Z. Upgrade your plan to continue.",
"data": { "limit": 10000, "current": 10000, "resetAt": "2026-11-01T00:00:00.000Z" }
}Validation failures (BAD_REQUEST) include field-level details in the body.
Ingest envelope
Failures at https://sdhk.events/<maskId> use a flat envelope. data appears only when the error has typed details:
{ "ok": false, "code": "UNAUTHORIZED", "message": "Signature verification failed" }Successful ingest returns {"ok":true,"eventId":"evt_..."}, or {"ok":true,"duplicate":true} when an idempotency key matches an earlier event.
Error reference
400 — Bad request
| Code | Where | Meaning and what to do |
|---|---|---|
BAD_REQUEST | Any endpoint, ingest | Validation failed; the body names the fields. Also returned when you replay a duplicate or rejected event, when a source ingests email and receives an HTTP webhook, and when private delivery is not configured on the deployment. Fix the request. |
DESTINATION_NOT_PUBLIC | POST /v1/destinations/{id}/test-delivery | The URL resolves to a private or reserved range (loopback, RFC1918, CGNAT, link-local including cloud metadata), so SolidHooks will not dial it. Use a public address, or make the destination private and deliver through a connector. |
401 — Unauthorized
| Code | Where | Meaning and what to do |
|---|---|---|
UNAUTHORIZED | /v1 | The API key is missing, invalid, expired or revoked. An agent token also gets 401 here; agents use capability execution. Ask the account owner whether the key was revoked. |
UNAUTHORIZED | Ingest | Signature verification failed. The message is always Signature verification failed, on purpose. The real reason is stored on the event; see below. |
UNAUTHORIZED | POST /v1/connectors/bootstrap, POST /v1/connectors/replicas/heartbeat | Unknown or revoked enrolment key or heartbeat token. The message is deliberately generic. |
403 — Forbidden
| Code | Where | Meaning and what to do |
|---|---|---|
FORBIDDEN | /v1, capability calls | No organisation or project could be resolved. For an agent, this is what you get before calling account.select_project. |
INSUFFICIENT_API_KEY_PERMISSIONS | /v1 | The key's own permissions do not cover this action: the resource was not granted read & write, or not granted at all. Retrying with the same key will not help. Ask whoever holds the key to mint one scoped for this resource. |
CONNECTOR_LIMIT_REACHED | POST /v1/connectors | The plan's connector limit is used up. data: { limit, current }. Upgrade or remove an unused connector. |
REPLICA_LIMIT_REACHED | POST /v1/connectors/bootstrap | The plan's replica limit for this connector is used up. data: { limit, current }. Upgrade or remove a dead replica. |
CONNECTOR_REPLICA_REVOKED | POST /v1/connectors/replicas/heartbeat | The token matches a revoked replica. data carries a reason: connector_deleted, replica_revoked or identity_absent. The stock agent stops and does not re-enrol. |
NEGOTIATED_PLAN_OVERRIDE | Checkout, via /auth/* | The organisation is on a negotiated plan, so new self-serve checkout is blocked. Contact support. |
404 — Not found
| Code | Where | Meaning and what to do |
|---|---|---|
NOT_FOUND | Any resource | The resource does not exist, or belongs to another organisation or project. A foreign X-Project-Id also returns this. On a project-bound key, check which project the key is bound to. |
409 — Conflict
| Code | Where | Meaning and what to do |
|---|---|---|
CONFLICT | POST /v1/deliveries/{id}/retry | The delivery is throttled: it is queued behind a rate limit and will send on its own. Do not retry it. |
CONNECTOR_PROVISIONING | DELETE /v1/connectors/{id} | The connector is still being provisioned (provisionedAt is null). Retry in a moment. |
ROTATION_IN_USE | POST /v1/sources/{id}/cancel-rotation | The new secret has already verified requests, so reverting would reject traffic the producer is signing correctly. data: { count, lastSeenAt }. Re-send with force: true only if you are sure. |
ROTATION_OVERLAP_ACTIVE | POST /v1/sources/{id}/rotate-secret, POST /v1/destinations/{id}/rotate-signing-secret | The previous secret is still in its overlap window. data: { expiresAt }. Wait for it to close, or cancel the source rotation or end the destination overlap first. A destination rotation also accepts force: true to displace it. |
415 and 413 — Body problems at ingest
These two come from the ingest code rather than the agent guide.
| Code | Where | Meaning and what to do |
|---|---|---|
UNSUPPORTED_BODY | Ingest | The body contains NUL bytes. Binary payloads are not supported; send a text-encoded body. |
PAYLOAD_TOO_LARGE | Ingest | The body exceeds the source's maxPayloadSizeKb. |
429 — Limits
| Code | Where | Meaning and what to do |
|---|---|---|
QUOTA_EXCEEDED | Ingest, POST /v1/events/{id}/replay | The organisation used its delivery quota for the current window. Only hard-capped plans return this; see Plans and usage. data: { limit, current, resetAt }. Wait for resetAt or upgrade. |
RATE_LIMITED | Ingest, POST /v1/sources/{id}/test-signature | Throughput limit hit. data: { limit, remaining, retryAfterSeconds }. On the direct ingest route, Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining headers are also set. Back off and retry. See Rate limits. |
| (no code) | /v1 | API keys are limited to 600 requests per minute per key. |
The public connector endpoints are limited to 5 requests per second per client IP with a burst of 20, applied before the token is checked.
503 — Unavailable
| Code | Where | Meaning and what to do |
|---|---|---|
ROTATION_USAGE_UNAVAILABLE | POST /v1/sources/{id}/cancel-rotation | The per-secret counters could not be read, so SolidHooks cannot tell whether the producer has switched. The revert is refused. Retry shortly, or re-send with force: true. |
Seat limits
The agent guide lists SEAT_LIMIT_REACHED for inviting a member past the plan's seat cap. That invitation runs through the dashboard's /auth/* endpoints, not /v1, and the code in the repository is INVITATION_LIMIT_REACHED, returned as a 403.
Why ingest 401s are opaque
An unauthenticated caller gets the same Signature verification failed message whichever check failed. Telling them why would turn the 401 into a signature oracle. The request is instead stored as an event with status: "rejected" and a rejectionReason. Read it with GET /v1/events?status=rejected.
rejectionReason | Meaning |
|---|---|
signature_mismatch | The signature did not match the secret. |
missing_signature_header | The signature header was absent. |
missing_id_header | Standard Webhooks only: the webhook-id header, which the MAC covers, was absent. |
missing_timestamp_header | The timestamp header was absent. |
malformed_signature_header | The signature header could not be parsed. |
malformed_timestamp | The timestamp could not be parsed. |
timestamp_out_of_tolerance | The timestamp fell outside the replay window. |
verifier_misconfigured | The source's verifier config is unusable. |
unsupported_verifier | The source uses a verifier type that is not supported. |
Rejected events are never deduplicated and never delivered. They are rate-limited per source, so under a flood the log is a representative sample, not a full ledger. To find out why a specific request fails without waiting for traffic, call POST /v1/sources/{id}/test-signature, which does return the reason. See Verification.
Next steps
- Verification — signature schemes and rejection diagnosis
- Rate limits — ingest and delivery pacing
- Plans and usage — quotas and entitlements