Tag policies

A tag policy sets rules for every trigger that carries a tag:

  • Throughput — how many requests may fire per time window.
  • Concurrency — how many requests may be in flight at the same time.
  • Request timeout — how long to wait for your target to answer.
  • Retries — whether and when to try a failed request again.

Without a policy, a trigger fires as soon as it's due, waits up to 300 seconds for a response and isn't retried.

Create a policy for the tag emails:

curl -X POST https://api.timetriggers.io/tag-policies \
-H "ttr-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "emails",
"throughput": {"1s": 5},
"maxConcurrent": 2,
"retryStrategy": "exponential",
"retryInitialDelaySeconds": 10,
"retryMaxAttempts": 5
}'

Then schedule triggers with emails in ttr-tagsheader. They fire at most 5 per second with at most 2 requests in flight, and a network error or 5xx response is retried after about 10, 20, 40 and 80 seconds, for 5 attempts in total.

Policies are checked each time a trigger is about to fire, not when it's scheduled. A new, changed or deleted policy therefore also applies to triggers you scheduled earlier, including ones already waiting on a limit, usually within a second.

Endpoints

EndpointWhat it does
GET /tag-policiesList your policies
POST /tag-policiesCreate a policy, or replace the policy of that tag
PUT /tag-policies/{id}Change some fields of a policy
DELETE /tag-policies/{id}Delete a policy

Authenticate with ttr-api-keyheader or Authorization: Bearer YOUR_API_KEY; see Authentication. POST and PUT take a JSON body: send Content-Type: application/json.

Policies belong to your project, and each tag has at most one policy. Any API key of the project can manage them, and the dashboard edits the same policies.

The policy object

{
"id": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f",
"projectId": "9b1d6f0a-2c3e-4a5b-8d7f-6e5c4b3a2f10",
"tag": "emails",
"throughput": {"1s": 5},
"maxConcurrent": 2,
"requestTimeoutSeconds": null,
"retryMaxAttempts": 5,
"retryStrategy": "exponential",
"retryInitialDelaySeconds": 10,
"retryMaxDelaySeconds": null,
"retryJitter": false,
"retryOn": "5xx_and_network",
"createdAt": "2026-10-01T08:30:00.000Z",
"updatedAt": "2026-10-01T08:30:00.000Z"
}
FieldTypeDefaultDescription
idstring (UUID)Identifies the policy in PUT and DELETE.
projectIdstringYour project.
tagstringRequiredThe tag the policy applies to: 1 to 50 characters from lowercase a-z, digits, _ and -.
throughputobject or nullnull (no limit)Map of time window to rate, such as {"10s": 2}. See Throughput.
maxConcurrentinteger or nullnull (unlimited)At least 1. See Concurrency.
requestTimeoutSecondsinteger or nullnull (300 s)1 to 3600. See Request timeout.
retryStrategystring"none""none", "fixed" or "exponential". See Retries.
retryInitialDelaySecondsinteger or nullnull0 to 86400. Required with "fixed" and "exponential".
retryMaxDelaySecondsinteger or nullnull (no cap)0 to 86400. Caps exponential delays.
retryMaxAttemptsinteger or nullnull (unlimited)At least 1. Counts every attempt, including the first.
retryJitterbooleanfalseRandomize each retry delay.
retryOnstring"5xx_and_network""network_only", "5xx_and_network" or "non_2xx".
createdAt, updatedAtstring (ISO 8601)When the policy was created and last changed.

Good to know

  • Tags are case-sensitive and aren't lowercased for you: "tag": "Emails" is rejected. Only tags in this format can get a policy, even though /declare and /bulk accept other tags.
  • Integer fields are rounded down when stored (2.7 becomes 2). The range checks apply to the value you send, so "maxConcurrent": 0.5 is rejected. Throughput rates keep their decimals.
  • Unknown fields in the body are ignored.

List policies

curl https://api.timetriggers.io/tag-policies \
-H "Authorization: Bearer YOUR_API_KEY"

It returns 200 with {"tagPolicies": [...]}, a list of policy objects. The list holds every policy in your project, newest first by createdAt. There's no pagination, and query parameters such as ?limit= are ignored. A project without policies gets {"tagPolicies": []}.

Create or replace a policy

POST /tag-policies takes tag and any of the other fields of the policy object, and returns 200 with {"tagPolicy": {...}}.

