yeetpostyeetpost

Discord webhook rate limits

November 3, 2026

Every response from a Discord webhook carries its own rate limit state in headers, and Discord leaves the per-webhook message rate unpublished on purpose. Discord's own rate limit documentation states that rate limits should not be hard coded into an application and that apps should parse the response headers instead. The per-route values move, while the header names stay fixed.

Discord webhook rate limits

#The global cap and the per-route buckets

There are two layers. The documented global cap is that all bots can make up to 50 requests per second to the API. The sentence that matters for webhooks follows immediately: if no authorization header is provided, the limit is applied to the IP address. Executing a webhook does not send a bot token, since the credential sits in the URL path of the Execute Webhook endpoint, so a webhook sender is counted against its own IP address, with no bot identity involved. If you fan out from a single worker to many webhooks, all of it lands in one 50 per second bucket. Splitting the sends across processes on the same host does not help. Splitting them across hosts does.

Underneath that sits the per-route layer. Discord calculates route limits using a top-level resource as the identifier, and the documented top-level resources are channels (channel_id), guilds (guild_id) and webhooks (webhook_id or webhook_id + webhook_token). Two consequences follow. A webhook is its own bucket, so one noisy integration will not exhaust another's budget. And because a channel is also a top-level resource, two different webhooks pointing at the same channel can still contend on a shared channel-level limit. When that happens the 429 comes back marked with a shared scope, which is how you tell the two cases apart.

Some routes sit outside the normal scheme. Emoji routes are limited per guild, and interaction endpoints are not bound to the global bot limit at all.

#The headers on every response

Discord returns bucket state on ordinary successful responses as well as on failures, so a well behaved sender never needs to hit a limit to learn about it. The documented headers are:

#The 429 response

When you cross a limit Discord answers 429 with both a Retry-After header and a JSON body. The documented example body is:

{
  "message": "You are being rate limited.",
  "retry_after": 64.57,
  "global": false
}

The retry_after field is the number of seconds to wait, and it is fractional. The documentation tells you to rely on the Retry-After header or the retry_after field to decide when to retry, which is worth taking literally: exponential backoff invented by your client will either wait far longer than the 64.57 seconds you were told, or far less, and the second failure mode is expensive.

It is expensive because of the invalid request limit. IP addresses that make too many invalid HTTP requests are temporarily blocked from the API, and the documented threshold is 10,000 per 10 minutes. Invalid means a 401, a 403 or a 429. A retry loop that ignores retry_after generates 429s, and 429s count toward the ban. The one carve-out is that 429s carrying X-RateLimit-Scope: shared are excluded from the count. A block from this rule hits the whole IP, so it takes down every integration on that host along with the one that misbehaved.

One more failure worth handling separately: if a webhook returns 404 the documentation says not to try it again, because repeated attempts result in a temporary restriction. A 404 means the webhook was deleted, which is a permanent condition. Retrying it is the fastest way to earn a block for a webhook that will never work again.

#A sender that respects the limits

The shape that works is a queue keyed by bucket, with a scheduler that only ever holds one in-flight request per bucket.

send(webhookId, payload):
    queue[webhookId].push(payload)

worker(webhookId):
    loop:
        payload = queue[webhookId].pop()
        bucket  = bucketFor(webhookId)          # from X-RateLimit-Bucket
        if bucket.remaining == 0:
            sleep(bucket.resetAfter)            # X-RateLimit-Reset-After
        acquire(globalTokenBucket)              # 50 per second, per IP
        res = POST /webhooks/{webhookId}/{token}  payload

        if res.status == 429:
            wait = res.headers["Retry-After"]   # or body.retry_after
            if res.headers["X-RateLimit-Scope"] == "global":
                pauseAllWorkers(wait)
            else:
                sleep(wait)
            queue[webhookId].pushFront(payload) # retry the same payload
            continue

        if res.status == 404:
            markWebhookDead(webhookId)          # never retry
            drainQueue(webhookId)
            continue

        bucket.update(res.headers)              # limit, remaining, resetAfter, bucket

Three details carry most of the value. Key the limiter on X-RateLimit-Bucket plus the webhook ID, never on the bare URL, because Discord reserves the right to put several routes in one bucket and only that header tells you when it has. Distinguish a global scope 429 from the others, because a global hit means every worker on the host must pause, including the ones that never saw the response. And make retries idempotent, since a request that times out may well have been delivered; carrying a deduplication key through the retry is what keeps a rate limit incident from turning into a double-post incident.

For bursts, add backpressure and keep concurrency at one request per bucket. A launch announcement fanned out to hundreds of webhooks will sit in the queue for a while at 50 requests per second, and that is fine. The failure case is a for-loop of concurrent fetches, which will spend the burst generating 429s and pushing you toward the invalid request threshold.

#The numbers are undated on purpose

Discord's rate limit page carries no changelog and no version stamp. Where it gives a number it hedges the tense, as in the invalid request limit, which reads "currently, this limit is 10,000 per 10 minutes". That phrasing is a deliberate warning. Any third-party figure you find for a per-webhook message rate is somebody's measurement from an unstated date, and a sender built on it breaks quietly when Discord moves the value, while a sender that reads the headers picks up the change on its next response.