Schedule a trigger

A trigger is an HTTP request that TimeTriggers stores and sends later. To create one, send the request you want fired to https://api.timetriggers.io/schedule. Then add ttr- headers that say where to send it and when:

curl -X POST https://api.timetriggers.io/schedule \
-H "ttr-api-key: YOUR_API_KEY" \
-H "ttr-url: https://example.com/webhooks/reminder" \
-H "ttr-scheduled-at: 2030-01-01T09:00:00Z" \
-H "Content-Type: application/json" \
-d '{"reminderId": 42}'

At 09:00 UTC on 1 January 2030, https://example.com/webhooks/reminder receives a POST with this body and Content-Type.

Request headers

Only two headers are required: ttr-api-keyheader and ttr-urlheader.

HeaderExampleDescription
ttr-api-keyreq.ttr_9f86d0...Your API key. Authorization: Bearer doesn't work on /schedule. See Authentication.
ttr-urlreq.https://example.com/hook?user=42The full target URL, query string included. See Target URL.
ttr-scheduled-atopt.2030-01-01T09:00:00Z, now | add 2d, cron(0 9 * * 1-5)When to fire. Defaults to now. See When to fire.
ttr-trigger-idopt.3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6fUpdate the trigger with this ID instead of creating one. See Trigger IDs.
ttr-custom-keyopt.appointment-42-reminderYour own key for the trigger. Sending the same key again updates that trigger. See Custom trigger keys.
ttr-titleopt.Reminder for appointment 42A label shown in the dashboard. See Titles.
ttr-tagsopt.billing,emailsComma-separated tags. Tag policies attach to these. See Tags.
ttr-run-missedopt.trueFire a trigger whose time is already past, instead of skipping it. See Past-dated triggers.

Good to know

  • Header names are case-insensitive (TTR-URL works). Values are case-sensitive, so now and true must be lowercase.
  • Options can only be set as headers. Query parameters on the /schedule URL itself are ignored: they aren't stored, aren't forwarded and aren't added to ttr-urlheader.
  • ttr- headers are never forwarded to your target.

A ttr- header that isn't in this table is ignored without an error, so check your spelling:

  • A missing or misspelled ttr-scheduled-atheader means now, so the trigger fires within seconds. This also applies to edits: if you update a recurring trigger by custom key without it, a one-shot trigger that fires immediately replaces the recurring one.
  • A misspelled ttr-custom-keyheader or ttr-trigger-idheader creates a second trigger instead of updating the first.
  • A misspelled ttr-tagsheader leaves the trigger without tags, so no tag policy applies to it.

A typo that doesn't start with ttr-, such as ttrscheduled-at or x-ttr-custom-key, isn't recognized either: it's an ordinary header and gets forwarded to your target.

HTTP method

We fire the trigger with the method you used to call /schedule. Send a PUT and we fire a PUT. Send a GET and we fire a GET.

Supported methods:

  • GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS and TRACE.
  • The WebDAV and other extension methods that Node.js recognizes, such as PROPFIND, MKCOL, COPY, MOVE, LOCK, REPORT and PURGE.

Limitations:

  • Method names must be uppercase. Lowercase or custom names (patch, FOO) get a plain 400 Bad Request with no JSON body, and nothing is created.
  • CONNECT isn't supported: the connection is closed without a response and nothing is created. QUERY returns 404.
  • HEAD creates the trigger normally, but HEAD responses have no body, so you never see the triggerId or monthQuotaRemaining. Errors only come back as status codes. Set a ttr-custom-keyheader so you can refer to the trigger later, or schedule with another method.

Request body

  • GET, HEAD, OPTIONS and TRACE: no body is read or stored. If you send one anyway, it's dropped silently and the trigger fires with no body. Your other headers, Content-Type included, are still forwarded.
  • Every other method: the raw body is stored and forwarded byte for byte. An empty body counts as no body.

The Content-Typeheader you send is stored with your other headers and replayed unchanged. Schedule with application/json and the trigger fires with application/json. If you send a body without a Content-Type, it's sent as application/octet-stream, so set the header explicitly if your receiver branches on it.

