Running Jira from Discord: the two limits that shaped the build

  • Suyog Acharya
  • 15 min read

A tracker only works if people open it. Ours sat behind a tab nobody had open, while the team talked all day in Discord, and the gap between the two was filled by somebody remembering to go and type it in. That gap is where the work went missing.

So I moved the tracker into the chat. Over five months and about thirty working days I built a bot that creates and edits Jira issues from slash commands, turns a plain-English sentence into a ticket, pushes sprint summaries on a schedule, and keeps several hundred community roles matching a database. It is roughly 8,300 lines of JavaScript across sixty-odd files, running as one container.

Most of what I want to write down is not the feature list. It is that two numbers in two sets of documentation decided the shape of the whole thing, and I did not find either of them until after I had written code that violated both.

Everything below is generic on purpose. The server and the project it was built for are not named anywhere, and the worked example is Commonhall, an invented community product that this article uses so the role arithmetic has somewhere to live. Commonhall is not a client and never was.

What does a Discord-to-Jira bot actually have to do?

Less than you would guess, and more than it looks.

The visible half is sixteen slash commands. Create a task, create a bug, edit either, move one to a new status, search by keyword, show a sprint, show a backlog, show what is assigned to me. Each one that takes real input opens a modal, because a dropdown for priority and assignee removes a whole class of typo that free text does not.

The invisible half is the part that takes the time:

  • Identity. Discord knows a user by a snowflake ID; Jira knows them by an account ID tied to an email. Nothing works both ways until those are linked, so a /link-jira command stores the pairing. Without it a mention in a ticket cannot find a person to ping.
  • Both directions. Commands push Discord to Jira. A webhook pushes Jira back to Discord, so a status changed in the browser shows up in the channel without anyone retyping it.
  • Schedules. Sprint summaries as tables grouped by status, posted on a timer, with the assignee mentioned so it is their name they see.
  • Boundaries. Commands only answer in the channels meant for them. A bug report in the general channel is noise, and a tidy server is the difference between a tool people use and a tool people mute.

So much for the brief. What made it hard were the two limits.

The first limit: Discord gives you fifty requests a second

Discord’s rate-limit documentation states the global ceiling plainly: “All bots can make up to 50 requests per second to our API.” That number is comfortable until you write your first bulk operation, and then it is not.

Underneath the global limit sits a per-route one, and this is the part worth internalising. Limits are not per endpoint, they are per bucket, and the docs say so: X-RateLimit-Bucket is “A unique string denoting the rate limit being encountered”, and “It’s recommended to use this header as a unique identifier for a rate limit.” Routes share buckets. Buckets also account for the top-level resource, so the same call against two different servers is limited separately. If you build your throttling around URLs you will be wrong in both directions at once, holding back calls that were never going to collide and firing calls that were.

The number that actually hurts is the other one. Invalid requests are counted, and the docs define them narrowly: “An invalid request is one that results in 401, 403, or 429 statuses.” Exceed the threshold and the response stops being an API at all, because “IP addresses that make too many invalid HTTP requests are automatically and temporarily restricted.” The threshold is “10,000 per 10 minutes.”

Read those two sentences together and a nasty property falls out. A 429 is itself an invalid request. So the failure mode of hitting a rate limit is to generate more invalid requests, which moves you toward a ban on the whole IP rather than a throttle on the one route. A naive retry loop does not degrade, it accelerates. The worst thing a bot can do on a 429 is try again immediately, and that is exactly what an unconfigured HTTP client does.

There is one mercy in the spec, and it is easy to miss: “429 errors returned with X-RateLimit-Scope: shared are not counted against you.” A shared-scope 429 means you collided with someone else on a resource limit rather than burning your own budget. Worth reading the header before you panic about your own numbers.

What that means for a role sync

None of which is theory. Say Commonhall has 1,200 members, and each one’s roles are derived from a database: which projects they belong to, what tier their score puts them in, whether they have platform access. Every few minutes the bot has to make Discord agree with that database.

The naive version reads 1,200 members and writes 1,200 role sets. At 50 requests a second the writes alone are 24 seconds of continuous saturation, with nothing left for the eleven people trying to file a bug meanwhile. Run it every five minutes and the bot spends eight per cent of its life at the ceiling, which is fine right up to the first retry storm.

