Prefer an SDK when there is one
The SDKs queue events, retry, sample and redact for you. Use the API directly when you need full control or there's no SDK for your platform. The contract below is stable: we add optional fields, we don't remove or rename them.Quick example
curl -X POST https://api.codeskop.com/v1/events \ -H "Authorization: Bearer cs_live_pk_…" \ -H "Content-Type: application/json" \ -d '{ "sent_at": "2026-10-05T12:00:00Z", "context": { "device": { "platform": "python", "hostname": "api-1" }, "app": { "release": "1.4.2", "environment": "production" } }, "batch": [{ "event_id": "0f8fad5b-d9cb-469f-a165-70867728950e", "type": "exception", "severity": "high", "occurred_at": "2026-10-05T11:59:58Z", "user": { "id": "user_8412" }, "payload": { "exception_class": "ValueError", "message": "Order total can't be negative", "stacktrace": [ { "class": "app.orders", "method": "create_order", "file": "app/orders.py", "line": 42 }, { "class": "app.views", "method": "checkout", "file": "app/views.py", "line": 118 } ] } }] }'Endpoint and authentication
| Option | Type | Description |
|---|---|---|
| POST /v1/events | JSON body | Sends a batch of events. Base URL https://api.codeskop.com. |
| Authorization | header | Bearer cs_live_pk_… or cs_test_pk_…: the project's public key from API tokens. Secret keys (_sk_) are always refused with 403. |
| Content-Type | header | application/json |
| Content-Encoding | header | Optional gzip. Recommended: limits apply to the compressed size. |
The key decides the project and environment, so you never send those in the body. Use a test key while developing; test environments have shorter retention and don't count toward billing.
The envelope
{ "sent_at": "2026-10-05T12:00:00Z", // when this request left your server (UTC) "context": { "device": { ... }, // where it ran: platform, hostname, os, runtime "app": { ... } // what ran: release, environment, package }, "batch": [ <event>, <event>, ... ] // 1 to 100 events}context describes where the events came from and applies to every event in the batch. Both device and app are required objects but may be empty; unknown fields are kept. Useful fields for servers: device.platform (for example python, node, go), device.hostname, device.os, app.release and app.environment.
Each event
{ "event_id": "uuid", // unique per event; used to drop duplicates "type": "exception", // see Event types "severity": "low" | "medium" | "high" | "critical", "occurred_at": "2026-10-05T11:59:58Z", "user": { "id": "user_8412" }, // optional: your own stable user ID "payload": { ... } // depends on the type}Send a new event_id (a UUID) for every event and reuse it when you retry: we drop duplicates for 48 hours, so a retried batch is never counted twice. If your server clock is off, we correct occurred_at using the difference between sent_at and when we received the request.
Event types
| Option | Type | Description |
|---|---|---|
| exception | error | A handled or unhandled error. Payload: exception_class, message, stacktrace (list of frames with class, method, file, line; innermost first). Every plan. |
| crash | error | A process crash. Same payload as exception. Every plan. |
| api_error | network | A failed outgoing call: status 400 or more, or a network error. Payload below. Starter and up. |
| api_timing | network | Every outgoing call (sampled). Same payload without error_kind. Starter and up. |
| track / screen / identify | analytics | Product analytics. See Tracking. Growth and Scale. |
| heartbeat | presence | Keeps sessions and live users accurate. Free; doesn't count toward your quota. |
Events your plan doesn't include are accepted and quietly dropped, so upgrading never requires a code change.
How errors are grouped
We group exceptions by exception_class plus the class and method of the top five frames. Line numbers don't affect grouping, so a deploy that shifts lines keeps the same issue. Without a stacktrace, all errors of one class end up in one issue.
Network payload
{ "event_id": "…", "type": "api_error", "severity": "high", "occurred_at": "…", "payload": { "method": "POST", "host": "payments.example.com", "path": "/v1/charges", "status": 502, "duration_ms": 1840, "error_kind": "http_5xx" // api_error only }}Send path without the query string. We turn IDs into placeholders (/users/42 becomes /users/{id}) so each endpoint is one row on the Network page. Optional: request_bytes, response_bytes and timing (dns, connect, tls, ttfb, total in ms). error_kind is one of http_4xx, http_5xx, network_error, timeout, aborted.
Limits
| Option | Type | Description |
|---|---|---|
| Request size | 1 MB | Measured before decompression. 16 MB after decompression. |
| Events per request | 100 | Larger batches are refused with 400. |
| Event size | 64 KB | A larger event is skipped and its ID returned in rejected; the rest of the batch is kept. |
| Requests per minute | per key | Free 60, Starter 300, Growth 1,200, Scale 6,000. |
| Monthly events | per plan | Over your quota, events are dropped but the request still succeeds. See Plans. |
Responses and retries
| Option | Type | Description |
|---|---|---|
| 200 | accepted | Body {}, or {"rejected": ["event_id", …]} listing events that were skipped. Don't resend those. |
| 400 | invalid | Bad JSON, a bad envelope or too many events. The body explains why in errors. Fix it; don't retry. |
| 401 / 403 | key | Unknown, revoked or secret key, a key without ingest access, or a suspended account. Don't retry. |
| 413 | too large | Split the batch and send the parts. |
| 429 / 503 | try later | Rate limited or briefly unavailable. Wait for Retry-After seconds (default 5), then send the same batch again. |
import gzip, json, uuid, datetime, requests def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat() body = { "sent_at": now(), "context": {"device": {"platform": "python"}, "app": {"release": "1.4.2"}}, "batch": [{ "event_id": str(uuid.uuid4()), "type": "exception", "severity": "high", "occurred_at": now(), "payload": {"exception_class": "ValueError", "message": "Order total can't be negative"}, }],} r = requests.post( "https://api.codeskop.com/v1/events", data=gzip.compress(json.dumps(body).encode()), headers={ "Authorization": "Bearer cs_live_pk_…", "Content-Type": "application/json", "Content-Encoding": "gzip", }, timeout=10,)if r.status_code in (429, 503): retry_after = int(r.headers.get("Retry-After", "5")) # wait, then send the same batch againRemote config
SDKs read GET /v1/config with the same key to learn which features your plan and project settings allow and how much to sample. Cache it: the response has an ETag and Cache-Control: max-age=300.
GET https://api.codeskop.com/v1/configAuthorization: Bearer cs_live_pk_…{ "enabled": true, "sample_rates": { "api_timing": 0.2 }, "features": { "anr": true, "network": true, "analytics": false }, "max_queue_mb": 10}Privacy
Never send request or response bodies, passwords or tokens. As a safety net we replace the value of any payload or user field whose name looks like a secret (password, token, authorization, api_key, card number, cvv, ssn and similar) before it's stored. Use your own user IDs, not emails. More in Privacy & Security.