Buy Me a Coffee

Buy Me a Coffee!

Friday, June 5, 2026

Automating Leantime with C# — Troubles, Trials, and Lessons Learned

I am working on a fun side project - an automation tool to help me manage and maintain my Proxmox home lab. After a few weeks of building out features, I realized my ideas for changes and enhancements were quickly overflowing the amount of mental swap space I was able to commit to the project. I needed a backlog, and I needed it to grow as I worked. I was already using Leantime to manage a project to track plans for wiring my home network and another project to manage the book I am writing, so it was obvious where my tasks needed to live. The problem is, manually adding tasks for an automation project seems counterproductive. It only took me about three days to get code that could manage to automate Leantime with C# code. Those three days were not boring.

The Starting Point

Leantime exposes a JSON-RPC 2.0 API. Not REST, not GraphQL - JSON-RPC. If you have not worked with it before, every call is a POST to a single endpoint with a method name and params in the body:

json
{ "jsonrpc": "2.0", "method": "leantime.rpc.Tickets.Tickets.addTicket", "params": { "values": { "headline": "My Task", "type": "task" } }, "id": 1 }

Authentication is an x-api-key header. Simple enough. I wrote a thin C# client around HttpClient, set up the serialization, and had my first successful call within an hour. That was the last time something worked on the first try.

The Secret 'Secrets' Side Quest

I did not want to paste an API key into a config file. I already had Infisical running in my lab for other secrets, so the plan was to store the Leantime API key there and resolve it at runtime.  But, I wasn't super happy with the Infisical UI so I thought it would be a great time to look at OpenBao (the open-source Vault fork) since it was much cleaner to interact with.  

This turned into its own project within the project. I had previously been using Infisical as my secret provider, so my tool already had a SecretProviderFactory that could resolve secrets by name. Adding OpenBao as a second provider meant abstracting a KV v2 client, writing a scoped read policy, minting a service token, and wiring it all together before I could even start calling the Leantime API:

csharp
var provider = SecretProviderFactory.Create(config); var apiKey = await provider.GetSecretAsync(config.Leantime.ApiKeySecretName);

Three lines of consuming code. Two days of infrastructure. Worth it for keeping secrets out of source control, but I cannot pretend I did not consider hardcoding the key more than once.

The Update

When I first connected to Leantime and started calling the API, things were inconsistent. Some methods worked, some returned -32601 Method not found, and the responses I got back were not what the documentation suggested. Turns out I was running an older version of Leantime (3.5.0). I updated Leantime to 3.8.0, which fixed some issues and created others - rate limiting appeared where it had not been before, and some method signatures changed. 

Did I mention that Leantime is written in PHP?  I once left a job because they were migrating from SharePoint to Drupal.  Seriously.  But Leantime is a popular project so it shouldn't be that bad.  Little did I know, I was going on a trip through a strange land.  

The update itself was straightforward. SSH into the LXC, pull the release, update the files. But now I had a moving target - code that worked yesterday might not work today because the API contract changed underneath me. There is no published schema or changelog for the RPC layer, so my approach became: try it, read the error, and adapt.  

The Rate Limit Wall

After the update, Leantime started enforcing rate limits - roughly 15 requests per minute. My seeder was trying to create 70 tickets as fast as the network could carry them. The first run got about 4 tickets in before the server started returning HTTP 429.

The fix has two parts. First, the client retries automatically when it sees a 429, respecting the Retry-After header:

csharp
for (int attempt = 0; attempt < 3; attempt++) { var response = await _http.PostAsync(_apiUrl, content); if (response.StatusCode == HttpStatusCode.TooManyRequests) { var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60); await Task.Delay(retryAfter); continue; } // process response... }

Second, the seeder introduces a 4500ms delay between ticket creates to stay under the limit proactively rather than constantly hitting the wall and backing off. The deleter uses a shorter 1200ms pace because single-field patches are less expensive on the server. It is not elegant, but it is reliable.

The API Quirks

This is where most of my time went. Leantime's JSON-RPC layer has a personality.

Array-wrapped booleans. Tickets.delete returns [true] - an array containing a boolean - not true. My initial response parser threw a deserialization exception because it expected a scalar. I added unwrapping logic to peel arrays of length 1.

Multiple success formats. Creating a ticket might return 5 (the new ID as an integer), or true (success with no ID), or "5" (the ID as a string), or {"id": 5} (an object), or [5] (an array). I am not exaggerating. The response parser has to handle all of these:

csharp
return el.ValueKind switch { JsonValueKind.Number => el.GetInt32(), JsonValueKind.True => 1, JsonValueKind.String => int.TryParse(el.GetString(), out var v) ? v : -1, JsonValueKind.Object => TryExtractId(el), JsonValueKind.Array => el[0].GetInt32(), _ => -1 };

