Rate-Limited HTTP Calls
A workflow that makes an HTTP call no more often than once every intervalMs, across every concurrent run. Each caller advances a shared “next available at” timestamp by the interval, waits until its reserved slot, then makes the call. Two callers that fire at the same time get sequential slots, not simultaneous ones.
This is the timestamp-ticket variant of rate limiting — different from a token bucket, which gives out a fixed pool of tokens that refill. Use this one when you want calls to space out evenly rather than burst-then-refill.
Workflow JSON
Section titled “Workflow JSON”Copy this and paste it into a new workflow with Cmd+V:
{ "name": "rate-limit-api-calls", "description": "Throttle calls to an external API across concurrent workflows: at most one call every intervalMs to a given key, regardless of how many runs are firing. Each caller atomically reserves a slot, waits until that slot, then makes the call.", "initial": { "rateLimitKey": "jsonplaceholder-todos", "intervalMs": 3000, "todoId": 1 }, "steps": [ { "stepId": "seed-slot", "stepType": "data-store.atomic-upsert", "input": { "tableName": "rate_limits", "key": "{{ initial.rateLimitKey }}", "insertValue": { "nextAvailableAt": "{{ $util.timestamp }}" }, "set": [] } }, { "stepId": "reset-if-stale", "stepType": "data-store.atomic-update", "input": { "tableName": "rate_limits", "key": "{{ initial.rateLimitKey }}", "where": [{ "field": "nextAvailableAt", "operator": "lt", "value": "{{ $util.timestamp }}" }], "set": [{ "field": "nextAvailableAt", "operation": "set", "value": "{{ $util.timestamp | plus: initial.intervalMs }}" }] } }, { "stepId": "branch-on-reset", "stepType": "core.if", "input": { "condition": { "==": [{ "var": "reset-if-stale.updated" }, true] }, "then": [ { "stepId": "stale-no-wait", "stepType": "core.log", "input": { "message": "Slot was idle long enough to reset; proceeding immediately." } } ], "else": [ { "stepId": "advance-slot", "stepType": "data-store.atomic-update", "input": { "tableName": "rate_limits", "key": "{{ initial.rateLimitKey }}", "set": [{ "field": "nextAvailableAt", "operation": "increment", "value": "{{ initial.intervalMs }}" }] } }, { "stepId": "wait-for-slot", "stepType": "core.wait", "input": { "durationMilliseconds": "{{ advance-slot.value.nextAvailableAt | minus: $util.timestamp | minus: initial.intervalMs }}" } } ] } }, { "stepId": "make-api-call", "stepType": "core.http", "input": { "url": "https://jsonplaceholder.typicode.com/todos/{{ initial.todoId }}", "method": "GET" } } ]}Connections needed: none — the example calls jsonplaceholder.typicode.com, which is unauthenticated. Replace the make-api-call step’s URL with the API you actually want to throttle.
Trigger: anything that should call the rate-limited API — webhook, schedule, manual run.
Table: a rate_limits table will be created on first run. One row per rateLimitKey; the value holds the next-available timestamp.
How it works
Section titled “How it works”Four steps:
- seed-slot —
atomic-upsertensures the rate-limit row exists.insertValuesetsnextAvailableAtto “now” so the first caller in a new bucket proceeds immediately.set: []makes this a no-op when the row already exists. - reset-if-stale —
atomic-updateguarded bynextAvailableAt lt now. If the bucket has been idle long enough that the stored slot is in the past, advance it tonow + intervalin one operation. This handles bursts after long idle periods without giving away free calls. - branch-on-reset — if the reset fired, proceed without waiting. Otherwise, advance-slot atomically increments
nextAvailableAtbyintervalMs(reserving this caller’s slot) and wait-for-slot sleeps until that slot arrives. - make-api-call — the actual external call.
The reserve-then-wait pattern means each caller knows its own slot before it starts waiting; no polling. Storing the timestamp as a number ({{ $util.timestamp }}, Unix ms) rather than an ISO string ({{ $util.now }}) is deliberate — increment operates on numeric values.
What to customize first
Section titled “What to customize first”- Replace the HTTP target. Swap the
make-api-callURL and method for the API you’re throttling. Add headers and a body if needed. - Tune the interval.
intervalMs: 3000is one call every 3 seconds. For a 60-calls-per-minute limit, use1000. For 10 calls per second, use100. - Use per-endpoint keys. Set
rateLimitKeyto something likestripe-create-chargeorsalesforce-soap-apiso each downstream API has its own counter. Different keys are independent. - Add a back-off step on errors. If the HTTP step returns a 429 or 5xx, the rate limit isn’t the bottleneck — the API is. Add a
core.ifafter the call to detect that and back off harder.
Related recipes
Section titled “Related recipes”- Idempotency - Process Event Once — for deduping rather than throttling
- Async Queue Dispatcher — for when you want producers to enqueue and a separate dispatcher to drain at a controlled rate