On this page
Production ingestion
Store raw observations in your backend
Keep one transport contract across Django, FastAPI, Express, and Go. Store the exact SDK event, bind it to a server-known action, and promote only deliberately adopted fields.
Recommended endpoint
POST /api/v1/device-signal-events
Authorization: Bearer <application-session-token>
Idempotency-Key: 01JEXAMPLEDEVICEEVENTAuthenticate with the host application's normal session controls. The server must derive account identity from the authenticated request, not trust client_id as authentication. Return 201 Created for a new event and 200 OK when the same idempotency key is replayed.
Transport payload
The SDK returns observations. The host application adds transport metadata that the SDK cannot know.
{
"action_id": "checkout_01JEXAMPLE",
"action_type": "payment_attempt",
"sdk_version": "0.8.1",
"platform": "android",
"app_version": "4.2.0",
"observations": {
"session_id": "checkout_01JEXAMPLE",
"event_type": "device_intel_collection",
"schema_version": 1,
"collected_at": "2026-07-29T12:00:00.000Z",
"probes": {
"network": {
"status": "success",
"data": {
"isConnected": true,
"interfaceNames": ["wlan0"],
"activeNetworkMtu": 1500
}
},
"transaction_safety": {
"status": "skipped",
"reason": "disabled"
}
}
}
}Choose your backend
The envelope and database strategy stay the same. Select a framework to see its validation and idempotent create pattern.
Django model
import uuid
from django.conf import settings
from django.db import models
class DeviceSignalEvent(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
idempotency_key = models.CharField(max_length=128, unique=True)
account = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="device_signal_events",
)
action_id = models.CharField(max_length=128, db_index=True)
action_type = models.CharField(max_length=64, db_index=True)
session_id = models.CharField(max_length=128, db_index=True)
event_type = models.CharField(max_length=64)
schema_version = models.PositiveSmallIntegerField()
sdk_version = models.CharField(max_length=32)
platform = models.CharField(max_length=16)
app_version = models.CharField(max_length=64, blank=True)
collected_at = models.DateTimeField(db_index=True)
received_at = models.DateTimeField(auto_now_add=True, db_index=True)
payload = models.JSONField()
class Meta:
indexes = [
models.Index(fields=["account", "-received_at"]),
models.Index(fields=["action_id", "-received_at"]),
models.Index(fields=["action_type", "-received_at"]),
models.Index(fields=["sdk_version", "schema_version"]),
]payload stores the original RawSignalEvent. Promoted columns support common joins, retention jobs, and operational queries without duplicating all 322 fields into relational columns.
DRF serializer
from django.utils.dateparse import parse_datetime
from rest_framework import serializers
ALLOWED_STATUSES = {"success", "skipped", "timeout", "error"}
class DeviceSignalEventSerializer(serializers.Serializer):
action_id = serializers.CharField(max_length=128)
action_type = serializers.CharField(max_length=64)
sdk_version = serializers.CharField(max_length=32)
platform = serializers.ChoiceField(choices=["android", "ios"])
app_version = serializers.CharField(max_length=64, required=False, allow_blank=True)
observations = serializers.JSONField()
def validate_observations(self, event):
required = {
"session_id", "event_type", "schema_version",
"collected_at", "probes",
}
if not isinstance(event, dict) or not required.issubset(event):
raise serializers.ValidationError("Invalid RawSignalEvent envelope.")
if event["event_type"] != "device_intel_collection":
raise serializers.ValidationError("Unsupported event_type.")
if event["schema_version"] != 1:
raise serializers.ValidationError("Unsupported schema_version.")
if parse_datetime(event["collected_at"]) is None:
raise serializers.ValidationError("collected_at must be ISO 8601.")
if not isinstance(event["probes"], dict):
raise serializers.ValidationError("probes must be an object.")
for probe_id, outcome in event["probes"].items():
if not isinstance(probe_id, str) or not isinstance(outcome, dict):
raise serializers.ValidationError("Invalid probe outcome.")
status = outcome.get("status")
if status not in ALLOWED_STATUSES:
raise serializers.ValidationError("Unknown probe status.")
if status == "success" and not isinstance(outcome.get("data"), dict):
raise serializers.ValidationError("Success requires object data.")
if status == "skipped" and not isinstance(outcome.get("reason"), str):
raise serializers.ValidationError("Skipped requires a reason.")
if status == "error" and not isinstance(outcome.get("error"), str):
raise serializers.ValidationError("Error requires a message.")
# Preserve every unknown probe and additive field in payload.
return eventValidate the fields your feature logic consumes against the published JSON Schema. Do not reject an otherwise valid event solely because a newer SDK added an unknown probe or success-data field.
Idempotent create view
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
class DeviceSignalEventView(APIView):
def post(self, request):
serializer = DeviceSignalEventSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
event = serializer.validated_data["observations"]
idempotency_key = request.headers.get("Idempotency-Key")
if not idempotency_key:
raise serializers.ValidationError("Idempotency-Key is required.")
stored, created = DeviceSignalEvent.objects.get_or_create(
idempotency_key=idempotency_key,
defaults={
"account": request.user if request.user.is_authenticated else None,
"action_id": serializer.validated_data["action_id"],
"action_type": serializer.validated_data["action_type"],
"session_id": event["session_id"],
"event_type": event["event_type"],
"schema_version": event["schema_version"],
"sdk_version": serializer.validated_data["sdk_version"],
"platform": serializer.validated_data["platform"],
"app_version": serializer.validated_data.get("app_version", ""),
"collected_at": parse_datetime(event["collected_at"]),
"payload": event,
},
)
return Response(
{"event_id": str(stored.id), "created": created},
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)In production, reject a replay when the same idempotency key arrives with a different payload hash. Apply request-size limits, timestamp freshness checks, action ownership checks, throttling, and normal application audit logging.
Reference: Django models and DRF serializers.
FastAPI with Pydantic validation
Model the transport envelope explicitly while keeping observations open to additive probe IDs and fields.
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
ALLOWED_STATUSES = {"success", "skipped", "timeout", "error"}
class DeviceSignalEventIn(BaseModel):
model_config = ConfigDict(extra="forbid")
action_id: str = Field(max_length=128)
action_type: str = Field(max_length=64)
sdk_version: str = Field(max_length=32)
platform: Literal["android", "ios"]
app_version: str = Field(default="", max_length=64)
observations: dict[str, Any]
@field_validator("observations")
@classmethod
def validate_observations(cls, event: dict[str, Any]):
required = {
"session_id", "event_type", "schema_version",
"collected_at", "probes",
}
if not required.issubset(event):
raise ValueError("Invalid RawSignalEvent envelope")
if event["event_type"] != "device_intel_collection":
raise ValueError("Unsupported event_type")
if event["schema_version"] != 1:
raise ValueError("Unsupported schema_version")
if not isinstance(event["probes"], dict):
raise ValueError("probes must be an object")
for outcome in event["probes"].values():
if not isinstance(outcome, dict):
raise ValueError("Invalid probe outcome")
if outcome.get("status") not in ALLOWED_STATUSES:
raise ValueError("Unknown probe status")
return eventIdempotent endpoint
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/api/v1/device-signal-events", status_code=status.HTTP_201_CREATED)
async def create_device_signal_event(
body: DeviceSignalEventIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
account = Depends(require_account),
):
# Repository stores body.observations unchanged in a JSONB column.
stored, created = await repository.get_or_create_device_signal_event(
idempotency_key=idempotency_key,
account_id=account.id,
action_id=body.action_id,
action_type=body.action_type,
sdk_version=body.sdk_version,
platform=body.platform,
payload=body.observations,
)
if not created and stored.payload != body.observations:
raise HTTPException(status_code=409, detail="Idempotency conflict")
return JSONResponse(
{"event_id": str(stored.id), "created": created},
status_code=201 if created else 200,
)The repository boundary is application-specific. Implement it with a unique constraint on idempotency_key, a transaction, and PostgreSQL jsonb.
Reference: FastAPI nested request models.
Node.js with Express 5 and PostgreSQL
Parse a bounded JSON body, validate the stable envelope, then insert the untouched observations through a parameterized query.
import express from "express";
const app = express();
app.use(express.json({ limit: "256kb" }));
function assertRawSignalRequest(input) {
if (!input || typeof input !== "object") throw new Error("Invalid request");
const event = input.observations;
if (!event || typeof event !== "object") throw new Error("Invalid observations");
if (event.event_type !== "device_intel_collection") {
throw new Error("Unsupported event_type");
}
if (event.schema_version !== 1 || !event.probes || typeof event.probes !== "object") {
throw new Error("Unsupported RawSignalEvent envelope");
}
for (const outcome of Object.values(event.probes)) {
if (!outcome || !["success", "skipped", "timeout", "error"].includes(outcome.status)) {
throw new Error("Invalid probe outcome");
}
}
}
app.post("/api/v1/device-signal-events", requireAccount, async (req, res) => {
try {
assertRawSignalRequest(req.body);
} catch (error) {
return res.status(400).json({ detail: error.message });
}
const idempotencyKey = req.get("Idempotency-Key");
if (!idempotencyKey) return res.status(400).json({ detail: "Idempotency-Key is required" });
const result = await db.query(
`INSERT INTO device_signal_event
(idempotency_key, account_id, action_id, action_type, sdk_version, platform, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`,
[
idempotencyKey,
req.account.id,
req.body.action_id,
req.body.action_type,
req.body.sdk_version,
req.body.platform,
JSON.stringify(req.body.observations),
],
);
if (result.rowCount === 1) {
return res.status(201).json({ event_id: result.rows[0].id, created: true });
}
const existing = await loadByIdempotencyKey(idempotencyKey);
return res.status(200).json({ event_id: existing.id, created: false });
});In production, compare a canonical payload hash before treating a replay as successful. Authenticate before the handler and derive the account from middleware, not from client_id.
Reference: Express 5 Router API.
Go standard library
Keep the raw event as json.RawMessage after validating only the stable envelope. This preserves additive probe data.
package main
import (
"encoding/json"
"net/http"
)
type ingestRequest struct {
ActionID string `json:"action_id"`
ActionType string `json:"action_type"`
SDKVersion string `json:"sdk_version"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Observations json.RawMessage `json:"observations"`
}
type rawEnvelope struct {
SessionID string `json:"session_id"`
EventType string `json:"event_type"`
SchemaVersion int `json:"schema_version"`
CollectedAt string `json:"collected_at"`
Probes map[string]json.RawMessage `json:"probes"`
}
func deviceSignalEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, "Idempotency-Key is required", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 256<<10)
var input ingestRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
var event rawEnvelope
if err := json.Unmarshal(input.Observations, &event); err != nil ||
event.EventType != "device_intel_collection" ||
event.SchemaVersion != 1 ||
event.Probes == nil {
http.Error(w, "invalid RawSignalEvent", http.StatusBadRequest)
return
}
accountID := authenticatedAccountID(r.Context())
stored, created, err := storeEvent(r.Context(), key, accountID, input)
if err != nil {
http.Error(w, "storage failure", http.StatusInternalServerError)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"event_id": stored.ID,
"created": created,
})
}
func main() {
http.HandleFunc("/api/v1/device-signal-events", deviceSignalEvents)
http.ListenAndServe(":8080", nil)
}storeEvent should use a database transaction and unique idempotency key, preserve input.Observations in jsonb, and reject conflicting replays.
Reference: Go net/http and encoding/json.
Storage and indexes
- Keep the immutable raw event in PostgreSQL
jsonb. Django maps this throughmodels.JSONField; other stacks should use their native JSON binding without flattening the payload. - Promote only stable query dimensions such as account, action ID, action type, timestamps, platform, SDK version, and schema version.
- Treat
action_typeas a host-defined transport field, not part ofRawSignalEvent. Keep its vocabulary versioned and documented by the backend. - Extract adopted model features into a separately versioned table or feature pipeline.
- Add targeted expression indexes only for JSON paths used in production queries.
- Avoid a broad GIN index on every raw payload until query evidence justifies its storage and write cost.
- Keep application authentication and server observations separate from attacker-influenced client telemetry.
Schema evolution
Route by schema_version. Reject unsupported envelope versions explicitly.
Preserve unknown probe IDs and fields. Adopt them in feature logic only after review.
Never collapse omitted, skipped, timeout, error, and observed false into one value.
Persist sdk_version, platform, app version, and collection time with every event.
Retention and access
Raw signal events can contain high-entropy and sensitive observations. Define a retention period per collection purpose, restrict raw access, record administrative access, encrypt data in transit and at rest, and support deletion requirements. Derived aggregates can often outlive raw payloads, but their lineage and feature version must remain auditable.