The version that works does three things. It diffs first, because the overwhelming majority of members have not changed since the last pass and a write you skip is a write that cannot be rate limited. It runs the remainder as a background job with its own token bucket well under the global ceiling, so a sync can never starve an interactive command. And it reports progress through a job ID that the caller polls, because an HTTP request that waits for 1,200 role writes has already failed, it just does not know it yet.

The diff is the whole trick. Sync jobs are almost always mostly no-ops, and the engineering is in finding that out cheaply.

The second limit: Jira retries five times, then stops trying

I assumed the webhook was the reliable half. It is the opposite.

Atlassian’s webhook documentation sets out the retry policy: “If a webhook is sent to its callback URL but fails, Jira Cloud will attempt to resend it up to five times”, each attempt “delayed with a randomized back-off algorithm”. Then comes the sentence that should change your design: “if the recipient doesn’t respond successfully within 30 minutes, we only try to deliver a single attempt per webhook.”

So the degradation is not graceful, it is a cliff. Miss half an hour and you do not come back to five retries, you come back to one shot per event until a delivery succeeds. A deploy that takes thirty-one minutes, a container that restarts badly, a TLS certificate that lapses on a Saturday: any of these turns a reliable feed into a lossy one, and nothing tells you. The docs are explicit about the consequence: “Failing to consume webhook traffic quickly enough could result in recipient errors, which may exhaust the number of retries Jira attempts. This leads to missed webhooks.”

The same page rules out the other naive assumption: “some webhooks might be delivered more than once (if the delivery acknowledgment fails).” At-least-once, not exactly-once. Every handler has to be idempotent, which in practice means keying on the Jira issue key plus the change timestamp and discarding what you have already applied.

Two consequences I would now treat as defaults on any webhook integration:

Acknowledge before you work. The endpoint should validate the shared secret, write the event somewhere durable, return 200, and process afterwards. Any work done before the response is work that counts against the thirty-minute window. Atlassian sizes the budget elsewhere on the same page, saying “All Primary webhooks should be delivered within 30 seconds” against a concurrency cap of twenty per tenant and host pair.

Poll anyway. A scheduled reconciliation pass asks Jira what changed since the last confirmed sync and fills in whatever the webhook did not deliver. It is unglamorous, it is a second code path doing what the first one was supposed to do, and it is the reason the channel and the board do not quietly drift apart over a weekend. Webhooks are an optimisation on top of polling, not a replacement for it. I had it the other way round for about a month.

Two paths into one handler, because webhooks are best effort Two paths into one handler, because webhooks are best effort: Issue tracker (the source of truth), then Webhook (fast, best effort), Scheduled reconciliation (slow, complete), then Idempotent handler (discards what it has applied), then Bot database, then Chat channel. Issue tracker the source of truth Webhook fast, best effort Scheduled reconciliation slow, complete Idempotent handler discards what it has applied Bot database Chat channel
The reconciliation path exists because the webhook path is best effort. Each is wrong on its own.

Why every managed role is created with no permissions

One decision in the whole build took five minutes to implement, and I am still glad about it.

The bot creates and assigns Discord roles from database state. That means a bug in the mapping logic, a bad migration, or a mis-scoped database query can hand roles to the wrong people. Standard practice would be to be careful. Careful is not a control.

So every role the bot owns is created with a permission integer of zero. The roles carry no capability whatsoever. They exist to be coloured, to be mentioned, and to gate channel access through channel-level overwrites that a human sets once. The worst possible outcome of a catastrophic sync bug is that someone is briefly in the wrong colour.

The property that matters is not that the roles are harmless. It is that they are structurally harmless, so the blast radius does not depend on the code being right. The same reasoning drives the read-only role as an MCP security model: when an automated thing acts on your behalf, bound its authority at the boundary rather than auditing every path that reaches it. A permission that does not exist cannot be granted by mistake.

Clients ask one question before they let a bot near their server, which is what happens when it goes wrong. “It cannot give anyone admin, because the roles it manages have no permissions to give” is a better answer than any amount of test coverage.

Where the model earns its place, and where it does not

There is exactly one language model in this system, and it has a form underneath it.

The useful job is intake. Somebody types “the login page breaks on Safari, assign it to Ram, this is blocking the demo” in the right channel. A model extracts a title, a description, a priority and an assignee, the bot shows the parsed result, and a human presses confirm before anything reaches Jira. Ten seconds, no fields, no tab switch.

Everything else in the bot is deterministic, and deliberately so. Posting a sprint summary is a query and a template. Syncing roles is a diff. Routing a webhook is a switch statement. None of them is improved by a model, and all of them get slower, costlier and harder to debug with one in the path. That is the same argument as most of what gets sold as an agent should be a workflow: the control flow belongs in code unless something genuinely unpredictable has to happen.

