Error Handling
Every error response from https://api.septemcore.com/v1/ uses
RFC 9457 Problem Details for HTTP APIs. A single, predictable format
across all 15 backend services means module code only needs one error
handler, and support teams only need one mental model.
application/problem+json
When the HTTP status is 4xx or 5xx, the response body is always:
Content-Type: application/problem+json
Required fields
{
"type": "https://api.septemcore.com/problems/not-found",
"title": "Resource not found.",
"status": 404,
"detail": "Wallet '01j9p3kx2e00000000000000' does not exist.",
"instance": "/v1/wallets/01j9p3kx2e00000000000000"
}
| Field | Type | Required | Description |
|---|---|---|---|
type | URI | ✅ | Stable, dereferenceable identifier for the error class. Never changes for a given error class. |
title | string | ✅ | Short, human-readable summary. Same for every occurrence of this error class. |
status | integer | ✅ | HTTP status code — mirrors the response status. |
detail | string | ✅ | Human-readable explanation specific to this occurrence. |
instance | URI | ✅ | Request path where the error occurred. |
Platform extensions
Two additional fields are present on every platform error response:
| Field | Type | Description |
|---|---|---|
trace_id | string | OpenTelemetry Trace ID. Use this when filing a support ticket or correlating with VictoriaMetrics traces. |
errors[] | array | Present only on validation errors. Each element: { "field": "email", "message": "Invalid email format", "code": "INVALID_FORMAT" }. |
{
"type": "https://api.septemcore.com/problems/validation-error",
"title": "Validation failed.",
"status": 400,
"detail": "2 fields failed validation.",
"instance": "/v1/users",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{ "field": "email", "message": "Invalid email format", "code": "INVALID_FORMAT" },
{ "field": "password", "message": "Minimum 12 characters", "code": "TOO_SHORT" }
]
}
The type URI is stable and machine-readable. Build your error handling
logic around type, not title or detail. Titles and details may be
localised or updated across releases; the type URI is versioned and
will not change within a major API version.
gRPC → HTTP Status Mapping
All platform backend services communicate with the API Gateway using gRPC. The Gateway translates gRPC status codes to HTTP and constructs the RFC 9457 body. Go services never produce HTTP status codes directly.
| gRPC code | HTTP status | type suffix |
|---|---|---|
codes.OK | 200 | — |
codes.InvalidArgument | 400 | /problems/validation-error |
codes.Unauthenticated | 401 | /problems/unauthorized |
codes.PermissionDenied | 403 | /problems/forbidden |
codes.NotFound | 404 | /problems/not-found |
codes.AlreadyExists | 409 | /problems/conflict |
codes.ResourceExhausted | 429 | /problems/rate-limit-exceeded |
codes.Internal | 500 | /problems/internal-error |
codes.Unavailable | 503 | /problems/service-unavailable |
codes.DeadlineExceeded | 504 | /problems/gateway-timeout |
The detail field is populated from status.Message() of the gRPC
response. The trace_id is propagated via W3C traceparent through
every gRPC hop.
Platform Error Type Catalogue
The full type URI is always:
https://api.septemcore.com/problems/{suffix}.
Authentication & authorisation
type suffix | HTTP | Trigger |
|---|---|---|
unauthorized | 401 | Missing or expired Bearer token. |
token-expired | 401 | Access token lifetime (15 min) elapsed. Call /auth/refresh. |
refresh-token-expired | 401 | Refresh token lifetime (7 days) elapsed. Full login required. |
forbidden | 403 | Token is valid but the caller lacks the required permission. |
mfa-required | 403 | The endpoint requires MFA verification. |
tenant-suspended | 402 | Tenant subscription is suspended (read-only mode). |
tenant-terminated | 403 | Tenant has been terminated. Contact platform support. |
Validation
type suffix | HTTP | Trigger |
|---|---|---|
validation-error | 400 | One or more request fields failed schema or business validation. Includes errors[]. |
invalid-filter-operator | 400 | Unsupported operator in Data API query (e.g. ?field=xyz.value). |
unknown-field | 400 | Field in ?select= or filter does not exist on the model. |
idempotency-key-missing | 400 | Idempotency-Key header absent on a mutating POST. |
idempotency-key-invalid | 400 | Idempotency-Key is not a valid UUID v4. |
Resources
type suffix | HTTP | Trigger |
|---|---|---|
not-found | 404 | Requested resource ID does not exist or is not visible to the caller's tenant. |
conflict | 409 | Optimistic locking conflict (Feature Flags version mismatch) or AlreadyExists. |
gone | 410 | API version has been sunset. Migrate to the current version. |
Rate limiting & quota
type suffix | HTTP | Trigger |
|---|---|---|
rate-limit-exceeded | 429 | Tenant or IP quota exceeded. Includes Retry-After header. |
plan-limit-exceeded | 402 | Billing plan limit reached (users, storage, modules). Upgrade plan. |
module-limit-exceeded | 402 | Tenant has reached the maximum module count (50 by default). |
Money
type suffix | HTTP | Trigger |
|---|---|---|
insufficient-funds | 422 | Debit or hold amount exceeds available balance. |
wallet-not-found | 404 | Wallet ID does not exist for this tenant. |
hold-expired | 422 | Attempted to confirm or cancel an expired hold (TTL 72 h by default). |
reversal-window-expired | 422 | Reversal requested after the max 365-day window. |
duplicate-transaction | 409 | Idempotency key collision on a money operation. |
transaction-limit-exceeded | 422 | Single transaction exceeds $100 000. |
Files
type suffix | HTTP | Trigger |
|---|---|---|
file-not-found | 404 | File ID does not exist or belongs to a different tenant. |
file-infected | 422 | Antivirus scanner detected a threat in the staging bucket. File rejected. |
file-too-large | 413 | Upload exceeds the service limit (images: 10 MB). |
staging-quota-exceeded | 429 | 100 pending-scan files already exist for this tenant. |
Events
type suffix | HTTP | Trigger |
|---|---|---|
event-not-declared | 403 | Module attempted to publish an event not listed in events.publishes[]. |
event-topic-protected | 403 | Module attempted to publish to a kernel-owned topic (auth.*, money.*, etc.). |
event-subscribe-not-declared | 403 | Module attempted to subscribe to an event not in events.subscribes[]. |
Modules
type suffix | HTTP | Trigger |
|---|---|---|
module-not-active | 404 | Module requested but status is not active for this tenant. |
module-install-failed | 422 | Module installation state machine reached failed status. |
module-version-mismatch | 422 | Required shared dependency version incompatible with the shell. |
manifest-invalid | 400 | module.manifest.json failed schema validation. Includes errors[]. |
External integrations
type suffix | HTTP | Trigger |
|---|---|---|
provider-circuit-open | 503 | Circuit breaker for the external provider is OPEN (5 failures in 30 s). Retry after 60 s. |
provider-timeout | 504 | External provider did not respond within its configured timeout. |
provider-not-found | 404 | Integration provider ID does not exist for this tenant. |
Infrastructure
type suffix | HTTP | Trigger |
|---|---|---|
internal-error | 500 | Unhandled server-side error. Always includes trace_id for debugging. |
service-unavailable | 503 | An upstream service (gRPC) is unavailable. Gateway circuit breaker is OPEN. |
gateway-timeout | 504 | gRPC deadline (5 s, GRPC_CALL_TIMEOUT_MS) exceeded before the upstream service responded. |
api-version-sunset | 410 | The requested API version (/v1/) has passed its sunset date. |
Batch / Partial Success — 207 Multi-Status
Batch endpoints (Data API batch CRUD, bulk notify, batch money transfers)
return 207 Multi-Status when part of the batch succeeds and part fails:
// POST https://api.septemcore.com/v1/data/crm/contacts/batch
// 207 Multi-Status
{
"results": [
{
"index": 0,
"status": 201,
"data": { "id": "01j9p3kx2e00000000000000" }
},
{
"index": 1,
"status": 400,
"error": {
"type": "https://api.septemcore.com/problems/validation-error",
"title": "Validation failed.",
"status": 400,
"detail": "Field 'email' is required.",
"instance": "/v1/data/crm/contacts/batch[1]",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{ "field": "email", "message": "Required field", "code": "REQUIRED" }
]
}
},
{
"index": 2,
"status": 201,
"data": { "id": "01j9p4mn3c00000000000000" }
}
],
"meta": {
"total": 3,
"succeeded": 2,
"failed": 1
}
}
Each error in results[].error is a full RFC 9457 object. The outer
response status is always 207 when the batch is mixed — never 200
or 400.
SDK Error Handling
The TypeScript SDK wraps all HTTP errors in typed exceptions:
import { kernel } from '@platform/sdk-core';
import { PlatformError } from '@platform/sdk-core/errors';
try {
const wallet = await kernel.money().retrieve('01j9p3kx2e00000000000000');
} catch (err) {
if (err instanceof PlatformError) {
switch (err.type) {
case 'https://api.septemcore.com/problems/not-found':
// Handle missing wallet gracefully
break;
case 'https://api.septemcore.com/problems/forbidden':
// Caller lacks the required permission
break;
default:
// Unexpected error — surface trace_id to user
console.error('Unexpected error', err.traceId);
}
}
}
PlatformError exposes:
| Property | Type | Description |
|---|---|---|
type | string | The full type URI from RFC 9457. |
title | string | Short stable description. |
status | number | HTTP status code. |
detail | string | Occurrence-specific description. |
instance | string | Request path. |
traceId | string | OpenTelemetry trace ID for debugging. |
errors | array | Validation errors (if any). |
Defining Custom Error Types (Module Authors)
Modules may define their own type URIs for domain-specific errors.
Rules:
- The base URI must use the module's registered slug:
https://api.septemcore.com/problems/{module-slug}/{suffix}. - The module must document all custom types in its own
docs/errors.md. - Custom types must not reuse kernel-reserved type suffixes.
- The Go service returns
status.Error(codes.InvalidArgument, "...")or another appropriate gRPC code — the Gateway constructs the HTTP error. Modules returning raw HTTP status codes from gRPC is prohibited.
// CRM module — Go service example
import "google.golang.org/grpc/status"
import "google.golang.org/grpc/codes"
func (s *ContactService) Create(ctx context.Context,
req *pb.CreateContactRequest) (*pb.CreateContactResponse, error) {
if isDuplicate {
return nil, status.Error(
codes.AlreadyExists,
"contact with this email already exists in your CRM",
)
// Gateway maps AlreadyExists → 409
// type: "https://api.septemcore.com/problems/conflict"
}
// ...
}
For module-specific problem types, the module must register a custom
error handler in its API declaration so the Gateway uses the module's
type URI instead of the default one:
// Module-specific errors registered in module API handler init
errMapping[codes.AlreadyExists] = "https://api.septemcore.com/problems/crm/duplicate-contact"
Error Handling Checklist
| ✅ Do | ❌ Do not |
|---|---|
Match on type URI for programmatic handling | Match on title or detail strings |
Surface trace_id in user-facing error messages | Discard trace_id |
Use errors[] for field-level validation feedback | Show generic "Something went wrong" for validation errors |
Retry 503 and 504 after the Retry-After interval | Retry immediately without back-off |
Treat 402 as a soft block — show upgrade prompt | Treat 402 as fatal |
Pass Idempotency-Key on retry after network failure | Use a new key per retry |