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
}
FieldDescription
triggerIdThe 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.
scheduledAtWhen the next run fires: the first run of a new recurring trigger, or the next run under the new settings after an edit.
operationschedule for a new recurring trigger, reschedule when you updated an existing one.
kindAlways generator for a cron(...) value (job is a one-shot trigger).
monthQuotaRemainingQuota 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 valueFires
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 cron in lowercase, directly followed by (, and end the value with ). Anything else, such as CRON(0 9 * * *) or cron (0 9 * * *), is read as a date and rejected with 400 Invalid 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, 15 is rejected.
  • Spaces just inside the parentheses and around the time-zone comma are ignored.
  • Date operations such as | add 1h can't be combined with cron(...).
  • An invalid expression returns 400 with the message Invalid cron expression: <details> (see Errors). A rejected request costs no quota.

Fields

Use 5 fields, or 6 with a leading seconds field:

FieldValuesNotes
second0-596-field form only, where it comes first. With 5 fields, runs fire at second 0.
minute0-59
hour0-23
day of month1-31L is the last day of the month.
month1-12 or JAN-DEC
day of week0-7 or SUN-SAT0 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

CharacterMeaningExample
*Any value* * * * *: every minute
?Same as *, in any field0 9 ? * 1: Mondays at 09:00
,List0 9 1,15 * *: the 1st and 15th at 09:00
-Range0 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
LDay of month: the last day of the month. Day of week: nL is the last weekday n of the month0 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 50 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 as MONDAY are rejected.
  • Ranges must go upward (5-1 is rejected), a list can't repeat a value, and a step can't be 0.
  • # can't be combined with a list, range or step in the same field. In the day-of-week field, L needs a weekday before it: a bare L is rejected.
  • In the day-of-month field, use a plain L. L-2 and LW are rejected, and 5L means the same as L.

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)):

AliasSame asFires
@yearly, @annually0 0 1 1 *January 1 at 00:00
@monthly0 0 1 * *The 1st of each month at 00:00
@weekly0 0 * * 0Sundays at 00:00
@daily0 0 * * *Every day at 00:00
@hourly0 * * * *Every hour on the hour
@weekdays0 0 * * 1-5Monday to Friday at 00:00
@weekends0 0 * * 0,6Saturday 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 example 15W.
  • A year field.
  • @reboot and @midnight.
  • H (hashed values). It's accepted, but a new random value is picked for every run and on every update, so H * * * * fires at irregular times, often more than once an hour. Write explicit values instead, such as 17 * * * *.

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_York or Asia/Tokyo.
  • Avoid abbreviations. Some, such as CEST, are rejected. Others, such as EST, CET or BST, are accepted but may not mean what you expect: BST is not British Summer Time.
  • For a fixed whole-hour UTC offset with no daylight saving time, use an Etc/GMT zone. Their sign is inverted: Etc/GMT-2 is UTC+2. For other offsets, use the IANA name of a place that keeps that offset all year, such as Asia/Kolkata for 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. /schedule creates it right away, at the next matching time after your request. That time is the scheduledAt of the response. Recurring triggers created through /declare or /bulk get 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: 1 on 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 the triggerId returned 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 scheduledAt of 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 without ttr-scheduled-atheader, an edit by custom key replaces the recurring trigger with a one-shot trigger, and an edit by ID fails with 404. 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. /declare replaces it within about 5 seconds instead of right away. /bulk doesn'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 /declare cancels 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.

TimeTriggers — Schedule HTTP requests at any time.