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
| Endpoint | What it does |
|---|---|
GET /tag-policies | List your policies |
POST /tag-policies | Create 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"}
| Field | Type | Default | Description |
|---|---|---|---|
id | string (UUID) | Identifies the policy in PUT and DELETE. | |
projectId | string | Your project. | |
tag | string | Required | The tag the policy applies to: 1 to 50 characters from lowercase a-z, digits, _ and -. |
throughput | object or null | null (no limit) | Map of time window to rate, such as {"10s": 2}. See Throughput. |
maxConcurrent | integer or null | null (unlimited) | At least 1. See Concurrency. |
requestTimeoutSeconds | integer or null | null (300 s) | 1 to 3600. See Request timeout. |
retryStrategy | string | "none" | "none", "fixed" or "exponential". See Retries. |
retryInitialDelaySeconds | integer or null | null | 0 to 86400. Required with "fixed" and "exponential". |
retryMaxDelaySeconds | integer or null | null (no cap) | 0 to 86400. Caps exponential delays. |
retryMaxAttempts | integer or null | null (unlimited) | At least 1. Counts every attempt, including the first. |
retryJitter | boolean | false | Randomize each retry delay. |
retryOn | string | "5xx_and_network" | "network_only", "5xx_and_network" or "non_2xx". |
createdAt, updatedAt | string (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/declareand/bulkaccept other tags. - Integer fields are rounded down when stored (
2.7becomes2). The range checks apply to the value you send, so"maxConcurrent": 0.5is 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.
nullclearsthroughput,maxConcurrent,requestTimeoutSeconds,retryMaxAttempts,retryInitialDelaySecondsandretryMaxDelaySeconds."throughput": {}also clears the throughput limit.tag,retryStrategy,retryJitterandretryOncan't benull(400with an empty body). To turn retries off, send"retryStrategy": "none".tagrenames 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 withoutretryInitialDelaySecondsonly if the policy already has one, and you can't clear the delay while the strategy is"fixed"or"exponential". Both fail withretryInitialDelaySeconds is required (≥0) when retryStrategy is 'fixed' or 'exponential'. - No minimum. Unlike
POST,PUTmay clear every setting, which leaves a policy that has no effect.{}is accepted too: it only updatesupdatedAt. 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 code | Message | When |
|---|---|---|
| 200OK | Success. 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 Request | See below | A value is out of range. |
| 401Unauthorized | Invalid api key | The key in ttr-api-keyheader or Authorization: Bearer doesn't exist. |
| 401Unauthorized | Unauthorized | No API key, an empty one, or an Authorization header that isn't Bearer, and no dashboard session. |
| 404Not Found | Tag policy not found | PUT or DELETE with an id that isn't a policy in your project. |
| 415 | Unsupported 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:
| Message | Cause |
|---|---|
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 specified | POST 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 ≥ 0 | A negative rate. |
Effective window for '<window>' at rate <rate> exceeds the 30-day maximum | The 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:
| Rate | Limit | Examples |
|---|---|---|
| 1 or more | Up 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 1 | One request per window ÷ rate, which spaces requests evenly. | {"1s": 0.2}: one every 5 seconds. {"1m": 0.5}: one every 2 minutes. |
| 0 | None: 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
maxConcurrentin 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
retryOnvalue 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 tags | Effective timeout |
|---|---|
| 10 s and 20 s | 10 s |
| 20 s, and a tag whose policy sets no timeout | 20 s |
| 600 s | 600 s |
| 600 s and 1 s | 1 s |
| No tag sets one, or no tags | 300 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"}'
| Field | Description |
|---|---|
retryStrategy | "none" (default): no retries. "fixed": the same delay every time. "exponential": the delay doubles after each failed attempt. |
retryInitialDelaySeconds | Required 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. |
retryMaxDelaySeconds | Optional, 0 to 86400. Caps exponential delays; "fixed" ignores it. null means no cap. |
retryMaxAttempts | The 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. |
retryJitter | true multiplies each delay by a random factor between 0.5 and 1.5, so retries of many triggers don't arrive together. |
retryOn | Which 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:
retryOn | Retries |
|---|---|
network_only | Network 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_2xx | Every 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 atretryMaxDelaySecondsif 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.
| Settings | Delays |
|---|---|
exponential, initial 1, cap 3 | 1, 2, 3, 3, ... seconds |
exponential, initial 10, no cap | 10, 20, 40, 80, ... seconds |
fixed, delay 4, jitter | Each 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 becomescompleted. - 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.
| Setting | How the policies combine |
|---|---|
throughput | Every 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. |
maxConcurrent | Every tag must have a free slot. A running request takes a slot on each tag. |
requestTimeoutSeconds | The shortest value among the tags that set one; 300 seconds if none does. |
| Retry fields | See 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 hasnull.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_onlyif any tag has it, otherwise5xx_and_networkif any tag has it, otherwisenon_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, orretryingif 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:
| Action | Result |
|---|---|
DELETE /cancel on a queued trigger | 410 Job is no longer cancellable (status=queued). It still fires. |
DELETE /cancel on a retrying trigger | 204: cancelled, the retry never fires. |
/bulk cancel by tag | Cancels 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. |
/declare | Cancels 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-idheader | 410 Job is no longer in registered state. |
/schedule with the ttr-custom-keyheader of a queued trigger | Creates a separate new trigger; the waiting one still fires. |
/schedule with the ttr-custom-keyheader of a retrying trigger | Updates 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 key | Its 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.