Deploy & Catch
Catch — error events
POST /api/catch/events — record an error or non-error event from any client/server SDK. Async write to ClickHouse. Symbolication and grouping happen in the background.
The Catch error-event ingest endpoint accepts unhandled exceptions, console errors, and non-error captures (captureMessage) from every official Sankofa SDK. The handler validates, scrubs PII, computes a fingerprint (or honors a client-supplied one), inserts to ClickHouse, and returns 200 immediately — symbolication and issue grouping run asynchronously in the background.
For Catch product semantics (issues, alerts, auto-rollback), see Catch product overview.
Authentication
Required header: x-api-key: sk_live_… or x-api-key: sk_test_…. See Authentication.
CORS: permissive (* origins) — works from any web origin without configuration.
Request body — single event
{
"wire_version": 1,
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"ts_ms": 1715183942483,
"environment": "live",
"distinct_id": "user_123",
"anon_id": "anon_a3b9ff",
"session_id": "sess_xyz",
"level": "error",
"type": "unhandled_exception",
"platform": "javascript",
"sdk": {
"name": "sankofa.web",
"version": "1.0.0"
},
"exception": {
"type": "TypeError",
"value": "Cannot read property 'length' of undefined",
"stacktrace": {
"frames": [
{
"filename": "app.js",
"function": "processData",
"lineno": 42,
"colno": 15,
"in_app": true
}
]
}
},
"tags": { "feature": "checkout" },
"extra": { "cart_total": 99.99 },
"user": {
"id": "user_123",
"email": "[email protected]"
},
"device": {
"os": "iOS",
"os_version": "17.4",
"browser": "Safari",
"browser_version": "17",
"model": "iPhone 15"
},
"breadcrumbs": [
{
"ts_ms": 1715183932000,
"type": "navigation",
"category": "router",
"message": "navigated to /checkout",
"level": "info"
}
],
"fingerprint": ["TypeError", "processData"],
"release": "1.2.0",
"trace_id": "80f198ee56343ba7123456789abcdef0",
"span_id": "05e3ac9a4f6e3b90"
}Request body — batch
{
"wire_version": 1,
"events": [
{ /* event 1 */ },
{ /* event 2 */ }
]
}Up to 1000 events per batch. Per-event validation failures within a batch don't reject the whole batch — the response counts accepted vs rejected.
Top-level fields
wire_versionintegerRequiredevent_idstring (UUID v4)Requiredts_msinteger (epoch ms)RequiredenvironmentstringRequiredlevelstringRequiredtypestringRequiredplatformstringRequiredsdk.namestringRequiredsdk.versionstringRequiredexceptionobjectmessagestringdistinct_idstringanon_idstringsession_idstringtagsobjectextraobjectuserobjectdeviceobjectbreadcrumbsarrayfingerprintarray<string>releasestringserver_namestringrequestobjectdebug_meta.imagesarrayflag_snapshotobjectconfig_snapshotobjecttrace_idstring (32-char hex)span_idstring (16-char hex)replay_chunk_indexintegerException schema
{
"type": "TypeError",
"value": "Cannot read property 'length' of undefined",
"module": "node:internal/process/main_thread_only.js",
"thread_id": "main",
"stacktrace": {
"frames": [
{
"filename": "app.js",
"function": "processData",
"lineno": 42,
"colno": 15,
"abs_path": "/app/build/app.js",
"context_line": " return data.length;",
"pre_context": ["function processData(data) {"],
"post_context": ["}"],
"in_app": true,
"module": "checkout",
"platform": "javascript"
}
]
}
}in_app is the key field for fingerprinting — the engine prefers in-app frames when computing the auto-fingerprint and breaks ties using stack depth.
Debug-meta schema (Apple / NDK)
{
"images": [
{
"type": "macho",
"debug_id": "550e8400-e29b-41d4-a716-446655440000",
"code_file": "/var/containers/app",
"image_addr": "0x100000000",
"image_size": 10485760,
"image_vmaddr": "0x100000000",
"arch": "arm64"
}
]
}The engine's symbolication worker uses debug_id to look up dSYM bundles uploaded via Catch symbolication.
Response
200 OK on accepted batch:
{
"accepted": 5,
"rejected": 0,
"reject_reasons": []
}When some events fail per-event validation:
{
"accepted": 4,
"rejected": 1,
"reject_reasons": [
{
"event_id": "abc-123",
"reason": "ts_ms_out_of_window"
}
]
}When the project's monthly Catch quota is hit:
{
"accepted": 5,
"rejected": 45,
"reject_reasons": [],
"quota_dropped": 45,
"quota_current": 10000,
"quota_limit": 10000
}The response also includes the header x-sankofa-cap-hit: catch_events so quota-aware clients can back off without reading the body.
Validation
Per-event validation runs before fingerprinting:
| Reason | When |
|---|---|
wire_version_mismatch | wire_version not 1 |
missing_event_id | event_id missing |
invalid_event_id | event_id not a valid UUID |
ts_ms_out_of_window | ts_ms more than 7 days past or 10 minutes future |
invalid_environment | environment not live / test |
invalid_level | level not in allowed list |
missing_type | type missing |
missing_platform | platform missing |
missing_sdk | sdk.name or sdk.version missing |
missing_body | Both exception and message missing |
Failed events don't break the batch — the response counts them under rejected with their reason.
Server-side enrichment
The engine adds:
| Field | Source |
|---|---|
received_at | Server timestamp on accept |
project_id, organization_id | Resolved from API key |
environment (cross-check) | If body's environment mismatches the resolved one, returns 400 |
| GeoIP fields | Country / region / city from request IP |
| User-Agent fields | Parsed when device not supplied |
client_ip | Resolved client IP, normalized for private addresses |
PII scrubbing
PII scrubbing runs before validation:
user.emailis hashed when the project's PII config hashash_emails: trueuser.ip_addressis set tonullwhenredact_ip: truerequest.headershasauthorization,cookie,x-api-keyredacted
Scrubbed values still count toward the validation's "non-empty" checks.
Fingerprinting
If fingerprint is provided in the request, the engine uses it. Tokens like {{ type }} / {{ message }} / {{ stack-top }} are templated against the event's actual values.
If absent, the engine computes:
fingerprint = SHA-1(exception.type + exception.value + first_in_app_frame.filename + first_in_app_frame.function)Same fingerprint = same issue. The dashboard groups events by issue and shows aggregated counts + first-seen / last-seen.
Async post-processing
After the response returns, the engine runs (async, fire-and-forget):
- Issue creation / update — finds or creates the matching
catch_issuesrow, updateslast_seen_atand counter. - Symbolication — looks up source maps / dSYM / R8 / NDK / Flutter symbols matching
release+debug_id, patchesstack_frames_jsonin-place. - Alert evaluation — checks all alerts configured for the project, fires webhooks if a threshold is crossed.
- Outbound webhook —
catch.event.capturedevent for subscribers;catch.issue.createdif it's a brand-new fingerprint.
If any of these fail, the event still appears in the dashboard — they just lack the symbolicated stacks or downstream webhook delivery until the worker retries.
Idempotency
Catch ingest is idempotent per event_id. Posting the same event ID twice produces only one row in catch_events (the second is a no-op). This is different from analytics ingest, which generates a new ID server-side every time.
This means SDK retries are safe — the SDK can fire-and-forget on send failures without worrying about duplicate events.