The policy must set at least one of: a non-empty throughput, maxConcurrent, requestTimeoutSeconds, or a retryStrategy of "fixed" or "exponential". retryMaxAttempts, retryJitter and retryOn don't count on their own, and neither do "throughput": {} or "retryStrategy": "none". Otherwise you get 400 At least one of throughput, concurrency, timeout, or retry strategy must be specified.

If the tag already has a policy, POST replaces it. The policy keeps its id and createdAt (and so its place in the list), updatedAt changes, and every field you leave out goes back to its default. The response is 200 either way; on a replace, createdAt is earlier than updatedAt.

POST doesn't merge. If emails has "maxConcurrent": 2 and you send {"tag": "emails", "requestTimeoutSeconds": 30}, the policy ends up with a 30-second timeout and no concurrency limit. To change some fields only, use PUT.

A request that fails validation changes nothing, so an existing policy for the tag stays as it was.

Update a policy

PUT /tag-policies/{id} changes only the fields you send and returns 200 with the whole updated policy as {"tagPolicy": {...}}:

curl -X PUT https://api.timetriggers.io/tag-policies/3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f \
-H "ttr-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"maxConcurrent": null, "requestTimeoutSeconds": 30}'

This removes the concurrency limit, sets a 30-second timeout and keeps every other setting.

  • Omitted fields keep their current values.
  • null clears throughput, maxConcurrent, requestTimeoutSeconds, retryMaxAttempts, retryInitialDelaySeconds and retryMaxDelaySeconds. "throughput": {} also clears the throughput limit.
  • tag, retryStrategy, retryJitter and retryOn can't be null (400 with an empty body). To turn retries off, send "retryStrategy": "none".
  • tag renames the policy, so it applies to another tag from then on. The new tag must follow the same format and must not have a policy yet.
  • Retry rules apply to the result. Switching to "fixed" or "exponential" works without retryInitialDelaySeconds only if the policy already has one, and you can't clear the delay while the strategy is "fixed" or "exponential". Both fail with retryInitialDelaySeconds is required (≥0) when retryStrategy is 'fixed' or 'exponential'.
  • No minimum. Unlike POST, PUT may clear every setting, which leaves a policy that has no effect. {} is accepted too: it only updates updatedAt. A missing body isn't: send at least {}.

An id that doesn't belong to a policy in your project returns 404 Tag policy not found.

Delete a policy

curl -X DELETE https://api.timetriggers.io/tag-policies/3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f \
-H "ttr-api-key: YOUR_API_KEY"

Returns 200 with {"ok": true}. Deleting the same id again returns 404 Tag policy not found.

Triggers keep their tag; they just stop getting the policy's limits, timeout and retries from the next attempt on. Triggers that were waiting on its limits fire within about a second, unless another tag's policy still holds them.

Errors

Status codeMessageWhen
200OKSuccess. DELETE returns {"ok": true}, the others the policy or the list.
400Bad Request(empty body)The body isn't valid JSON or doesn't match the format: it's missing, tag is missing on POST, a field has the wrong type (such as "maxConcurrent": "2"), a non-nullable field is null, a retryStrategy or retryOn value is unknown, or a throughput window is malformed.
400Bad RequestSee belowA value is out of range.
401UnauthorizedInvalid api keyThe key in ttr-api-keyheader or Authorization: Bearer doesn't exist.
401UnauthorizedUnauthorizedNo API key, an empty one, or an Authorization header that isn't Bearer, and no dashboard session.
404Not FoundTag policy not foundPUT or DELETE with an id that isn't a policy in your project.
415Unsupported content-type: <type>The Content-Type isn't application/json. curl's -d sends application/x-www-form-urlencoded unless you set it. The body is plain text.

Errors with a message have a JSON body such as {"_tag": "BadRequest", "message": "maxConcurrent must be ≥ 1"}; see Errors. The Content-Type and the body format are checked before your API key, so a malformed request gets 415 or 400 even without a valid key. On PUT, 404 is checked before the values in the body.

Validation messages

These 400 errors have a JSON body. Fields are checked in this order, and only the first error is returned:

