diff --git a/services/reports/Cargo.lock b/services/reports/Cargo.lock
--- a/services/reports/Cargo.lock
+++ b/services/reports/Cargo.lock
@@ -518,6 +518,7 @@
  "iana-time-zone",
  "js-sys",
  "num-traits",
+ "serde",
  "time 0.1.45",
  "wasm-bindgen",
  "winapi",
@@ -813,6 +814,17 @@
  "version_check",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
 [[package]]
 name = "gimli"
 version = "0.28.0"
@@ -1133,6 +1145,17 @@
  "winapi",
 ]
 
+[[package]]
+name = "num-derive"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e6a0fd4f737c707bd9086cc16c925f294943eb62eb71499e9fd4cf71f8b9f4e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
 [[package]]
 name = "num-integer"
 version = "0.1.45"
@@ -1354,13 +1377,21 @@
 dependencies = [
  "anyhow",
  "aws-config",
+ "chrono",
  "clap",
  "comm-services-lib",
+ "derive_more",
+ "num-derive",
+ "num-traits",
  "once_cell",
+ "serde",
+ "serde_json",
+ "serde_repr",
  "tokio",
  "tokio-stream",
  "tracing",
  "tracing-subscriber",
+ "uuid",
 ]
 
 [[package]]
@@ -1564,6 +1595,17 @@
  "serde",
 ]
 
+[[package]]
+name = "serde_repr"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
 [[package]]
 name = "serde_urlencoded"
 version = "0.7.1"
@@ -1983,6 +2025,15 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
 
+[[package]]
+name = "uuid"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
+dependencies = [
+ "getrandom",
+]
+
 [[package]]
 name = "valuable"
 version = "0.1.0"
diff --git a/services/reports/Cargo.toml b/services/reports/Cargo.toml
--- a/services/reports/Cargo.toml
+++ b/services/reports/Cargo.toml
@@ -9,12 +9,20 @@
 [dependencies]
 anyhow = "1.0"
 aws-config = "0.55"
+chrono = { version = "0.4", features = ["serde"] }
 clap = { version = "4.0", features = ["derive", "env"] }
 comm-services-lib = { path = "../comm-services-lib", features = [
   "blob-client",
 ] }
+derive_more = "0.99"
+num-traits = "0.2"
+num-derive = "0.4"
 once_cell = "1.17"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+serde_repr = "0.1"
 tokio = { version = "1.32", features = ["macros", "rt-multi-thread"] }
 tokio-stream = "0.1"
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+uuid = { version = "1.2", features = ["v4"] }
diff --git a/services/reports/src/main.rs b/services/reports/src/main.rs
--- a/services/reports/src/main.rs
+++ b/services/reports/src/main.rs
@@ -1,4 +1,5 @@
 pub mod config;
+pub mod report_types;
 
 use anyhow::Result;
 use tracing_subscriber::filter::{EnvFilter, LevelFilter};
diff --git a/services/reports/src/report_types.rs b/services/reports/src/report_types.rs
new file mode 100644
--- /dev/null
+++ b/services/reports/src/report_types.rs
@@ -0,0 +1,114 @@
+// Report ID
+
+use std::collections::HashMap;
+
+use chrono::{serde::ts_milliseconds_option, DateTime, Utc};
+use derive_more::{Deref, Display, Into};
+use num_derive::FromPrimitive;
+use serde::{de::Error, Deserialize, Serialize};
+use serde_repr::Deserialize_repr;
+
+#[derive(Clone, Debug, Deref, Serialize, Into)]
+#[repr(transparent)]
+pub struct ReportID(String);
+impl Default for ReportID {
+  fn default() -> Self {
+    let uuid = uuid::Uuid::new_v4();
+    ReportID(uuid.to_string())
+  }
+}
+impl From<String> for ReportID {
+  fn from(value: String) -> Self {
+    ReportID(value)
+  }
+}
+
+/// Serialized / deserialized report type.
+/// We receive report type from clients as a number,
+/// but want to display it as a string.
+#[derive(
+  Copy, Clone, Debug, Default, FromPrimitive, Serialize, Deserialize_repr,
+)]
+#[repr(u8)]
+#[serde(rename_all(serialize = "snake_case"))]
+pub enum ReportType {
+  // NOTE: Keep these in sync with `reportTypes` in lib/types/report-types.js
+  #[default]
+  ErrorReport = 0,
+  ThreadInconsistency = 1,
+  EntryInconsistency = 2,
+  MediaMission = 3,
+  UserInconsistency = 4,
+}
+
+/// Report platform
+#[derive(Clone, Debug, Serialize, Deserialize, Display)]
+#[serde(rename_all = "lowercase")]
+pub enum ReportPlatform {
+  Android,
+  IOS,
+  Web,
+  Windows,
+  MacOS,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlatformDetails {
+  pub platform: ReportPlatform,
+  code_version: Option<u16>,
+  state_version: Option<u16>,
+}
+
+/// Input report payload - this is the JSON we receive from clients
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[serde(remote = "Self")] // we delegate to our custom validation trait
+pub struct ReportInput {
+  pub platform_details: PlatformDetails,
+
+  #[serde(rename = "type")]
+  #[serde(default)]
+  pub report_type: ReportType,
+
+  #[serde(default)]
+  #[serde(with = "ts_milliseconds_option")]
+  pub time: Option<DateTime<Utc>>,
+
+  // we usually don't care about the rest of the fields
+  // so we just keep them as a JSON object
+  #[serde(flatten)]
+  pub report_content: HashMap<String, serde_json::Value>,
+}
+
+// We can do additional validation here
+impl<'de> serde::de::Deserialize<'de> for ReportInput {
+  fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+  where
+    D: serde::Deserializer<'de>,
+  {
+    let mut this = Self::deserialize(deserializer)?;
+    if this.time.is_none() {
+      if !matches!(this.report_type, ReportType::ThreadInconsistency) {
+        return Err(Error::custom(
+          "The 'time' field is optional only for thread inconsistency reports",
+        ));
+      }
+      this.time = Some(Utc::now());
+    }
+    Ok(this)
+  }
+}
+
+/// Report output payload - this is used to view the report
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReportOutput {
+  pub id: ReportID,
+  #[serde(rename = "userID")]
+  pub user_id: String,
+  pub platform: ReportPlatform,
+  pub report_type: ReportType,
+  pub creation_time: DateTime<Utc>,
+  pub content: HashMap<String, serde_json::Value>,
+}