diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs
new file mode 100644
index 00000000..a2dc95c2
--- /dev/null
+++ b/src/api/admin/api.rs
@@ -0,0 +1,486 @@
+use std::net::SocketAddr;
+
+use serde::{Deserialize, Serialize};
+
+use crate::helpers::is_default;
+
+pub enum AdminApiRequest {
+	// Cluster operations
+	GetClusterStatus(GetClusterStatusRequest),
+	GetClusterHealth(GetClusterHealthRequest),
+	ConnectClusterNodes(ConnectClusterNodesRequest),
+	GetClusterLayout(GetClusterLayoutRequest),
+	UpdateClusterLayout(UpdateClusterLayoutRequest),
+	ApplyClusterLayout(ApplyClusterLayoutRequest),
+	RevertClusterLayout(RevertClusterLayoutRequest),
+}
+
+pub enum AdminApiResponse {
+	// Cluster operations
+	GetClusterStatus(GetClusterStatusResponse),
+	GetClusterHealth(GetClusterHealthResponse),
+	ConnectClusterNodes(ConnectClusterNodesResponse),
+	GetClusterLayout(GetClusterLayoutResponse),
+	UpdateClusterLayout(UpdateClusterLayoutResponse),
+	ApplyClusterLayout(ApplyClusterLayoutResponse),
+	RevertClusterLayout(RevertClusterLayoutResponse),
+}
+
+// **********************************************
+//      Metrics-related endpoints
+// **********************************************
+
+// TODO: do we want this here ??
+
+// ---- Metrics ----
+
+pub struct MetricsRequest;
+
+// ---- Health ----
+
+pub struct HealthRequest;
+
+// **********************************************
+//      Cluster operations
+// **********************************************
+
+// ---- GetClusterStatus ----
+
+pub struct GetClusterStatusRequest;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetClusterStatusResponse {
+	pub node: String,
+	pub garage_version: &'static str,
+	pub garage_features: Option<&'static [&'static str]>,
+	pub rust_version: &'static str,
+	pub db_engine: String,
+	pub layout_version: u64,
+	pub nodes: Vec<NodeResp>,
+}
+
+#[derive(Serialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct NodeResp {
+	pub id: String,
+	pub role: Option<NodeRoleResp>,
+	pub addr: Option<SocketAddr>,
+	pub hostname: Option<String>,
+	pub is_up: bool,
+	pub last_seen_secs_ago: Option<u64>,
+	pub draining: bool,
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub data_partition: Option<FreeSpaceResp>,
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub metadata_partition: Option<FreeSpaceResp>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NodeRoleResp {
+	pub id: String,
+	pub zone: String,
+	pub capacity: Option<u64>,
+	pub tags: Vec<String>,
+}
+
+#[derive(Serialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct FreeSpaceResp {
+	pub available: u64,
+	pub total: u64,
+}
+
+// ---- GetClusterHealth ----
+
+pub struct GetClusterHealthRequest;
+
+#[derive(Debug, Clone, Copy, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetClusterHealthResponse {
+	pub status: &'static str,
+	pub known_nodes: usize,
+	pub connected_nodes: usize,
+	pub storage_nodes: usize,
+	pub storage_nodes_ok: usize,
+	pub partitions: usize,
+	pub partitions_quorum: usize,
+	pub partitions_all_ok: usize,
+}
+
+// ---- ConnectClusterNodes ----
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ConnectClusterNodesRequest(pub Vec<String>);
+
+#[derive(Serialize)]
+pub struct ConnectClusterNodesResponse(pub Vec<ConnectClusterNodeResponse>);
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConnectClusterNodeResponse {
+	pub success: bool,
+	pub error: Option<String>,
+}
+
+// ---- GetClusterLayout ----
+
+pub struct GetClusterLayoutRequest;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetClusterLayoutResponse {
+	pub version: u64,
+	pub roles: Vec<NodeRoleResp>,
+	pub staged_role_changes: Vec<NodeRoleChange>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NodeRoleChange {
+	pub id: String,
+	#[serde(flatten)]
+	pub action: NodeRoleChangeEnum,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum NodeRoleChangeEnum {
+	#[serde(rename_all = "camelCase")]
+	Remove { remove: bool },
+	#[serde(rename_all = "camelCase")]
+	Update {
+		zone: String,
+		capacity: Option<u64>,
+		tags: Vec<String>,
+	},
+}
+
+// ---- UpdateClusterLayout ----
+
+#[derive(Deserialize)]
+pub struct UpdateClusterLayoutRequest(pub Vec<NodeRoleChange>);
+
+#[derive(Serialize)]
+pub struct UpdateClusterLayoutResponse(pub GetClusterLayoutResponse);
+
+// ---- ApplyClusterLayout ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ApplyClusterLayoutRequest {
+	pub version: u64,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ApplyClusterLayoutResponse {
+	pub message: Vec<String>,
+	pub layout: GetClusterLayoutResponse,
+}
+
+// ---- RevertClusterLayout ----
+
+pub struct RevertClusterLayoutRequest;
+
+#[derive(Serialize)]
+pub struct RevertClusterLayoutResponse(pub GetClusterLayoutResponse);
+
+// **********************************************
+//      Access key operations
+// **********************************************
+
+// ---- ListKeys ----
+
+pub struct ListKeysRequest;
+
+#[derive(Serialize)]
+pub struct ListKeysResponse(pub Vec<ListKeysResponseItem>);
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListKeysResponseItem {
+	pub id: String,
+	pub name: String,
+}
+
+// ---- GetKeyInfo ----
+
+pub struct GetKeyInfoRequest {
+	pub id: Option<String>,
+	pub search: Option<String>,
+	pub show_secret_key: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetKeyInfoResponse {
+	pub name: String,
+	pub access_key_id: String,
+	#[serde(skip_serializing_if = "is_default")]
+	pub secret_access_key: Option<String>,
+	pub permissions: KeyPerm,
+	pub buckets: Vec<KeyInfoBucketResponse>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct KeyPerm {
+	#[serde(default)]
+	pub create_bucket: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct KeyInfoBucketResponse {
+	pub id: String,
+	pub global_aliases: Vec<String>,
+	pub local_aliases: Vec<String>,
+	pub permissions: ApiBucketKeyPerm,
+}
+
+#[derive(Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct ApiBucketKeyPerm {
+	#[serde(default)]
+	pub read: bool,
+	#[serde(default)]
+	pub write: bool,
+	#[serde(default)]
+	pub owner: bool,
+}
+
+// ---- CreateKey ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateKeyRequest {
+	pub name: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct CreateKeyResponse(pub GetKeyInfoResponse);
+
+// ---- ImportKey ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ImportKeyRequest {
+	pub access_key_id: String,
+	pub secret_access_key: String,
+	pub name: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct ImportKeyResponse(pub GetKeyInfoResponse);
+
+// ---- UpdateKey ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateKeyRequest {
+	// TODO: id (get parameter) goes here
+	pub name: Option<String>,
+	pub allow: Option<KeyPerm>,
+	pub deny: Option<KeyPerm>,
+}
+
+#[derive(Serialize)]
+pub struct UpdateKeyResponse(pub GetKeyInfoResponse);
+
+// ---- DeleteKey ----
+
+pub struct DeleteKeyRequest {
+	pub id: String,
+}
+
+pub struct DeleteKeyResponse;
+
+// **********************************************
+//      Bucket operations
+// **********************************************
+
+// ---- ListBuckets ----
+
+pub struct ListBucketsRequest;
+
+pub struct ListBucketsResponse(pub Vec<ListBucketsResponseItem>);
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListBucketsResponseItem {
+	pub id: String,
+	pub global_aliases: Vec<String>,
+	pub local_aliases: Vec<BucketLocalAlias>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BucketLocalAlias {
+	pub access_key_id: String,
+	pub alias: String,
+}
+
+// ---- GetBucketInfo ----
+
+pub struct GetBucketInfoRequest {
+	pub id: Option<String>,
+	pub global_alias: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetBucketInfoResponse {
+	pub id: String,
+	pub global_aliases: Vec<String>,
+	pub website_access: bool,
+	#[serde(default)]
+	pub website_config: Option<GetBucketInfoWebsiteResponse>,
+	pub keys: Vec<GetBucketInfoKey>,
+	pub objects: i64,
+	pub bytes: i64,
+	pub unfinished_uploads: i64,
+	pub unfinished_multipart_uploads: i64,
+	pub unfinished_multipart_upload_parts: i64,
+	pub unfinished_multipart_upload_bytes: i64,
+	pub quotas: ApiBucketQuotas,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetBucketInfoWebsiteResponse {
+	pub index_document: String,
+	pub error_document: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetBucketInfoKey {
+	pub access_key_id: String,
+	pub name: String,
+	pub permissions: ApiBucketKeyPerm,
+	pub bucket_local_aliases: Vec<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ApiBucketQuotas {
+	pub max_size: Option<u64>,
+	pub max_objects: Option<u64>,
+}
+
+// ---- CreateBucket ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBucketRequest {
+	pub global_alias: Option<String>,
+	pub local_alias: Option<CreateBucketLocalAlias>,
+}
+
+#[derive(Serialize)]
+pub struct CreateBucketResponse(GetBucketInfoResponse);
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBucketLocalAlias {
+	pub access_key_id: String,
+	pub alias: String,
+	#[serde(default)]
+	pub allow: ApiBucketKeyPerm,
+}
+
+// ---- UpdateBucket ----
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateBucketRequest {
+	pub website_access: Option<UpdateBucketWebsiteAccess>,
+	pub quotas: Option<ApiBucketQuotas>,
+}
+
+#[derive(Serialize)]
+pub struct UpdateBucketResponse(GetBucketInfoResponse);
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateBucketWebsiteAccess {
+	pub enabled: bool,
+	pub index_document: Option<String>,
+	pub error_document: Option<String>,
+}
+
+// ---- DeleteBucket ----
+
+pub struct DeleteBucketRequest {
+	pub id: String,
+}
+
+pub struct DeleteBucketResponse;
+
+// **********************************************
+//      Operations on permissions for keys on buckets
+// **********************************************
+
+// ---- BucketAllowKey ----
+
+pub struct BucketAllowKeyRequest(pub BucketKeyPermChangeRequest);
+
+pub struct BucketAllowKeyResponse;
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BucketKeyPermChangeRequest {
+	pub bucket_id: String,
+	pub access_key_id: String,
+	pub permissions: ApiBucketKeyPerm,
+}
+
+// ---- BucketDenyKey ----
+
+pub struct BucketDenyKeyRequest(pub BucketKeyPermChangeRequest);
+
+pub struct BucketDenyKeyResponse;
+
+// **********************************************
+//      Operations on bucket aliases
+// **********************************************
+
+// ---- GlobalAliasBucket ----
+
+pub struct GlobalAliasBucketRequest {
+	pub id: String,
+	pub alias: String,
+}
+
+pub struct GlobalAliasBucketReponse;
+
+// ---- GlobalUnaliasBucket ----
+
+pub struct GlobalUnaliasBucketRequest {
+	pub id: String,
+	pub alias: String,
+}
+
+pub struct GlobalUnaliasBucketReponse;
+
+// ---- LocalAliasBucket ----
+
+pub struct LocalAliasBucketRequest {
+	pub id: String,
+	pub access_key_id: String,
+	pub alias: String,
+}
+
+pub struct LocalAliasBucketReponse;
+
+// ---- LocalUnaliasBucket ----
+
+pub struct LocalUnaliasBucketRequest {
+	pub id: String,
+	pub access_key_id: String,
+	pub alias: String,
+}
+
+pub struct LocalUnaliasBucketReponse;
diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs
index 0e4565bb..9715292c 100644
--- a/src/api/admin/api_server.rs
+++ b/src/api/admin/api_server.rs
@@ -22,12 +22,14 @@ use garage_util::socket_address::UnixOrTCPSocketAddress;
 
 use crate::generic_server::*;
 
+use crate::admin::api::*;
 use crate::admin::bucket::*;
 use crate::admin::cluster::*;
 use crate::admin::error::*;
 use crate::admin::key::*;
 use crate::admin::router_v0;
 use crate::admin::router_v1::{Authorization, Endpoint};
+use crate::admin::EndpointHandler;
 use crate::helpers::*;
 
 pub type ResBody = BoxBody<Error>;
@@ -269,8 +271,14 @@ impl ApiHandler for AdminApiServer {
 			Endpoint::CheckDomain => self.handle_check_domain(req).await,
 			Endpoint::Health => self.handle_health(),
 			Endpoint::Metrics => self.handle_metrics(),
-			Endpoint::GetClusterStatus => handle_get_cluster_status(&self.garage).await,
-			Endpoint::GetClusterHealth => handle_get_cluster_health(&self.garage).await,
+			Endpoint::GetClusterStatus => GetClusterStatusRequest
+				.handle(&self.garage)
+				.await
+				.and_then(|x| json_ok_response(&x)),
+			Endpoint::GetClusterHealth => GetClusterHealthRequest
+				.handle(&self.garage)
+				.await
+				.and_then(|x| json_ok_response(&x)),
 			Endpoint::ConnectClusterNodes => handle_connect_cluster_nodes(&self.garage, req).await,
 			// Layout
 			Endpoint::GetClusterLayout => handle_get_cluster_layout(&self.garage).await,
diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs
index ac3cba00..593848f0 100644
--- a/src/api/admin/bucket.rs
+++ b/src/api/admin/bucket.rs
@@ -2,7 +2,6 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode};
-use serde::{Deserialize, Serialize};
 
 use garage_util::crdt::*;
 use garage_util::data::*;
@@ -17,9 +16,14 @@ use garage_model::permission::*;
 use garage_model::s3::mpu_table;
 use garage_model::s3::object_table::*;
 
+use crate::admin::api::ApiBucketKeyPerm;
+use crate::admin::api::{
+	ApiBucketQuotas, BucketKeyPermChangeRequest, BucketLocalAlias, CreateBucketRequest,
+	GetBucketInfoKey, GetBucketInfoResponse, GetBucketInfoWebsiteResponse, ListBucketsResponseItem,
+	UpdateBucketRequest,
+};
 use crate::admin::api_server::ResBody;
 use crate::admin::error::*;
-use crate::admin::key::ApiBucketKeyPerm;
 use crate::common_error::CommonError;
 use crate::helpers::*;
 
@@ -39,7 +43,7 @@ pub async fn handle_list_buckets(garage: &Arc<Garage>) -> Result<Response<ResBod
 		.into_iter()
 		.map(|b| {
 			let state = b.state.as_option().unwrap();
-			ListBucketResultItem {
+			ListBucketsResponseItem {
 				id: hex::encode(b.id),
 				global_aliases: state
 					.aliases
@@ -65,28 +69,6 @@ pub async fn handle_list_buckets(garage: &Arc<Garage>) -> Result<Response<ResBod
 	Ok(json_ok_response(&res)?)
 }
 
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct ListBucketResultItem {
-	id: String,
-	global_aliases: Vec<String>,
-	local_aliases: Vec<BucketLocalAlias>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct BucketLocalAlias {
-	access_key_id: String,
-	alias: String,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct ApiBucketQuotas {
-	max_size: Option<u64>,
-	max_objects: Option<u64>,
-}
-
 pub async fn handle_get_bucket_info(
 	garage: &Arc<Garage>,
 	id: Option<String>,
@@ -175,98 +157,63 @@ async fn bucket_info_results(
 	let state = bucket.state.as_option().unwrap();
 
 	let quotas = state.quotas.get();
-	let res =
-		GetBucketInfoResult {
-			id: hex::encode(bucket.id),
-			global_aliases: state
-				.aliases
-				.items()
-				.iter()
-				.filter(|(_, _, a)| *a)
-				.map(|(n, _, _)| n.to_string())
-				.collect::<Vec<_>>(),
-			website_access: state.website_config.get().is_some(),
-			website_config: state.website_config.get().clone().map(|wsc| {
-				GetBucketInfoWebsiteResult {
-					index_document: wsc.index_document,
-					error_document: wsc.error_document,
+	let res = GetBucketInfoResponse {
+		id: hex::encode(bucket.id),
+		global_aliases: state
+			.aliases
+			.items()
+			.iter()
+			.filter(|(_, _, a)| *a)
+			.map(|(n, _, _)| n.to_string())
+			.collect::<Vec<_>>(),
+		website_access: state.website_config.get().is_some(),
+		website_config: state.website_config.get().clone().map(|wsc| {
+			GetBucketInfoWebsiteResponse {
+				index_document: wsc.index_document,
+				error_document: wsc.error_document,
+			}
+		}),
+		keys: relevant_keys
+			.into_values()
+			.map(|key| {
+				let p = key.state.as_option().unwrap();
+				GetBucketInfoKey {
+					access_key_id: key.key_id,
+					name: p.name.get().to_string(),
+					permissions: p
+						.authorized_buckets
+						.get(&bucket.id)
+						.map(|p| ApiBucketKeyPerm {
+							read: p.allow_read,
+							write: p.allow_write,
+							owner: p.allow_owner,
+						})
+						.unwrap_or_default(),
+					bucket_local_aliases: p
+						.local_aliases
+						.items()
+						.iter()
+						.filter(|(_, _, b)| *b == Some(bucket.id))
+						.map(|(n, _, _)| n.to_string())
+						.collect::<Vec<_>>(),
 				}
-			}),
-			keys: relevant_keys
-				.into_values()
-				.map(|key| {
-					let p = key.state.as_option().unwrap();
-					GetBucketInfoKey {
-						access_key_id: key.key_id,
-						name: p.name.get().to_string(),
-						permissions: p
-							.authorized_buckets
-							.get(&bucket.id)
-							.map(|p| ApiBucketKeyPerm {
-								read: p.allow_read,
-								write: p.allow_write,
-								owner: p.allow_owner,
-							})
-							.unwrap_or_default(),
-						bucket_local_aliases: p
-							.local_aliases
-							.items()
-							.iter()
-							.filter(|(_, _, b)| *b == Some(bucket.id))
-							.map(|(n, _, _)| n.to_string())
-							.collect::<Vec<_>>(),
-					}
-				})
-				.collect::<Vec<_>>(),
-			objects: *counters.get(OBJECTS).unwrap_or(&0),
-			bytes: *counters.get(BYTES).unwrap_or(&0),
-			unfinished_uploads: *counters.get(UNFINISHED_UPLOADS).unwrap_or(&0),
-			unfinished_multipart_uploads: *mpu_counters.get(mpu_table::UPLOADS).unwrap_or(&0),
-			unfinished_multipart_upload_parts: *mpu_counters.get(mpu_table::PARTS).unwrap_or(&0),
-			unfinished_multipart_upload_bytes: *mpu_counters.get(mpu_table::BYTES).unwrap_or(&0),
-			quotas: ApiBucketQuotas {
-				max_size: quotas.max_size,
-				max_objects: quotas.max_objects,
-			},
-		};
+			})
+			.collect::<Vec<_>>(),
+		objects: *counters.get(OBJECTS).unwrap_or(&0),
+		bytes: *counters.get(BYTES).unwrap_or(&0),
+		unfinished_uploads: *counters.get(UNFINISHED_UPLOADS).unwrap_or(&0),
+		unfinished_multipart_uploads: *mpu_counters.get(mpu_table::UPLOADS).unwrap_or(&0),
+		unfinished_multipart_upload_parts: *mpu_counters.get(mpu_table::PARTS).unwrap_or(&0),
+		unfinished_multipart_upload_bytes: *mpu_counters.get(mpu_table::BYTES).unwrap_or(&0),
+		quotas: ApiBucketQuotas {
+			max_size: quotas.max_size,
+			max_objects: quotas.max_objects,
+		},
+	};
 
 	Ok(json_ok_response(&res)?)
 }
 
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetBucketInfoResult {
-	id: String,
-	global_aliases: Vec<String>,
-	website_access: bool,
-	#[serde(default)]
-	website_config: Option<GetBucketInfoWebsiteResult>,
-	keys: Vec<GetBucketInfoKey>,
-	objects: i64,
-	bytes: i64,
-	unfinished_uploads: i64,
-	unfinished_multipart_uploads: i64,
-	unfinished_multipart_upload_parts: i64,
-	unfinished_multipart_upload_bytes: i64,
-	quotas: ApiBucketQuotas,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetBucketInfoWebsiteResult {
-	index_document: String,
-	error_document: Option<String>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetBucketInfoKey {
-	access_key_id: String,
-	name: String,
-	permissions: ApiBucketKeyPerm,
-	bucket_local_aliases: Vec<String>,
-}
-
 pub async fn handle_create_bucket(
 	garage: &Arc<Garage>,
 	req: Request<IncomingBody>,
@@ -336,22 +283,6 @@ pub async fn handle_create_bucket(
 	bucket_info_results(garage, bucket.id).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct CreateBucketRequest {
-	global_alias: Option<String>,
-	local_alias: Option<CreateBucketLocalAlias>,
-}
-
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct CreateBucketLocalAlias {
-	access_key_id: String,
-	alias: String,
-	#[serde(default)]
-	allow: ApiBucketKeyPerm,
-}
-
 pub async fn handle_delete_bucket(
 	garage: &Arc<Garage>,
 	id: String,
@@ -446,21 +377,6 @@ pub async fn handle_update_bucket(
 	bucket_info_results(garage, bucket_id).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct UpdateBucketRequest {
-	website_access: Option<UpdateBucketWebsiteAccess>,
-	quotas: Option<ApiBucketQuotas>,
-}
-
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct UpdateBucketWebsiteAccess {
-	enabled: bool,
-	index_document: Option<String>,
-	error_document: Option<String>,
-}
-
 // ---- BUCKET/KEY PERMISSIONS ----
 
 pub async fn handle_bucket_change_key_perm(
@@ -502,14 +418,6 @@ pub async fn handle_bucket_change_key_perm(
 	bucket_info_results(garage, bucket.id).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct BucketKeyPermChangeRequest {
-	bucket_id: String,
-	access_key_id: String,
-	permissions: ApiBucketKeyPerm,
-}
-
 // ---- BUCKET ALIASES ----
 
 pub async fn handle_global_alias_bucket(
diff --git a/src/api/admin/cluster.rs b/src/api/admin/cluster.rs
index 357ac600..11753509 100644
--- a/src/api/admin/cluster.rs
+++ b/src/api/admin/cluster.rs
@@ -1,9 +1,8 @@
 use std::collections::HashMap;
-use std::net::SocketAddr;
 use std::sync::Arc;
 
+use async_trait::async_trait;
 use hyper::{body::Incoming as IncomingBody, Request, Response};
-use serde::{Deserialize, Serialize};
 
 use garage_util::crdt::*;
 use garage_util::data::*;
@@ -12,153 +11,178 @@ use garage_rpc::layout;
 
 use garage_model::garage::Garage;
 
+use crate::admin::api::{
+	ApplyClusterLayoutRequest, ApplyClusterLayoutResponse, ConnectClusterNodeResponse,
+	ConnectClusterNodesRequest, ConnectClusterNodesResponse, FreeSpaceResp,
+	GetClusterHealthRequest, GetClusterHealthResponse, GetClusterLayoutResponse,
+	GetClusterStatusRequest, GetClusterStatusResponse, NodeResp, NodeRoleChange,
+	NodeRoleChangeEnum, NodeRoleResp, UpdateClusterLayoutRequest,
+};
 use crate::admin::api_server::ResBody;
 use crate::admin::error::*;
+use crate::admin::EndpointHandler;
 use crate::helpers::{json_ok_response, parse_json_body};
 
-pub async fn handle_get_cluster_status(garage: &Arc<Garage>) -> Result<Response<ResBody>, Error> {
-	let layout = garage.system.cluster_layout();
-	let mut nodes = garage
-		.system
-		.get_known_nodes()
-		.into_iter()
-		.map(|i| {
-			(
-				i.id,
-				NodeResp {
-					id: hex::encode(i.id),
-					addr: i.addr,
-					hostname: i.status.hostname,
-					is_up: i.is_up,
-					last_seen_secs_ago: i.last_seen_secs_ago,
-					data_partition: i
-						.status
-						.data_disk_avail
-						.map(|(avail, total)| FreeSpaceResp {
-							available: avail,
-							total,
+#[async_trait]
+impl EndpointHandler for GetClusterStatusRequest {
+	type Response = GetClusterStatusResponse;
+
+	async fn handle(self, garage: &Arc<Garage>) -> Result<GetClusterStatusResponse, Error> {
+		let layout = garage.system.cluster_layout();
+		let mut nodes = garage
+			.system
+			.get_known_nodes()
+			.into_iter()
+			.map(|i| {
+				(
+					i.id,
+					NodeResp {
+						id: hex::encode(i.id),
+						addr: i.addr,
+						hostname: i.status.hostname,
+						is_up: i.is_up,
+						last_seen_secs_ago: i.last_seen_secs_ago,
+						data_partition: i.status.data_disk_avail.map(|(avail, total)| {
+							FreeSpaceResp {
+								available: avail,
+								total,
+							}
 						}),
-					metadata_partition: i.status.meta_disk_avail.map(|(avail, total)| {
-						FreeSpaceResp {
-							available: avail,
-							total,
-						}
-					}),
-					..Default::default()
-				},
-			)
-		})
-		.collect::<HashMap<_, _>>();
+						metadata_partition: i.status.meta_disk_avail.map(|(avail, total)| {
+							FreeSpaceResp {
+								available: avail,
+								total,
+							}
+						}),
+						..Default::default()
+					},
+				)
+			})
+			.collect::<HashMap<_, _>>();
 
-	for (id, _, role) in layout.current().roles.items().iter() {
-		if let layout::NodeRoleV(Some(r)) = role {
-			let role = NodeRoleResp {
-				id: hex::encode(id),
-				zone: r.zone.to_string(),
-				capacity: r.capacity,
-				tags: r.tags.clone(),
-			};
-			match nodes.get_mut(id) {
-				None => {
-					nodes.insert(
-						*id,
-						NodeResp {
-							id: hex::encode(id),
-							role: Some(role),
-							..Default::default()
-						},
-					);
-				}
-				Some(n) => {
-					n.role = Some(role);
-				}
-			}
-		}
-	}
-
-	for ver in layout.versions().iter().rev().skip(1) {
-		for (id, _, role) in ver.roles.items().iter() {
+		for (id, _, role) in layout.current().roles.items().iter() {
 			if let layout::NodeRoleV(Some(r)) = role {
-				if r.capacity.is_some() {
-					if let Some(n) = nodes.get_mut(id) {
-						if n.role.is_none() {
-							n.draining = true;
-						}
-					} else {
+				let role = NodeRoleResp {
+					id: hex::encode(id),
+					zone: r.zone.to_string(),
+					capacity: r.capacity,
+					tags: r.tags.clone(),
+				};
+				match nodes.get_mut(id) {
+					None => {
 						nodes.insert(
 							*id,
 							NodeResp {
 								id: hex::encode(id),
-								draining: true,
+								role: Some(role),
 								..Default::default()
 							},
 						);
 					}
+					Some(n) => {
+						n.role = Some(role);
+					}
 				}
 			}
 		}
+
+		for ver in layout.versions().iter().rev().skip(1) {
+			for (id, _, role) in ver.roles.items().iter() {
+				if let layout::NodeRoleV(Some(r)) = role {
+					if r.capacity.is_some() {
+						if let Some(n) = nodes.get_mut(id) {
+							if n.role.is_none() {
+								n.draining = true;
+							}
+						} else {
+							nodes.insert(
+								*id,
+								NodeResp {
+									id: hex::encode(id),
+									draining: true,
+									..Default::default()
+								},
+							);
+						}
+					}
+				}
+			}
+		}
+
+		let mut nodes = nodes.into_values().collect::<Vec<_>>();
+		nodes.sort_by(|x, y| x.id.cmp(&y.id));
+
+		Ok(GetClusterStatusResponse {
+			node: hex::encode(garage.system.id),
+			garage_version: garage_util::version::garage_version(),
+			garage_features: garage_util::version::garage_features(),
+			rust_version: garage_util::version::rust_version(),
+			db_engine: garage.db.engine(),
+			layout_version: layout.current().version,
+			nodes,
+		})
 	}
-
-	let mut nodes = nodes.into_values().collect::<Vec<_>>();
-	nodes.sort_by(|x, y| x.id.cmp(&y.id));
-
-	let res = GetClusterStatusResponse {
-		node: hex::encode(garage.system.id),
-		garage_version: garage_util::version::garage_version(),
-		garage_features: garage_util::version::garage_features(),
-		rust_version: garage_util::version::rust_version(),
-		db_engine: garage.db.engine(),
-		layout_version: layout.current().version,
-		nodes,
-	};
-
-	Ok(json_ok_response(&res)?)
 }
 
-pub async fn handle_get_cluster_health(garage: &Arc<Garage>) -> Result<Response<ResBody>, Error> {
-	use garage_rpc::system::ClusterHealthStatus;
-	let health = garage.system.health();
-	let health = ClusterHealth {
-		status: match health.status {
-			ClusterHealthStatus::Healthy => "healthy",
-			ClusterHealthStatus::Degraded => "degraded",
-			ClusterHealthStatus::Unavailable => "unavailable",
-		},
-		known_nodes: health.known_nodes,
-		connected_nodes: health.connected_nodes,
-		storage_nodes: health.storage_nodes,
-		storage_nodes_ok: health.storage_nodes_ok,
-		partitions: health.partitions,
-		partitions_quorum: health.partitions_quorum,
-		partitions_all_ok: health.partitions_all_ok,
-	};
-	Ok(json_ok_response(&health)?)
+#[async_trait]
+impl EndpointHandler for GetClusterHealthRequest {
+	type Response = GetClusterHealthResponse;
+
+	async fn handle(self, garage: &Arc<Garage>) -> Result<GetClusterHealthResponse, Error> {
+		use garage_rpc::system::ClusterHealthStatus;
+		let health = garage.system.health();
+		let health = GetClusterHealthResponse {
+			status: match health.status {
+				ClusterHealthStatus::Healthy => "healthy",
+				ClusterHealthStatus::Degraded => "degraded",
+				ClusterHealthStatus::Unavailable => "unavailable",
+			},
+			known_nodes: health.known_nodes,
+			connected_nodes: health.connected_nodes,
+			storage_nodes: health.storage_nodes,
+			storage_nodes_ok: health.storage_nodes_ok,
+			partitions: health.partitions,
+			partitions_quorum: health.partitions_quorum,
+			partitions_all_ok: health.partitions_all_ok,
+		};
+		Ok(health)
+	}
 }
 
 pub async fn handle_connect_cluster_nodes(
 	garage: &Arc<Garage>,
 	req: Request<IncomingBody>,
 ) -> Result<Response<ResBody>, Error> {
-	let req = parse_json_body::<Vec<String>, _, Error>(req).await?;
+	let req = parse_json_body::<ConnectClusterNodesRequest, _, Error>(req).await?;
 
-	let res = futures::future::join_all(req.iter().map(|node| garage.system.connect(node)))
-		.await
-		.into_iter()
-		.map(|r| match r {
-			Ok(()) => ConnectClusterNodesResponse {
-				success: true,
-				error: None,
-			},
-			Err(e) => ConnectClusterNodesResponse {
-				success: false,
-				error: Some(format!("{}", e)),
-			},
-		})
-		.collect::<Vec<_>>();
+	let res = req.handle(garage).await?;
 
 	Ok(json_ok_response(&res)?)
 }
 
+#[async_trait]
+impl EndpointHandler for ConnectClusterNodesRequest {
+	type Response = ConnectClusterNodesResponse;
+
+	async fn handle(self, garage: &Arc<Garage>) -> Result<ConnectClusterNodesResponse, Error> {
+		let res = futures::future::join_all(self.0.iter().map(|node| garage.system.connect(node)))
+			.await
+			.into_iter()
+			.map(|r| match r {
+				Ok(()) => ConnectClusterNodeResponse {
+					success: true,
+					error: None,
+				},
+				Err(e) => ConnectClusterNodeResponse {
+					success: false,
+					error: Some(format!("{}", e)),
+				},
+			})
+			.collect::<Vec<_>>();
+		Ok(ConnectClusterNodesResponse(res))
+	}
+}
+
 pub async fn handle_get_cluster_layout(garage: &Arc<Garage>) -> Result<Response<ResBody>, Error> {
 	let res = format_cluster_layout(garage.system.cluster_layout().inner());
 
@@ -212,85 +236,6 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp
 
 // ----
 
-#[derive(Debug, Clone, Copy, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct ClusterHealth {
-	status: &'static str,
-	known_nodes: usize,
-	connected_nodes: usize,
-	storage_nodes: usize,
-	storage_nodes_ok: usize,
-	partitions: usize,
-	partitions_quorum: usize,
-	partitions_all_ok: usize,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetClusterStatusResponse {
-	node: String,
-	garage_version: &'static str,
-	garage_features: Option<&'static [&'static str]>,
-	rust_version: &'static str,
-	db_engine: String,
-	layout_version: u64,
-	nodes: Vec<NodeResp>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct ApplyClusterLayoutResponse {
-	message: Vec<String>,
-	layout: GetClusterLayoutResponse,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct ConnectClusterNodesResponse {
-	success: bool,
-	error: Option<String>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetClusterLayoutResponse {
-	version: u64,
-	roles: Vec<NodeRoleResp>,
-	staged_role_changes: Vec<NodeRoleChange>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct NodeRoleResp {
-	id: String,
-	zone: String,
-	capacity: Option<u64>,
-	tags: Vec<String>,
-}
-
-#[derive(Serialize, Default)]
-#[serde(rename_all = "camelCase")]
-struct FreeSpaceResp {
-	available: u64,
-	total: u64,
-}
-
-#[derive(Serialize, Default)]
-#[serde(rename_all = "camelCase")]
-struct NodeResp {
-	id: String,
-	role: Option<NodeRoleResp>,
-	addr: Option<SocketAddr>,
-	hostname: Option<String>,
-	is_up: bool,
-	last_seen_secs_ago: Option<u64>,
-	draining: bool,
-	#[serde(skip_serializing_if = "Option::is_none")]
-	data_partition: Option<FreeSpaceResp>,
-	#[serde(skip_serializing_if = "Option::is_none")]
-	metadata_partition: Option<FreeSpaceResp>,
-}
-
 // ---- update functions ----
 
 pub async fn handle_update_cluster_layout(
@@ -304,7 +249,7 @@ pub async fn handle_update_cluster_layout(
 	let mut roles = layout.current().roles.clone();
 	roles.merge(&layout.staging.get().roles);
 
-	for change in updates {
+	for change in updates.0 {
 		let node = hex::decode(&change.id).ok_or_bad_request("Invalid node identifier")?;
 		let node = Uuid::try_from(&node).ok_or_bad_request("Invalid node identifier")?;
 
@@ -343,7 +288,7 @@ pub async fn handle_apply_cluster_layout(
 	garage: &Arc<Garage>,
 	req: Request<IncomingBody>,
 ) -> Result<Response<ResBody>, Error> {
-	let param = parse_json_body::<ApplyLayoutRequest, _, Error>(req).await?;
+	let param = parse_json_body::<ApplyClusterLayoutRequest, _, Error>(req).await?;
 
 	let layout = garage.system.cluster_layout().inner().clone();
 	let (layout, msg) = layout.apply_staged_changes(Some(param.version))?;
@@ -375,36 +320,3 @@ pub async fn handle_revert_cluster_layout(
 	let res = format_cluster_layout(&layout);
 	Ok(json_ok_response(&res)?)
 }
-
-// ----
-
-type UpdateClusterLayoutRequest = Vec<NodeRoleChange>;
-
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct ApplyLayoutRequest {
-	version: u64,
-}
-
-// ----
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct NodeRoleChange {
-	id: String,
-	#[serde(flatten)]
-	action: NodeRoleChangeEnum,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(untagged)]
-enum NodeRoleChangeEnum {
-	#[serde(rename_all = "camelCase")]
-	Remove { remove: bool },
-	#[serde(rename_all = "camelCase")]
-	Update {
-		zone: String,
-		capacity: Option<u64>,
-		tags: Vec<String>,
-	},
-}
diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs
index 291b6d54..96ce3518 100644
--- a/src/api/admin/key.rs
+++ b/src/api/admin/key.rs
@@ -2,13 +2,16 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 use hyper::{body::Incoming as IncomingBody, Request, Response, StatusCode};
-use serde::{Deserialize, Serialize};
 
 use garage_table::*;
 
 use garage_model::garage::Garage;
 use garage_model::key_table::*;
 
+use crate::admin::api::{
+	ApiBucketKeyPerm, CreateKeyRequest, GetKeyInfoResponse, ImportKeyRequest,
+	KeyInfoBucketResponse, KeyPerm, ListKeysResponseItem, UpdateKeyRequest,
+};
 use crate::admin::api_server::ResBody;
 use crate::admin::error::*;
 use crate::helpers::*;
@@ -25,7 +28,7 @@ pub async fn handle_list_keys(garage: &Arc<Garage>) -> Result<Response<ResBody>,
 		)
 		.await?
 		.iter()
-		.map(|k| ListKeyResultItem {
+		.map(|k| ListKeysResponseItem {
 			id: k.key_id.to_string(),
 			name: k.params().unwrap().name.get().clone(),
 		})
@@ -34,13 +37,6 @@ pub async fn handle_list_keys(garage: &Arc<Garage>) -> Result<Response<ResBody>,
 	Ok(json_ok_response(&res)?)
 }
 
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct ListKeyResultItem {
-	id: String,
-	name: String,
-}
-
 pub async fn handle_get_key_info(
 	garage: &Arc<Garage>,
 	id: Option<String>,
@@ -73,12 +69,6 @@ pub async fn handle_create_key(
 	key_info_results(garage, key, true).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct CreateKeyRequest {
-	name: Option<String>,
-}
-
 pub async fn handle_import_key(
 	garage: &Arc<Garage>,
 	req: Request<IncomingBody>,
@@ -101,14 +91,6 @@ pub async fn handle_import_key(
 	key_info_results(garage, imported_key, false).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct ImportKeyRequest {
-	access_key_id: String,
-	secret_access_key: String,
-	name: Option<String>,
-}
-
 pub async fn handle_update_key(
 	garage: &Arc<Garage>,
 	id: String,
@@ -139,14 +121,6 @@ pub async fn handle_update_key(
 	key_info_results(garage, key, false).await
 }
 
-#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct UpdateKeyRequest {
-	name: Option<String>,
-	allow: Option<KeyPerm>,
-	deny: Option<KeyPerm>,
-}
-
 pub async fn handle_delete_key(
 	garage: &Arc<Garage>,
 	id: String,
@@ -192,7 +166,7 @@ async fn key_info_results(
 		}
 	}
 
-	let res = GetKeyInfoResult {
+	let res = GetKeyInfoResponse {
 		name: key_state.name.get().clone(),
 		access_key_id: key.key_id.clone(),
 		secret_access_key: if show_secret {
@@ -207,7 +181,7 @@ async fn key_info_results(
 			.into_values()
 			.map(|bucket| {
 				let state = bucket.state.as_option().unwrap();
-				KeyInfoBucketResult {
+				KeyInfoBucketResponse {
 					id: hex::encode(bucket.id),
 					global_aliases: state
 						.aliases
@@ -239,41 +213,3 @@ async fn key_info_results(
 
 	Ok(json_ok_response(&res)?)
 }
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct GetKeyInfoResult {
-	name: String,
-	access_key_id: String,
-	#[serde(skip_serializing_if = "is_default")]
-	secret_access_key: Option<String>,
-	permissions: KeyPerm,
-	buckets: Vec<KeyInfoBucketResult>,
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct KeyPerm {
-	#[serde(default)]
-	create_bucket: bool,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct KeyInfoBucketResult {
-	id: String,
-	global_aliases: Vec<String>,
-	local_aliases: Vec<String>,
-	permissions: ApiBucketKeyPerm,
-}
-
-#[derive(Serialize, Deserialize, Default)]
-#[serde(rename_all = "camelCase")]
-pub(crate) struct ApiBucketKeyPerm {
-	#[serde(default)]
-	pub(crate) read: bool,
-	#[serde(default)]
-	pub(crate) write: bool,
-	#[serde(default)]
-	pub(crate) owner: bool,
-}
diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs
index 43a8c59c..e64eca7e 100644
--- a/src/api/admin/mod.rs
+++ b/src/api/admin/mod.rs
@@ -1,8 +1,24 @@
 pub mod api_server;
 mod error;
+
+pub mod api;
 mod router_v0;
 mod router_v1;
 
 mod bucket;
 mod cluster;
 mod key;
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use serde::Serialize;
+
+use garage_model::garage::Garage;
+
+#[async_trait]
+pub trait EndpointHandler {
+	type Response: Serialize;
+
+	async fn handle(self, garage: &Arc<Garage>) -> Result<Self::Response, error::Error>;
+}