Headers sent to your target

Every header on your /schedule request is stored and sent with the trigger, except:

  • ttr- headers, in any letter case, including ones TimeTriggers doesn't recognize;
  • the connection-level headers Host, Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailer, Transfer-Encoding and Upgrade.

This includes headers your HTTP client adds by itself, such as User-Agent, Accept or Cookie. It also includes proxy headers added before your request reaches TimeTriggers, such as X-Forwarded-For, X-Forwarded-Host, X-Real-IP or X-Request-ID. Your target may receive these too.

How headers arrive at your target:

  • Names are lowercase (X-Signature arrives as x-signature). Values are unchanged.
  • Repeated headers are merged into one. Most are joined with , and Cookie with ; . For single-value headers such as Authorization, Content-Type, User-Agent and Referer, only the first value is kept.
  • A Set-Cookie request header isn't sent.
  • Host comes from ttr-urlheader, and Connection is set for the request we send. Content-Length is computed from the stored body. Without a body, POST, PUT, PATCH, PROPFIND and PROPPATCH are sent with Content-Length: 0, and other methods without one.
  • Every attempt carries traceparent and b3 tracing headers with a new trace ID. If you set either one yourself, your value is replaced. Other tracing headers, such as tracestate, are passed through unchanged.

Forwarded headers are stored in plain text. We need them to replay your request, so we keep credentials meant for your target too: Authorization, Cookie, API-key headers and signatures. Everyone with access to your project in the dashboard sees them in full on the trigger's details. They stay visible after the trigger fires or is cancelled, and recurring triggers copy them onto every instance. Prefer short-lived or narrowly scoped credentials, or a signature your endpoint can verify.

Target URL

