Recurring triggers
Set ttr-scheduled-atheader to a cron(...) value and /schedule creates a recurring trigger instead of a one-shot trigger. The API calls it a generator: it fires your request at every time that matches the cron expression, until you cancel it.
curl -X POST https://api.timetriggers.io/schedule \-H "ttr-api-key: YOUR_API_KEY" \-H "ttr-url: https://example.com/webhooks/daily-report" \-H "ttr-scheduled-at: cron(0 9 * * 1-5, Europe/Paris)" \-H "ttr-custom-key: daily-report" \-H "Content-Type: application/json" \-d '{"report": "daily"}'
This sends a POST with that body to https://example.com/webhooks/daily-report every weekday at 09:00 Paris time. The method, forwarded headers, body, ttr-custom-keyheader and ttr-tagsheader work as for a one-shot trigger — see Schedule a trigger. ttr-titleheader and ttr-run-missedheader have no effect on recurring triggers.
Response
{"triggerId": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f","scheduledAt": "2030-01-07T08:00:00.000Z","operation": "schedule","kind": "generator","monthQuotaRemaining": 499}
| Field | Description |
|---|---|
triggerId | The ID of the recurring trigger (the generator), not of a single run. Send it in ttr-trigger-idheader to edit or cancel the recurring trigger. |
scheduledAt | When the next run fires: the first run of a new recurring trigger, or the next run under the new settings after an edit. |
operation | schedule for a new recurring trigger, reschedule when you updated an existing one. |
kind | Always generator for a cron(...) value (job is a one-shot trigger). |
monthQuotaRemaining | Quota left this month. Each /schedule call costs one unit, edits included. The runs themselves are free and keep firing after you run out. See Quota. |
Store the triggerId or use a custom key: the cron expression and time zone can't be read back later.
Cron syntax
The full value is cron(<expression>[, <timezone>]):
| Header value | Fires |
|---|---|
cron(0 9 * * 1-5) | Weekdays at 09:00 UTC |
cron(0 9 * * 1-5, Europe/Paris) | Weekdays at 09:00 Paris time |
cron(*/15 * * * *) | Every 15 minutes |
cron(0 9 1,15 * *, Europe/Paris) | The 1st and 15th of each month at 09:00 Paris time |
cron(0 0 1 * *) | The first of every month at midnight UTC |
cron(0 17 * * 5L, America/New_York) | The last Friday of each month at 17:00 New York time |
cron(@daily) | Every day at midnight UTC |
The same values work in the scheduledAt field of /declare and /bulk items.
The cron(...) value
- Write
cronin lowercase, directly followed by(, and end the value with). Anything else, such asCRON(0 9 * * *)orcron (0 9 * * *), is read as a date and rejected with400Invalid date: .... - The time zone is optional and goes after the last comma. Commas inside the expression are fine:
cron(0 9 1,15 * *, Europe/Paris). Don't put spaces inside a list:1, 15is rejected. - Spaces just inside the parentheses and around the time-zone comma are ignored.
- Date operations such as
| add 1hcan't be combined withcron(...). - An invalid expression returns
400with the messageInvalid cron expression: <details>(see Errors). A rejected request costs no quota.
Fields
Use 5 fields, or 6 with a leading seconds field:
| Field | Values | Notes |
|---|---|---|
| second | 0-59 | 6-field form only, where it comes first. With 5 fields, runs fire at second 0. |
| minute | 0-59 | |
| hour | 0-23 | |
| day of month | 1-31 | L is the last day of the month. |
| month | 1-12 or JAN-DEC | |
| day of week | 0-7 or SUN-SAT | 0 and 7 are both Sunday. |
With 6 fields, every field moves one position to the right: 0 0 9 * * * is 09:00:00 every day, while 0 9 * * * * is minute 9 of every hour. Expressions with 7 or more fields, for example with a year, are rejected.
Always write at least 5 fields. Shorter expressions are not always rejected. When they are accepted, the missing fields are filled in on the left, so your values land in other fields: cron(0 9 * *) doesn't mean 09:00 every day; it fires again and again during the minute after midnight UTC on the 9th of each month.
Special characters
| Character | Meaning | Example |
|---|---|---|
* | Any value | * * * * *: every minute |
? | Same as *, in any field | 0 9 ? * 1: Mondays at 09:00 |
, | List | 0 9 1,15 * *: the 1st and 15th at 09:00 |
- | Range | 0 9 * * 1-5: weekdays at 09:00 |
/ | Step | */15 * * * *: every 15 minutes. 0 8-18/2 * * *: every 2 hours from 08:00 to 18:00 |
L | Day of month: the last day of the month. Day of week: nL is the last weekday n of the month | 0 0 L * *: the last day of each month. 0 17 * * 5L: the last Friday |
# | Day of week only: n#k is the k-th weekday n of the month, with k from 1 to 5 | 0 9 * * 1#1: the first Monday |
- Month and weekday names are three-letter English abbreviations, in any case:
jan,JUL,mon-fri,FRI#2,FRIL. Full names such asMONDAYare rejected. - Ranges must go upward (
5-1is rejected), a list can't repeat a value, and a step can't be0. #can't be combined with a list, range or step in the same field. In the day-of-week field,Lneeds a weekday before it: a bareLis rejected.- In the day-of-month field, use a plain
L.L-2andLWare rejected, and5Lmeans the same asL.
Day of month and day of week
When both the day-of-month and day-of-week fields are restricted, a day matches if either one matches. 0 0 13 * 5 fires on every 13th and on every Friday.
A field counts as unrestricted only when it is exactly * or ?. So 0 0 1-31 * 1 fires every day, not only on Mondays.
Aliases
Instead of an expression, you can write one of these aliases, optionally with a time zone (cron(@hourly, Europe/Paris)):
| Alias | Same as | Fires |
|---|---|---|
@yearly, @annually | 0 0 1 1 * | January 1 at 00:00 |
@monthly | 0 0 1 * * | The 1st of each month at 00:00 |
@weekly | 0 0 * * 0 | Sundays at 00:00 |
@daily | 0 0 * * * | Every day at 00:00 |
@hourly | 0 * * * * | Every hour on the hour |
@weekdays | 0 0 * * 1-5 | Monday to Friday at 00:00 |
@weekends | 0 0 * * 0,6 | Saturday and Sunday at 00:00 |
@minutely | * * * * * | Every minute |
@secondly | * * * * * * | Every second in theory; see skipped ticks |
Aliases must be lowercase: @DAILY is rejected.
Not supported
W(nearest weekday), for example15W.- A year field.
@rebootand@midnight.H(hashed values). It's accepted, but a new random value is picked for every run and on every update, soH * * * *fires at irregular times, often more than once an hour. Write explicit values instead, such as17 * * * *.
Good to know
An impossible date in a single month is rejected (0 0 31 2 *), and 0 0 29 2 * fires only in leap years. With several months listed, an impossible date isn't always caught: 0 0 31 4,6 * is accepted and gets a meaningless schedule. Check the scheduledAt of the response.
Time zones
The time zone is optional and defaults to UTC, which has no daylight saving time. Runs follow the local wall-clock time of the zone you give: cron(0 9 * * *, Europe/Paris) fires at 09:00 Paris time all year, which is 07:00 UTC in summer and 08:00 UTC in winter.
- Use an IANA time zone name such as
Europe/Paris,America/New_YorkorAsia/Tokyo. - Avoid abbreviations. Some, such as
CEST, are rejected. Others, such asEST,CETorBST, are accepted but may not mean what you expect:BSTis not British Summer Time. - For a fixed whole-hour UTC offset with no daylight saving time, use an
Etc/GMTzone. Their sign is inverted:Etc/GMT-2is UTC+2. For other offsets, use the IANA name of a place that keeps that offset all year, such asAsia/Kolkatafor UTC+5:30. - An unknown time zone is rejected with
400. The error message may not mention the time zone, so check its spelling when an expression that looks valid is rejected.
Daylight saving time
What happens when the clocks change depends on the hour field.
A specific hour, list, range or step (30 2 * * *, 0 9 * * 1-5, */15 2 * * *, 0 */2 * * *) follows the wall clock:
- Clocks go forward. A time that doesn't exist that day fires one hour later. In Paris on 28 March 2027,
cron(30 2 * * *, Europe/Paris)fires at 03:30. If several matching times fall in the skipped hour, only the first one runs:cron(*/30 2 * * *, Europe/Paris)runs once that day, at 03:00. - Clocks go back. A time in the repeated hour runs once, during the first pass (summer time). In Paris on 25 October 2026,
cron(30 2 * * *, Europe/Paris)runs at 02:30 summer time and not again at 02:30 winter time. Because the second pass is skipped,cron(0 */2 * * *, Europe/Paris)goes from 02:00 summer time to 04:00 winter time that day, three hours later.
Every hour (*, */1 or 0-23 in the hour field, as in 0 * * * * or */30 * * * *) follows elapsed time:
- Clocks go forward. The skipped hour has no runs, and nothing is shifted.
- Clocks go back. Both passes of the repeated hour run:
cron(0 * * * *, Europe/Paris)fires at 02:00 summer time and again at 02:00 winter time.
A recurring trigger that is created or edited during the second pass of the repeated hour can also run during that second pass. So can one whose first-pass run comes due during a service interruption that lasts into the second pass.
How instances are created
Each run of a recurring trigger is an instance: an ordinary trigger with its own ID, listed in the dashboard like a one-shot trigger. Instances have no custom key or title of their own; the custom key belongs to the recurring trigger.
- First instance.
/schedulecreates it right away, at the next matching time after your request. That time is thescheduledAtof the response. Recurring triggers created through/declareor/bulkget their first instance within about 5 seconds instead. - Next instances. When an instance's time comes and it is queued to fire, the next instance is created within a few seconds: a background process checks about every 5 seconds. It doesn't wait for the previous run to be sent or to finish.
- One upcoming instance. A recurring trigger has at most one instance that isn't due yet. For a few seconds after each run comes due, it has none.
- Snapshot. Each instance copies the recurring trigger's URL, method, headers, body and tags when it is created. Later changes never rewrite an existing instance; see Editing a recurring trigger.
What happens once an instance is due (statuses, success and failure, retries) is the same as for any trigger: see How triggers fire.
Skipped ticks
A recurring trigger produces at most one instance per matching time, not exactly one. Each new instance gets the next matching time after the moment it is created, and missed times are never caught up:
- Very frequent schedules. Times less than about 5 seconds apart can't all run. Six-field expressions with seconds are accepted, but
cron(* * * * * *)fires roughly every 5 seconds, not every second. Schedules with runs about 10 seconds or more apart normally get every run. - Service interruptions. Times missed while the service is unavailable are skipped. The instance that was already scheduled fires once, late, and the trigger then continues from the next future time.
Tag policies and backlogs
Instances carry the recurring trigger's tags, so tag policies apply to every run, as they do to one-shot triggers.
Instances are never merged. When a run is held back by a throughput or concurrency limit, it waits with the status queued (see Trigger statuses), and new instances keep coming on schedule. If the schedule fires faster than the policy lets runs through, the backlog keeps growing.
- Held-back runs fire oldest first, at the pace the policy allows: at the policy's rate with a throughput limit, one after another as slots free up with a concurrency limit. A higher limit drains the backlog faster, at the new pace.
- If you delete the policy, or raise its limit far above the size of the backlog, the whole backlog fires almost at once.
- Without a policy, a slow target doesn't delay the next run. Runs overlap and several requests can be in flight at the same time. To run them one after another, set
maxConcurrent: 1on a tag the recurring trigger carries. - Editing the recurring trigger, or cancelling it with
/cancel, leaves instances that are already queued in place. They still fire with their original data. Cancelling a recurring trigger shows how to drop them.
Editing a recurring trigger
Call /schedule again with a cron(...) value in ttr-scheduled-atheader, and identify the recurring trigger with one of these headers:
ttr-trigger-idheader: the generator ID, which is thetriggerIdreturned when you created it.ttr-custom-keyheader: its custom key. If no active recurring trigger has that key, a new one is created ("operation": "schedule").
curl -X POST https://api.timetriggers.io/schedule \-H "ttr-api-key: YOUR_API_KEY" \-H "ttr-trigger-id: 3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f" \-H "ttr-url: https://example.com/webhooks/daily-report" \-H "ttr-scheduled-at: cron(0 10 * * 1-5, Europe/Paris)" \-H "Content-Type: application/json" \-d '{"report": "daily"}'
The response has the same triggerId, "operation": "reschedule" and "kind": "generator". An edit replaces the whole recurring trigger, so send the full request every time, including the time zone and ttr-tagsheader. Edit a recurring trigger lists what is replaced, and Status codes covers the 404 and 410 errors.
What happens to the instances:
- The upcoming instance is replaced. The instance that isn't due yet is cancelled (it stays in the history) and a new instance with a new ID is created right away from the new settings. Its time is the
scheduledAtof the response. - Other instances keep their data. Instances that are already queued, running, waiting to retry, or finished keep their original URL, headers, body and tags. Queued and retrying ones still fire with that data.
Good to know
- Use a
cron(...)value. With a date, or withoutttr-scheduled-atheader, an edit by custom key replaces the recurring trigger with a one-shot trigger, and an edit by ID fails with404. See Edit a recurring trigger. - Send only one identifier, and never the ID of a single instance. See Sending both identifiers and the note on instances in Edit a recurring trigger.
- Other endpoints handle the upcoming instance differently.
/declarereplaces it within about 5 seconds instead of right away./bulkdoesn't replace it, so the new settings apply from the run after it.
Cancelling a recurring trigger
Send DELETE /cancel with the generator ID in ttr-trigger-idheader or the custom key in ttr-custom-keyheader:
curl -X DELETE https://api.timetriggers.io/cancel \-H "ttr-api-key: YOUR_API_KEY" \-H "ttr-custom-key: daily-report"
A 204 means the recurring trigger is cancelled: it creates no new instances, and its upcoming instance is cancelled too.
- Queued and retrying instances still fire. Instances whose time has already come, for example ones held back by a tag policy, and instances waiting for a retry aren't cancelled. To stop a retrying instance, cancel it with its own ID in
ttr-trigger-idheader. - To drop the backlog too, give the recurring trigger a tag of its own and cancel by that tag with
/bulk({"cancels": [{"tag": "daily-report"}]}). That cancels the recurring trigger and its queued and retrying instances, along with every other trigger that carries the tag. A recurring trigger that/declarecancels loses its queued instances too. - Cancelling the upcoming instance doesn't skip a run. If you cancel the upcoming instance by its own ID, a new instance for the same time is created within seconds. Edit the schedule to change which times run, or cancel the recurring trigger to stop it.
Cancelling a recurring trigger has the full details, and Cancelling again explains what a repeated cancel returns.