MessageCause
Invalid tag (only a-z 0-9 _ - allowed, max 50 chars)tag is empty, longer than 50 characters, or has another character, such as an uppercase letter.
At least one of throughput, concurrency, timeout, or retry strategy must be specifiedPOST only: the policy would set nothing. See Create or replace a policy.
requestTimeoutSeconds must be between 1 and 3600
maxConcurrent must be ≥ 1
At most 8 throughput windows per policy
Throughput rate for '<window>' must be a finite number ≥ 0A negative rate.
Effective window for '<window>' at rate <rate> exceeds the 30-day maximumThe window, stretched for rates below 1, is longer than 30 days. For rates below 1 the message ends with (rates below 1 stretch the window by 1/rate).
retryMaxAttempts must be ≥ 1 (or null for unlimited)
retryInitialDelaySeconds is required (≥0) when retryStrategy is 'fixed' or 'exponential'The strategy is "fixed" or "exponential" and the delay is missing, null or negative.
retryMaxDelaySeconds must be ≥ 0
retryInitialDelaySeconds must be ≤ 86400 (1 day)Larger values are rejected, not capped.
retryMaxDelaySeconds must be ≤ 86400 (1 day)Larger values are rejected, not capped.

Throughput

throughput limits how often the tag's triggers fire. It maps a time window to a rate:

"throughput": {"1s": 5, "1h": 1000}

This allows at most 5 requests in any second and at most 1,000 in any hour. Every window must have room before a trigger fires.

Windows are written <n><unit>: a whole number without leading zeros and one of the units s, m, h or d, such as 10s, 2m, 1h or 30d. There's no week or month unit; use 7d or 30d. A policy has up to 8 windows.

Rates are numbers of 0 or more, and decimals are allowed:

RateLimitExamples
1 or moreUp to the rate, rounded down, in any window of that length. They may fire back to back.{"10s": 2}: 2 per 10 seconds. {"1s": 2.7}: 2 per second.
Between 0 and 1One request per window ÷ rate, which spaces requests evenly.{"1s": 0.2}: one every 5 seconds. {"1m": 0.5}: one every 2 minutes.
0None: the tag is blocked until you change or delete the policy.{"1s": 0}

The window, after stretching for rates below 1, can be at most 30 days: {"30d": 1} is accepted, while {"31d": 1} and {"1d": 0.03} (33 days) are rejected. {} or null means no throughput limit.

Windows slide. Before a trigger fires, TimeTriggers counts the tag's requests that started within the last window. These count:

  • every attempt that started, whether it's still in flight, succeeded or failed;
  • retries and dashboard replays, not only first attempts;
  • attempts made before the policy existed or changed;
  • attempts of triggers with several tags, which count toward each of their tags.

Attempts that are still waiting don't count, and neither do triggers that were cancelled before firing. Each project counts separately, even for the same tag name.

Waiting triggers are checked about once a second, so requests end up spaced by the window plus up to about a second: with {"3s": 1}, about every 3 to 4 seconds.

Limitations:

  • A throughput limit can occasionally be exceeded by a small amount when several of the tag's triggers are ready to fire at once, for example a backlog waiting on the limit, because they may be picked up in parallel. If the limit must hold exactly, also set maxConcurrent in the same policy: that makes the throughput check strict as well.

Concurrency

maxConcurrent caps how many of the tag's requests are in flight at the same time, across your whole project. null means no cap.

  • An attempt takes a slot when its request is sent and frees it when the attempt ends: with a response, a network error or a timeout.
  • Every attempt counts: first attempts, retries, dashboard replays and each run of a recurring trigger (an instance). A trigger waiting out a retry delay holds no slot.
  • The cap holds exactly, even though TimeTriggers sends requests from several workers in parallel.
  • A freed slot is refilled within about a second, oldest due trigger first.
  • Changing the value applies to the next attempts. Requests already in flight aren't interrupted.

Example: with "maxConcurrent": 2, five triggers due at the same time whose target takes 2.5 seconds to answer fire as 2, then 2, then 1.

Set "maxConcurrent": 1 to send a tag's requests strictly one after another, for example the instances of a recurring trigger whose target is slow.

Good to know

  • Cancelling a trigger doesn't stop a request that is already in flight, so that request keeps its slot until it ends.
  • If an attempt is cut off by a service interruption, its slot stays taken until the attempt is closed out, between 30 and 60 seconds after its timeout. See When a request can be lost.

Request timeout