ttr-urlheader is the full URL we send the request to. It must be an absolute http:// or https:// URL.

  • Query string: put it in ttr-urlheader, e.g. https://example.com/hook?user=42&type=reminder. It's sent as written. Characters that aren't valid in a URL are percent-encoded, so a space becomes %20.
  • Invalid URLs: anything that isn't an absolute URL, such as /hooks/reminder or example.com, is rejected with 400 Invalid URL: <value>.
  • Other schemes: ftp:, mailto: and other non-HTTP URLs are accepted and count against your quota, but they can't be delivered. Every attempt fails with Transport error (<METHOD> <url>).
  • Fragments: a #fragment isn't stripped. It's sent to your server as part of the request path, which many servers don't expect, so leave it out.
  • Credentials: a username and password in the URL (https://user:pass@example.com) are not sent and don't become an Authorization header. Send an explicit Authorization header with your /schedule request instead. The URL is stored and shown in the dashboard exactly as you sent it, so don't put secrets in it.

When to fire

ttr-scheduled-atheader decides when the trigger fires and what kind of trigger you get:

ValueResult
2030-01-01T09:00:00ZOne-shot trigger at that time
now, or no header at allOne-shot trigger that fires right away
now | add 2dOne-shot trigger relative to now. See Operations.
cron(0 9 * * 1-5)Recurring trigger. See Recurring triggers.

A trigger set to now fires within a couple of seconds (firing precision). A header that is present but empty is rejected with 400 Invalid date: .

Date formats

  • Use ISO 8601 with Z or an offset. For example, 2030-01-01T09:00:00Z or 2030-01-01T09:00:00+02:00 (07:00 UTC).
  • A date without a time, such as 2030-01-01, means 00:00 UTC.
  • A date-time without an offset, such as 2030-01-01T09:00:00, is read as UTC.
  • now is the time of your request. It must be lowercase: NOW is rejected with 400 Invalid date: NOW.
  • Other formats that JavaScript's date parser understands are accepted and read as UTC, e.g. Dec 1, 2030 or 2030/12/01. Avoid them. 12/01/2030 is read as US month/day (1 December), and an impossible day rolls over instead of failing (2030-02-30 becomes 2 March).
  • Epoch timestamps (1893456000), words like tomorrow and an empty value are rejected with 400 Invalid date: <value>.
  • Milliseconds are dropped, so scheduledAt always ends in .000Z. An absolute timestamp for "right now", such as new Date().toISOString(), therefore lands in the past and the trigger is skipped. Use now to fire immediately.

Operations on ttr-scheduled-atheader

You can do date arithmetic on a date or on now by adding operations after a |:

When to fireHeader value
30 minutes from nownow | add 30m
2 days from nownow | add 2d
2 weeks from nownow | add 2w
1 hour before a date2030-06-01T09:00:00Z | subtract 1h
Chained: 22:30 on 1 June 20302030-06-01T00:00:00Z | add 1d | subtract 2h | add 30m

The format is <date or now> | <operation> | <operation> ...:

  • An operation is add <amount><unit> or subtract <amount><unit>, written in lowercase.
  • The amount is a whole number. It can be negative: add -1h is the same as subtract 1h. Decimals and a leading + aren't allowed.
  • A space between the amount and the unit is optional (add 2d or add 2 d). Each operation takes exactly one amount, so write add 1d | add 2h, not add 1d 2h.
  • You can chain any number of operations. They are applied left to right, and the result is truncated to whole seconds.
  • Operations can't be combined with cron(...).
UnitMeaning
sSeconds
mMinutes
hHours
dDays of exactly 24 hours
wWeeks of exactly 7 × 24 hours

Units are lowercase. There are no month or year units, and d and w don't adjust for daylight saving time.

A malformed value is rejected with 400 Bad Request and doesn't count against your quota:

MessageCauseExamples
Invalid date: <date>The part before the first | is neither now nor a dateNOW | add 1d
Unknown pipe operation: <operation>An operation that isn't lowercase add or subtract followed by a space and an amount, or an empty stepADD 5m, add5m, a trailing |
Invalid duration: <amount>The amount isn't a whole number followed by a unit1.5h, +1h, 2y, 5M, 1d 2h

Try it out

ttr-scheduled-at

Past-dated triggers

If ttr-scheduled-atheader resolves to a time in the past, the one-shot trigger is stored as skipped and never fires, except in these two cases:

  • The value is based on now (now, now | subtract 1h, or no header at all). These values are never skipped and fire right away.
  • You send ttr-run-missedheader: true. The trigger fires right away.

The response is still a normal 200 (with "operation": "schedule", or "reschedule" for an update) and a scheduledAt in the past. Nothing in the response says the trigger was skipped. It shows as Skipped in the dashboard and still counts against your quota.

Good to know

  • ttr-run-missedheader only takes effect with the exact lowercase value true. TRUE, 1 and yes count as absent.
  • What counts is the final time, after operations and after milliseconds are dropped. 2020-01-01T00:00:00Z | add 5000d is in the future, but a timestamp of the current instant ends up in the past.
  • The rule doesn't apply to recurring triggers, and ttr-run-missedheader is ignored for them.
  • Updates by custom key apply the rule again: a past time makes the trigger skipped, and a future time, a now-based time or ttr-run-missedheader: true revives a skipped one. Updates by ttr-trigger-idheader don't apply it: a trigger moved to a past time fires right away, and a skipped trigger can't be updated by ID. See Edit a trigger.

Response

A successful call returns 200 with a JSON body:

{
"triggerId": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f",
"scheduledAt": "2030-01-01T09:00:00.000Z",
"operation": "schedule",
"kind": "job",
"monthQuotaRemaining": 499
}
FieldDescription
triggerIdThe trigger's ID, a UUID. For a recurring trigger, this is the ID of the recurring trigger, not of a single instance. Send it back in ttr-trigger-idheader to edit or cancel the trigger.
scheduledAtWhen the trigger fires, in whole seconds. For a recurring trigger, the time of the next instance.
operationschedule if a trigger was created, reschedule if an existing one was updated through ttr-trigger-idheader or ttr-custom-keyheader.
kindjob for a one-shot trigger, generator for a recurring trigger.
monthQuotaRemainingHow many /schedule calls your project has left this calendar month (UTC), after this one. null if your plan has no monthly limit.

Every /schedule call that gets past request validation uses one unit of quota. That includes updates, past-dated triggers that are skipped, and calls that then fail with 404 or 410. Once the quota is used up, every /schedule call (edits included) gets 402, while /cancel keeps working. See Quota.

The response doesn't include the trigger's status, and no endpoint returns it: you follow status and results in the dashboard.

Status codes

Status codeMeaning
200OKTrigger created or updated. Also returned when a past-dated trigger is stored as skipped.
400Bad Requestttr-api-keyheader or ttr-urlheader is missing (empty response body), or ttr-urlheader, ttr-tagsheader or ttr-scheduled-atheader is invalid. A lowercase or unknown method name also gets a plain 400 with no body.
401Unauthorizedttr-api-keyheader is present but isn't a valid key.
402Payment RequiredYour monthly quota is used up.
404Not FoundNo trigger with this ttr-trigger-idheader exists, or it's not the kind ttr-scheduled-atheader asks for. See Trigger IDs.
410GoneThe trigger can no longer be updated by ID: the one-shot trigger isn't registered anymore, or the recurring trigger was cancelled.
431The request headers are too large (empty response body). See Size limits.

Errors come with a JSON body such as {"_tag": "BadRequest", "message": "Invalid URL: example.com"}, except where noted. See Errors for the format, every message and the order in which checks run.

Custom trigger keys

Add ttr-custom-keyheader to give a trigger your own identifier. If you call /schedule again with the same key, the existing trigger is updated instead of a second one being created.

For example, to send a reminder a day before an appointment that may move, use the appointment as the key and send it again whenever the time changes:

curl -X POST https://api.timetriggers.io/schedule \
-H "ttr-api-key: YOUR_API_KEY" \
-H "ttr-url: https://example.com/webhooks/reminder" \
-H "ttr-scheduled-at: 2030-03-14T09:00:00Z | subtract 1d" \
-H "ttr-custom-key: appointment-42-reminder" \
-H "Content-Type: application/json" \
-d '{"appointmentId": 42}'

A repeat call updates the trigger in place (same triggerId, "operation": "reschedule") if it's a one-shot trigger that is still registered, skipped or retrying, or, when you send cron(...), an active recurring trigger. Once a one-shot trigger is queued, running, completed or cancelled, or a recurring trigger is cancelled, the key creates a new trigger with a new triggerId, and an old one that's queued or running still fires. A retrying trigger keeps the time of its next attempt; see Triggers waiting to retry. Every case is listed in Edit by custom key.

An update replaces the whole trigger, so resend everything you want to keep: ttr-scheduled-atheader, ttr-titleheader, ttr-tagsheader, your headers and your body.

One-shot and recurring triggers share the key namespace. Sending cron(...) with the key of a pending one-shot trigger normally cancels the one-shot, and the new recurring trigger takes over the key. If earlier one-shot triggers have used the same key, the pending one may not be cancelled, so cancel it by its triggerId first. Sending a date or now with the key of an active recurring trigger cancels it, along with its pending instance. See Switch between one-shot and recurring.

Choosing key values:

  • Use short keys of printable ASCII characters (well under 2 KB). Header values are read as ISO-8859-1, so a non-ASCII key sent in a header doesn't match the same key sent in the JSON body of /declare or /bulk.
  • Spaces at the start and end are trimmed.
  • Don't send an empty ttr-custom-keyheader. It counts as no key: it never updates an existing trigger, and you can't cancel by it. Leave the header out instead.

Key matching only works for requests that arrive one after another. If several requests with the same new key arrive at the same moment, only one creates the trigger. The others can fail and still count against your quota. Send requests that share a key one at a time, or retry the failed ones: a retry updates the trigger the first request created.

Trigger IDs

Each response returns a triggerId, which is a UUID such as 3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f. It identifies a trigger in your project. Send it in ttr-trigger-idheader to update that trigger with /schedule, or to cancel it.

By ID, a one-shot trigger can be updated only while it's registered (scheduled and not yet due), and a recurring trigger only while it's active. Otherwise you get 410. The kind must also match: send a one-shot trigger's ID with a date or now, and a recurring trigger's ID with cron(...), or you get 404, the same as for an unknown ID. A skipped trigger can still be updated by custom key. See Edit by trigger ID.

If you send both ttr-trigger-idheader and ttr-custom-keyheader, see Sending both identifiers for which one wins.

Tags

Tags group triggers. A tag policy on a tag sets throughput limits, concurrency, request timeout and retries for every trigger that carries it. Tags also let you filter triggers in the dashboard. You don't need to create a tag before using it.

ttr-tags: billing,emails

Rules:

  • Separate tags with commas. Spaces around each tag are trimmed and empty entries are ignored, so billing, emails, means billing,emails. An empty value means no tags.
  • You can use up to 10 tags.
  • Each tag has 1 to 50 characters, using only lowercase a-z, digits, _ and -. Uppercase letters are rejected, not lowercased.
  • List each tag only once.
  • If you send the header more than once, the lists are combined.

Invalid tags are rejected with 400 Bad Request:

MessageCause
Maximum 10 tags allowedMore than 10 tags
Tag too long (max 50 chars): <tag>A tag longer than 50 characters
Invalid tag (only a-z 0-9 _ - allowed): <tag>A tag with any other character, such as an uppercase letter, a space or a .

Good to know

  • An update replaces the whole tag set. If you leave out ttr-tagsheader when updating a trigger, all of its tags are removed.
  • A recurring trigger's tags are copied onto each instance when the instance is created, so tag policies apply to instances the same way they apply to one-shot triggers.
  • Tags aren't forwarded to your target.

Titles

Add ttr-titleheader to give a trigger a human-readable label:

ttr-title: Reminder for appointment 42

The dashboard shows the title on the trigger page, and in bold before the URL on the Triggers and Executions pages. You can also search triggers by title there. The title isn't forwarded to your target and isn't returned in the response.

  • One-shot triggers only. With cron(...), ttr-titleheader is ignored without an error, and instances of recurring triggers have no title.
  • Updates replace it. If you leave out ttr-titleheader when updating a trigger, its title is cleared.
  • Use ASCII. Header values are read as ISO-8859-1. UTF-8 text from a tool like curl is stored garbled (Café becomes Café), and characters outside ISO-8859-1, such as emoji, can't be sent in a header at all. For full Unicode titles, use the title field of /declare or /bulk.
  • There's no length limit of its own, but the title counts toward the header size limit.

Recurring triggers

To fire on a schedule instead of once, put a cron(...) expression in ttr-scheduled-atheader:

ttr-scheduled-at: cron(0 9 * * 1-5, Europe/Paris)

This fires every weekday at 09:00 Paris time. The time zone is optional and defaults to UTC.

Everything on this page also applies to recurring triggers: the URL, method, headers, body and tags are stored on the recurring trigger and copied onto each instance it creates. A custom key identifies the recurring trigger itself, not its instances. The differences:

  • The response has "kind": "generator". triggerId identifies the recurring trigger, and scheduledAt is the time of the next instance.
  • Date operations can't be combined with cron(...).
  • ttr-titleheader is ignored. The past-dated rule and ttr-run-missedheader don't apply.

See Recurring triggers for cron syntax, time zones, how instances are created, and editing or cancelling.

Size limits

  • Headers: the /schedule path and query string plus all header names and values must total less than 16 KB (16,384 bytes). ttr- headers count toward this limit. Larger requests get a plain 431 Request Header Fields Too Large with no body, and nothing is created. Also keep each single header under 8 KB, and send no more than 1,000 header fields: fields beyond that can be dropped.
  • Body: TimeTriggers doesn't set a body size limit of its own, but keep bodies under 1 MB. Larger requests may be rejected before they reach the API.

TimeTriggers — Schedule HTTP requests at any time.