Two rules make the model safe to depend on:

  1. It confirms. Nothing is written from a model’s output without a human looking at the parsed fields. The model proposes a ticket, it does not file one.
  2. It falls back. If the provider is down, slow, or returns something that fails validation, the bot opens the ordinary modal instead. Degraded means a form appears. It never means a lost report.

People skip the second rule, and skipping it is the difference between a feature and a liability. A model in the critical path without a deterministic fallback is an outage with extra steps.

What it took: thirty working days spread across five months

The honest timeline, because the compressed version is the one that misleads people who are budgeting.

Phase Working days What landed
Core bot and Jira 3 Task and bug modals, issue creation, webhook sync, first rate limiting
Production setup 4 Docker, nginx, HTTPS, secrets out of the repo
Workflow commands 4 Edit, delete, search, epics, sprints, channel rules, screenshots
AI ticket intake 2 Plain-English parsing, the confirm step, the fallback
Sync and alerts 8 Two-way sync, polling reconciliation, scheduled tables, direct messages
Roles API 4 Secured endpoints, background jobs, job status
Fixes and hardening 4 Alert retries, a Jira call that could hang, an oversized embed
Community role sync 4 Database-driven roles, the zero-permission rule

Two things stand out when it is laid out like that.

The first week got the demo working. Everything after it was the difference between a demo and something a team can depend on, and it was seven times the work. The sync and alerts phase alone took eight days, more than core Jira integration and production setup combined, and almost all of that was the reconciliation path and the retry logic rather than anything a user can see.

The second is that the hardening row never really closes. Alert retries, a Jira call with no timeout that could hang the process, an embed that exceeded Discord’s size cap and took the message with it: none of those were foreseeable from the brief. They were found by running it.

What drives the cost, if you are budgeting for one

I am not going to quote you a number in a blog post, because the number depends on your Jira workflow and I have not seen it. What I can give you is the shape, which is more useful anyway.

Work of this kind sits in the automation band on our AI and automation page, which starts at $3,000. Four things move a build within it or past it:

How far your Jira workflow is from the default. Status mapping is the single most client-specific part of the system. A team using the stock columns is configuration. A team with eleven statuses, two of which mean “done” to different departments, is a conversation before it is code.

Whether anything syncs from your database. Commands and alerts talk to Jira and Discord only. Role sync adds a third source of truth, and with it the diffing, the job queue and the rate-limit budget described above. It is the largest single piece of work in this article and the one most often underestimated.

Which chat platform. Slack instead of Discord is roughly one to two weeks more. The Jira logic, the reconciliation, the job queue and the database layer are unchanged; only the chat adapter is rewritten, which is a good argument for keeping that boundary clean from the first commit even when you only have one platform.

Whether you want the model at all. Plain-English intake is about two days of the thirty. Skip it and you lose ten seconds per ticket and nothing else.

A few things worth saying plainly before anyone budgets against this. One deployment serves one server and one Jira project today; several clients from one deployment is rework we have not done. The status mapping needs to become configurable per client. There are no automated tests yet, and I would want them before this goes anywhere near a paying client. We would rather say that now than discover it in week three, which is roughly how we work generally.

The part I would tell anyone starting this

Read the rate limits before you write the client, not after. Both numbers in this article are in the first page of the respective documentation, and I found both the expensive way: Discord’s by getting a bot temporarily banned on a bulk operation I thought was small, and Jira’s by watching a channel drift out of sync for a week before I understood that the retries had already been spent.

Chat-ops looks like a thin layer over two APIs. It is actually a distributed system with two sources of truth, two rate limiters and no transaction spanning them, and the day you accept that is the day the design gets simple. Diff before you write. Acknowledge before you work. Poll anyway. Give the automation no permissions it does not need.

If your team is keeping a tracker and a chat server in sync by hand, that is a bot, a webhook and a scheduled job, and it is the kind of thing we build under AI and automation. Tell us what is eating the week and we will tell you whether it needs a model at all, which it usually does not.

Suyog Acharya is a trainee engineer at Crimson Umbrella Technologies and built the bot this article describes. You can find him on LinkedIn.

Tell us what’s broken.

Twenty minutes, no deck. We’ll tell you straight whether this is work we should be doing, and if it isn’t, we usually know who should.

We come back within one business day.