requestTimeoutSeconds sets how long each attempt waits for your target to answer: 1 to 3600 seconds. Without one, the timeout is 300 seconds (5 minutes).

  • The timeout covers connecting, sending the request and receiving the response status and headers. Reading the response body isn't limited by it, but if the response is still arriving more than 30 seconds after the timeout, the attempt can be closed as failed and the response discarded. See Timeouts.
  • A timed-out attempt fails without a response status, as a network error, and is retried under every retryOn value if the trigger has a retry policy.
  • The timeout is fixed when an attempt starts. Changing or deleting the policy afterwards doesn't affect a request in flight; the next attempt uses the policy as it is then.

With several tags, the shortest timeout among the tags that set one wins. The 300-second default only applies when none of them does; it never takes part in the comparison, so a longer value is used as is:

Timeouts of the trigger's tagsEffective timeout
10 s and 20 s10 s
20 s, and a tag whose policy sets no timeout20 s
600 s600 s
600 s and 1 s1 s
No tag sets one, or no tags300 s

Retries

By default, a failed attempt is final and becomes a dead letter. A trigger is retried only if at least one of its tags has a policy with retryStrategy "fixed" or "exponential":

curl -X POST https://api.timetriggers.io/tag-policies \
-H "ttr-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "billing",
"retryStrategy": "exponential",
"retryInitialDelaySeconds": 30,
"retryMaxDelaySeconds": 3600,
"retryMaxAttempts": 8,
"retryJitter": true,
"retryOn": "5xx_and_network"
}'
FieldDescription
retryStrategy"none" (default): no retries. "fixed": the same delay every time. "exponential": the delay doubles after each failed attempt.
retryInitialDelaySecondsRequired with "fixed" and "exponential", 0 to 86400. The delay for "fixed", and the first delay for "exponential". 0 retries on the next check, about a second later.
retryMaxDelaySecondsOptional, 0 to 86400. Caps exponential delays; "fixed" ignores it. null means no cap.
retryMaxAttemptsThe total number of attempts, including the first: 3 means the first attempt and up to 2 retries, and 1 means no retries. null (default) means unlimited.
retryJittertrue multiplies each delay by a random factor between 0.5 and 1.5, so retries of many triggers don't arrive together.
retryOnWhich failures are retried; see below.

With "retryStrategy": "none", the other retry fields are stored but have no effect.

What is retried

Only a 2xx response is a success; see Success and failure. retryOn picks which failures get another attempt:

retryOnRetries
network_onlyNetwork errors only: no response was received, for example a refused connection, a DNS or TLS error, or a timeout.
5xx_and_network (default)Network errors and 5xx responses. 3xx and 4xx responses aren't retried.
non_2xxEvery failure, including 3xx and 4xx. Redirects aren't followed, so a 3xx is a failure too.

Delays

After attempt n fails (the first attempt is n = 1), the next attempt waits:

  • fixed: retryInitialDelaySeconds.
  • exponential: retryInitialDelaySeconds × 2n−1, capped at retryMaxDelaySeconds if set. Without a cap, the delay keeps doubling.
  • Jitter then multiplies the delay by a random factor between 0.5 and 1.5, for both strategies. Because it applies after the cap, a delay can reach 1.5 × retryMaxDelaySeconds.
  • The result is rounded to whole seconds.
SettingsDelays
exponential, initial 1, cap 31, 2, 3, 3, ... seconds
exponential, initial 10, no cap10, 20, 40, 80, ... seconds
fixed, delay 4, jitterEach between 2 and 6 seconds

The delay counts from the moment the failed attempt ended, which for a timeout is when the timeout ran out. The retry then fires on the next check, up to about a second later. The tag's throughput and concurrency limits apply to retries as well, so they can hold a retry longer.

Retry lifecycle

  • While a retry is pending, the trigger's status is retrying, including while a throughput or concurrency limit holds the retry. See Trigger statuses. You can still cancel it: the pending retry then never fires.
  • Each retry is a new attempt with the next attempt number. You see every attempt on the trigger's page in the dashboard.
  • When the retries run out, or the failure isn't retried under retryOn, the last attempt is marked as a dead letter and the trigger becomes completed.
  • Retries don't use quota. They count toward the tag's throughput and concurrency limits like any other request.
  • Settings are read per attempt. Whether a failed attempt is retried, and after which delay, depends on the policy as it was when that attempt started. A change applies from the next attempt on.
  • Each instance of a recurring trigger is a trigger of its own, with its own attempts starting at 1.
  • An attempt cut off by a service interruption is closed out as failed and isn't retried. See When a request can be lost.