Parameter wrapping. Some methods expect {"values": {...}} wrapping the actual parameters. Others expect flat params. There is no obvious pattern. I found out by trial and error which methods need the wrapper.

Missing methods. Projects.removeProject returns -32601 Method not found. It is referenced in the Leantime source code but not exposed through the RPC gateway. My delete workflow has to iterate every ticket in a project and remove them one at a time. The project shell still lingers in the UI.

Method name changes. createClient became the method in one version but was create in another. My client tries the new name first and falls back:

csharp
try { return await CallAsync("createClient", new { values }); } catch (InvalidOperationException ex) when (ex.Message.Contains("-32601")) { } return await CallAsync("create", new { values });

User response formats. Users.getAll returns an array on some endpoints and a keyed object on others. Same method, different shapes depending on context. I handle both:

csharp
IEnumerable<JsonElement> items = el.ValueKind switch { JsonValueKind.Array => el.EnumerateArray().ToList(), JsonValueKind.Object => el.EnumerateObject().Select(p => p.Value).ToList(), _ => [] };

Each one of these cost 15–30 minutes of debugging. Multiply that by a dozen quirks and you have a day gone.  RPC over PHP is...shiny.

Making It Idempotent

I live for repeatability when developing.  I wanted the seeder to be safe to run repeatedly. If a ticket already exists, skip it. If the project is already there, reuse it. The approach is simple - before creating anything, pull all existing tickets and index them by headline:

csharp
var existingByTitle = existingTickets .ToDictionary(t => t.Headline.Trim().ToLowerInvariant(), t => t.Id); if (existingByTitle.TryGetValue(key, out var existingId)) return existingId;

This means I can evolve the backlog JSON file - add new milestones or tasks - and re-run the seeder without duplicating anything that is already there. Combined with the rate limit pacing, a full seed of 12 milestones and 70 tasks takes about 6 minutes. Not fast, but completely unattended.

Server-Side Configuration via SSH

After I got almost everything working, I decided I should look at removing the rate limit issue that had been slowing me down so much.  I had set myself some rules for interacting with Leantime and one of them required me to manage it's server configuration without logging into the box manually. I needed to set rate limit values and manage an IP whitelist - both stored as environment variables in the .env file on the Leantime LXC without going to the machine's console.  No problem!

The automation tool I am working on already had an SSH client library for managing other Linux nodes, so I extended it to edit the .env file remotely with sed and grep:

csharp
// Read var (output, _) = await ssh.ExecAsync(host, $"grep '^MCP_RATE_LIMIT=' {envFilePath} | cut -d= -f2"); // Write await ssh.ExecAsync(host, $"sed -i 's/^MCP_RATE_LIMIT=.*/MCP_RATE_LIMIT={value}/' {envFilePath}"); await ssh.ExecAsync(host, "systemctl restart apache2");

Straightforward Unix commands behind a C# interface. The host and file path are derived from values already in the config file, so the CLI commands stay clean:

text
leantime rate-limit set 100 leantime whitelist add 192.168.1.103

What I Learned

All learning is good learning. The three days I spent wrestling with Leantime's API were not wasted even if the immediate goal was just "add some tickets to a project tracker." Here is what ended up in my toolbox:

JSON-RPC is not REST. The single-endpoint design means you cannot rely on HTTP verbs or status codes to tell you what happened. Error handling lives entirely in the response body. Your client needs to be more defensive than a typical REST consumer.

Defensive response parsing pays off. Building a parser that handles five different success shapes for the same operation sounds overengineered until you realize the alternative is hard-crashing at 2am when the API returns a string instead of an integer.

Rate limiting is a feature, not a bug. Once I accepted that and built pacing into the workflow, the system became more reliable than it was before the limit existed. The 429 retry logic means transient throttling self-heals without human intervention.

Automate the automation infrastructure. Spending two days on secret management feels excessive until you realize that the API key rotation, secret scoping, and an audit trail all come for free now. Every future integration just calls GetSecretAsync and moves on.

Side quests are the real project. I set out to add backlog tickets to Leantime to help me work on my side project. Along the way I built a generic JSON-RPC client, added OpenBao integration to my secret provider, added SSH-based server management, and learned more about Leantime internals than the documentation will ever tell you. Every one of those pieces is reusable for something else.  Dogfooding helps build higher quality code and much better feature sets.

Having more and varied automation tools in your toolbox is never a bad thing. The specific issues I faced creating my Leantime automation code may not apply to your projects, but the patterns - retry logic, idempotent operations, defensive parsing, secret management, SSH-based configuration - show up everywhere. The next time an API surprises you with inconsistent response shapes or undocumented rate limits, you will already have a playbook and will know that others have felt your pain.