Skip to content

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.

Copy this and paste it into a new workflow with Cmd+V:

rate-limit-api-calls.json
{
"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.

Four steps:

  1. seed-slotatomic-upsert ensures the rate-limit row exists. insertValue sets nextAvailableAt to “now” so the first caller in a new bucket proceeds immediately. set: [] makes this a no-op when the row already exists.
  2. reset-if-staleatomic-update guarded by nextAvailableAt lt now. If the bucket has been idle long enough that the stored slot is in the past, advance it to now + interval in one operation. This handles bursts after long idle periods without giving away free calls.
  3. branch-on-reset — if the reset fired, proceed without waiting. Otherwise, advance-slot atomically increments nextAvailableAt by intervalMs (reserving this caller’s slot) and wait-for-slot sleeps until that slot arrives.
  4. 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.

  • Replace the HTTP target. Swap the make-api-call URL and method for the API you’re throttling. Add headers and a body if needed.
  • Tune the interval. intervalMs: 3000 is one call every 3 seconds. For a 60-calls-per-minute limit, use 1000. For 10 calls per second, use 100.
  • Use per-endpoint keys. Set rateLimitKey to something like stripe-create-charge or salesforce-soap-api so 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.if after the call to detect that and back off harder.