Dashboard replays continue the attempt count. "Replay now" and "Retry now" in the dashboard add an attempt with the next number. If the trigger has already used all of its retryMaxAttempts, a replay that fails becomes a dead letter right away, without automatic retries. To give a replay more retries, raise retryMaxAttempts or set it to null first.

retryMaxAttempts is unlimited by default. If a policy with retries leaves it out or sets it to null, a target that keeps failing is retried until it succeeds or you cancel the trigger. With non_2xx, that includes requests that fail with a 4xx that may never go away. Set a limit unless you really want endless retries.

Multiple tags

When a trigger carries several tags, the policies of all of them apply. Tags without a policy are ignored.

SettingHow the policies combine
throughputEvery window of every tag must have room. A request counts toward every tag the trigger carries. A rate of 0 on any tag blocks the trigger.
maxConcurrentEvery tag must have a free slot. A running request takes a slot on each tag.
requestTimeoutSecondsThe shortest value among the tags that set one; 300 seconds if none does.
Retry fieldsSee below.

Retry settings merge only across tags with "fixed" or "exponential". A tag with "none" doesn't turn retries off for the trigger and contributes none of its retry fields, but its throughput, concurrency and timeout still apply. Among the tags that retry:

  • retryStrategy: "exponential" if any tag has it, otherwise "fixed".
  • retryMaxAttempts: the lowest value. The result is unlimited only if every one of these tags has null.
  • retryInitialDelaySeconds: the highest value.
  • retryMaxDelaySeconds: the highest value, including from "fixed" tags.
  • retryJitter: on if any tag turns it on.
  • retryOn: the most restrictive value: network_only if any tag has it, otherwise 5xx_and_network if any tag has it, otherwise non_2xx.

Example: tag a has fixed, delay 10, max delay 100, 5 attempts and non_2xx. Tag b has exponential, initial delay 2, max delay 50, 3 attempts, jitter and 5xx_and_network. A trigger tagged a,b gets exponential, initial delay 10, max delay 100, 3 attempts, jitter and 5xx_and_network. A 404 becomes a dead letter right away. A 500 is retried after about 10 seconds, then about 20 seconds (both with jitter), and the third failed attempt becomes a dead letter.

The merge is done again for each attempt, so it always uses the current policies.

When a limit is reached

A throughput or concurrency limit never rejects or drops a trigger, and scheduling isn't affected: /schedule still answers 200. A trigger that comes due while a limit is reached waits:

  • Its status is queued, or retrying if the waiting attempt is a retry. See Trigger statuses.
  • Waiting triggers are checked again about once a second and fire as soon as every limit on them has room, oldest due first.
  • Triggers that don't carry the limited tag aren't delayed.
  • There's no time limit. With a rate of 0, triggers wait until you change or delete the policy.
  • Instances of a recurring trigger aren't merged: new instances keep coming on schedule and wait too, so a backlog can build up.

To pause a tag, set its throughput to {"1s": 0}. When you remove that limit, everything that waited fires at the pace the remaining limits allow, or almost at once if there are none.

Stopping or changing a waiting trigger:

ActionResult
DELETE /cancel on a queued trigger410 Job is no longer cancellable (status=queued). It still fires.
DELETE /cancel on a retrying trigger204: cancelled, the retry never fires.
/bulk cancel by tagCancels the waiting triggers that carry the tag, along with every other trigger and recurring trigger with that tag that isn't running or finished. A /bulk cancel by ID or custom key skips queued triggers.
/declareCancels waiting one-shot triggers under its tag that are no longer in the list, and the queued instances of recurring triggers it cancels.
/schedule with the trigger's ttr-trigger-idheader410 Job is no longer in registered state.
/schedule with the ttr-custom-keyheader of a queued triggerCreates a separate new trigger; the waiting one still fires.
/schedule with the ttr-custom-keyheader of a retrying triggerUpdates it in place. The pending retry keeps its time and sends the new request; the new time is never used. See Triggers waiting to retry.
Cancelling a recurring trigger with DELETE /cancel, the dashboard, or a /bulk cancel by ID or custom keyIts waiting instances aren't cancelled and still fire.

In the dashboard

Tag Policies in the dashboard lists your policies and lets you add, edit and delete them through this same API, with the same rules. Add policy with a tag that already has a policy replaces that policy completely, without a warning, like POST. See Dashboard → Tag policies for how the form works.

TimeTriggers — Schedule HTTP requests at any time.