diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md
index dbaa9ea7196e4d5dd21f3c00567275c4033a138a..edb5fda84d348310814c5dd399e80e5814936fcc 100644
--- a/docs/HTTP_API.md
+++ b/docs/HTTP_API.md
@@ -196,6 +196,47 @@ Mark an item as deleted:
 * Update `dateModified` (server's time is taken)
 
 
+### POST /v2/$owner_key/insert_tree
+```json5
+{
+  "databaseKey": "2DD29CA851E7B56E4697B0E1F08507293D761A05CE4D1B628663F411A8086D99",
+  "payload": { /* item definition (see below) */ }
+}
+```
+Insert a tree with edges (of arbitrary depth) in one batch.
+
+Each item should either be an object with only `uid` and `_edges` fields:
+```json5
+{
+  "uid": 123456789 /* uid of the item to create edge with */,
+  "_edges": [ /* see below edges definition*/ ]
+}
+```
+Or the full item to be created, in which case `uid` is optional,
+but all standard mandatory item fields need to be present:
+```json5
+{
+  "_type": "SomeItemType",
+  "_edges": [ /* see below edges definition*/ ],
+  /* other item properties here */
+}
+```
+
+Each edge in the array above is required to have the following form:
+```json5
+{
+  "_type": "SomeEdgeType",
+  "_target": { /* item of identical structure to the above */ }
+  /* optional edge properties here */
+}
+```
+
+As always, inserting edges will result in updating timestamps for `_source` items
+(even if they are referenced by `uid` only).
+
+The method will return the `uid` of the created root item, e.g. `123456789`.
+
+
 ### POST /v2/$owner_key/search_by_fields/
 ```json
 {
@@ -204,8 +245,10 @@ Mark an item as deleted:
 }
 ```
 Search items by their fields.
-Field `_dateServerModifiedAfter` is not treated in the standard way, and instead, it filters
-items by their `_dateServerModified` field using the `>` operator.
+
+Ephemeral underscore field `_dateServerModifiedAfter`, if specified,
+is treated specially. It will filter out those items that have
+`_dateServerModified` higher (`>`) than the specified value.
 
 The endpoint will return an array of all items with exactly the same properties.
 
diff --git a/src/api_model.rs b/src/api_model.rs
index eefcd15e8b51b7a048f2b17934cab9678d315074..29ec34fd19bb74f762d40d06fec1509af738af06 100644
--- a/src/api_model.rs
+++ b/src/api_model.rs
@@ -49,6 +49,22 @@ pub struct BulkAction {
     pub delete_edges: Vec<DeleteEdge>,
 }
 
+#[derive(Serialize, Deserialize, Debug)]
+pub struct InsertTreeItem {
+    #[serde(default)]
+    pub _edges: Vec<InsertTreeEdge>,
+    #[serde(flatten)]
+    pub fields: HashMap<String, Value>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+pub struct InsertTreeEdge {
+    pub _type: String,
+    pub _target: InsertTreeItem,
+    #[serde(flatten)]
+    pub fields: HashMap<String, Value>,
+}
+
 #[derive(Serialize, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 pub struct SearchByFields {
diff --git a/src/internal_api.rs b/src/internal_api.rs
index f830e65728ba65d6b9834f70ade3b27b3e04fb16..110a4629d1a09d28d563749e7c3a5c576e1453e2 100644
--- a/src/internal_api.rs
+++ b/src/internal_api.rs
@@ -1,6 +1,6 @@
 use crate::api_model::BulkAction;
-use crate::api_model::CreateItem;
 use crate::api_model::DeleteEdge;
+use crate::api_model::InsertTreeItem;
 use crate::api_model::SearchByFields;
 use crate::error::Error;
 use crate::error::Result;
@@ -250,12 +250,25 @@ pub fn bulk_action_tx(tx: &Transaction, bulk_action: BulkAction) -> Result<()> {
     Ok(())
 }
 
-pub fn create_item(conn: &mut Connection, create_action: CreateItem) -> Result<i64> {
-    debug!("Creating item {:?}", create_action);
-    let tx = conn.transaction()?;
-    let result = create_item_tx(&tx, create_action.fields)?;
-    tx.commit()?;
-    Ok(result)
+pub fn insert_tree(tx: &Transaction, item: InsertTreeItem) -> Result<i64> {
+    let source_uid: i64 = if item.fields.len() > 1 {
+        create_item_tx(tx, item.fields)?
+    } else if let Some(uid) = item.fields.get("uid").map(|v| v.as_i64()).flatten() {
+        if !item._edges.is_empty() {
+            update_item_tx(tx, uid, HashMap::new())?;
+        }
+        uid
+    } else {
+        return Err(Error {
+            code: StatusCode::BAD_REQUEST,
+            msg: format!("Cannot create item: {:?}", item),
+        });
+    };
+    for edge in item._edges {
+        let target_item = insert_tree(tx, edge._target)?;
+        create_edge(tx, &edge._type, source_uid, target_item, edge.fields)?;
+    }
+    Ok(source_uid)
 }
 
 pub fn search_by_fields(tx: &Transaction, query: SearchByFields) -> Result<Vec<Value>> {
diff --git a/src/warp_api.rs b/src/warp_api.rs
index f8642bd078f9227a877fa116ce90cbb2e5da6add..cab5ebea4d2d81443f0eae1f98b24c782cc0856b 100644
--- a/src/warp_api.rs
+++ b/src/warp_api.rs
@@ -1,6 +1,7 @@
 use crate::api_model::BulkAction;
 use crate::api_model::CreateItem;
 use crate::api_model::GetFile;
+use crate::api_model::InsertTreeItem;
 use crate::api_model::PayloadWrapper;
 use crate::api_model::RunDownloader;
 use crate::api_model::RunImporter;
@@ -126,7 +127,18 @@ pub async fn run_server(cli_options: &CLIOptions) {
         });
 
     let init_db = initialized_databases_arc.clone();
-    let search = items_api
+    let insert_tree = items_api
+        .and(warp::path!(String / "insert_tree"))
+        .and(warp::path::end())
+        .and(warp::body::json())
+        .map(move |owner: String, body: PayloadWrapper<InsertTreeItem>| {
+            let result = warp_endpoints::insert_tree(owner, init_db.deref(), body);
+            let result = result.map(|result| warp::reply::json(&result));
+            respond_with_result(result)
+        });
+
+    let init_db = initialized_databases_arc.clone();
+    let search_by_fields = items_api
         .and(warp::path!(String / "search_by_fields"))
         .and(warp::path::end())
         .and(warp::body::json())
@@ -250,7 +262,8 @@ pub async fn run_server(cli_options: &CLIOptions) {
         .or(bulk_action.with(&headers))
         .or(update_item.with(&headers))
         .or(delete_item.with(&headers))
-        .or(search.with(&headers))
+        .or(insert_tree.with(&headers))
+        .or(search_by_fields.with(&headers))
         .or(get_items_with_edges.with(&headers))
         .or(run_downloader.with(&headers))
         .or(run_importer.with(&headers))
diff --git a/src/warp_endpoints.rs b/src/warp_endpoints.rs
index f79251b40adfb5b9e12b2a01d0b83f761500951c..b07ff183ea5bb2d3e4d9ec47f3b8cde9c8a0c025 100644
--- a/src/warp_endpoints.rs
+++ b/src/warp_endpoints.rs
@@ -1,6 +1,7 @@
 use crate::api_model::BulkAction;
 use crate::api_model::CreateItem;
 use crate::api_model::GetFile;
+use crate::api_model::InsertTreeItem;
 use crate::api_model::PayloadWrapper;
 use crate::api_model::RunDownloader;
 use crate::api_model::RunImporter;
@@ -94,6 +95,15 @@ pub fn delete_item(
     })
 }
 
+pub fn insert_tree(
+    owner: String,
+    init_db: &RwLock<HashSet<String>>,
+    body: PayloadWrapper<InsertTreeItem>,
+) -> Result<i64> {
+    let mut conn: Connection = check_owner_and_initialize_db(&owner, &init_db, &body.database_key)?;
+    in_transaction(&mut conn, |tx| internal_api::insert_tree(&tx, body.payload))
+}
+
 pub fn search_by_fields(
     owner: String,
     init_db: &RwLock<HashSet<String>>,