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: 01JEXAMPLEDEVICEEVENT

Authenticate 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"
      }
    }
  }
}
JSON data may contain strings, finite numbers, booleans, arrays, nested objects, and omitted optional keys. The SDK does not emit a risk score or device verdict.

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 event

Validate 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 event

Idempotent 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 through models.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_type as a host-defined transport field, not part of RawSignalEvent. 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

Envelope changes

Route by schema_version. Reject unsupported envelope versions explicitly.

Additive probe data

Preserve unknown probe IDs and fields. Adopt them in feature logic only after review.

Outcome semantics

Never collapse omitted, skipped, timeout, error, and observed false into one value.

SDK context

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.

Treat every client field as attacker-influenced context. Never make a consequential decision from one device signal and always provide an appropriate recovery or review path.