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.
| Header | Example | Description |
|---|---|---|
ttr-api-keyreq. | ttr_9f86d0... | Your API key. Authorization: Bearer doesn't work on /schedule. See Authentication. |
ttr-urlreq. | https://example.com/hook?user=42 | The 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-1a2b3c4d5e6f | Update the trigger with this ID instead of creating one. See Trigger IDs. |
ttr-custom-keyopt. | appointment-42-reminder | Your own key for the trigger. Sending the same key again updates that trigger. See Custom trigger keys. |
ttr-titleopt. | Reminder for appointment 42 | A label shown in the dashboard. See Titles. |
ttr-tagsopt. | billing,emails | Comma-separated tags. Tag policies attach to these. See Tags. |
ttr-run-missedopt. | true | Fire a trigger whose time is already past, instead of skipping it. See Past-dated triggers. |
Good to know
- Header names are case-insensitive (
TTR-URLworks). Values are case-sensitive, sonowandtruemust be lowercase. - Options can only be set as headers. Query parameters on the
/scheduleURL itself are ignored: they aren't stored, aren't forwarded and aren't added tottr-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 meansnow, 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 orttr-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,OPTIONSandTRACE.- The WebDAV and other extension methods that Node.js recognizes, such as
PROPFIND,MKCOL,COPY,MOVE,LOCK,REPORTandPURGE.
Limitations:
- Method names must be uppercase. Lowercase or custom names (
patch,FOO) get a plain400 Bad Requestwith no JSON body, and nothing is created. CONNECTisn't supported: the connection is closed without a response and nothing is created.QUERYreturns404.HEADcreates the trigger normally, but HEAD responses have no body, so you never see thetriggerIdormonthQuotaRemaining. Errors only come back as status codes. Set attr-custom-keyheader so you can refer to the trigger later, or schedule with another method.
Request body
GET,HEAD,OPTIONSandTRACE: 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-Typeincluded, 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-EncodingandUpgrade.
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-Signaturearrives asx-signature). Values are unchanged. - Repeated headers are merged into one. Most are joined with
,andCookiewith;. For single-value headers such asAuthorization,Content-Type,User-AgentandReferer, only the first value is kept. - A
Set-Cookierequest header isn't sent. Hostcomes fromttr-urlheader, andConnectionis set for the request we send.Content-Lengthis computed from the stored body. Without a body,POST,PUT,PATCH,PROPFINDandPROPPATCHare sent withContent-Length: 0, and other methods without one.- Every attempt carries
traceparentandb3tracing headers with a new trace ID. If you set either one yourself, your value is replaced. Other tracing headers, such astracestate, 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/reminderorexample.com, is rejected with400 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 withTransport error (<METHOD> <url>). - Fragments: a
#fragmentisn'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 anAuthorizationheader. Send an explicitAuthorizationheader with your/schedulerequest 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:
| Value | Result |
|---|---|
2030-01-01T09:00:00Z | One-shot trigger at that time |
now, or no header at all | One-shot trigger that fires right away |
now | add 2d | One-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
Zor an offset. For example,2030-01-01T09:00:00Zor2030-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. nowis the time of your request. It must be lowercase:NOWis rejected with400 Invalid date: NOW.- Other formats that JavaScript's date parser understands are accepted and read as UTC, e.g.
Dec 1, 2030or2030/12/01. Avoid them.12/01/2030is read as US month/day (1 December), and an impossible day rolls over instead of failing (2030-02-30becomes 2 March). - Epoch timestamps (
1893456000), words liketomorrowand an empty value are rejected with400 Invalid date: <value>. - Milliseconds are dropped, so
scheduledAtalways ends in.000Z. An absolute timestamp for "right now", such asnew Date().toISOString(), therefore lands in the past and the trigger is skipped. Usenowto 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 fire | Header value |
|---|---|
| 30 minutes from now | now | add 30m |
| 2 days from now | now | add 2d |
| 2 weeks from now | now | add 2w |
| 1 hour before a date | 2030-06-01T09:00:00Z | subtract 1h |
| Chained: 22:30 on 1 June 2030 | 2030-06-01T00:00:00Z | add 1d | subtract 2h | add 30m |
The format is <date or now> | <operation> | <operation> ...:
- An operation is
add <amount><unit>orsubtract <amount><unit>, written in lowercase. - The amount is a whole number. It can be negative:
add -1his the same assubtract 1h. Decimals and a leading+aren't allowed. - A space between the amount and the unit is optional (
add 2doradd 2 d). Each operation takes exactly one amount, so writeadd 1d | add 2h, notadd 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(...).
| Unit | Meaning |
|---|---|
s | Seconds |
m | Minutes |
h | Hours |
d | Days of exactly 24 hours |
w | Weeks 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:
| Message | Cause | Examples |
|---|---|---|
Invalid date: <date> | The part before the first | is neither now nor a date | NOW | 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 step | ADD 5m, add5m, a trailing | |
Invalid duration: <amount> | The amount isn't a whole number followed by a unit | 1.5h, +1h, 2y, 5M, 1d 2h |
Try it out
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 valuetrue.TRUE,1andyescount as absent.- What counts is the final time, after operations and after milliseconds are dropped.
2020-01-01T00:00:00Z | add 5000dis 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, anow-based time orttr-run-missedheader:truerevives a skipped one. Updates byttr-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}
| Field | Description |
|---|---|
triggerId | The 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. |
scheduledAt | When the trigger fires, in whole seconds. For a recurring trigger, the time of the next instance. |
operation | schedule if a trigger was created, reschedule if an existing one was updated through ttr-trigger-idheader or ttr-custom-keyheader. |
kind | job for a one-shot trigger, generator for a recurring trigger. |
monthQuotaRemaining | How 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 code | Meaning |
|---|---|
| 200OK | Trigger created or updated. Also returned when a past-dated trigger is stored as skipped. |
| 400Bad Request | ttr-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. |
| 401Unauthorized | ttr-api-keyheader is present but isn't a valid key. |
| 402Payment Required | Your monthly quota is used up. |
| 404Not Found | No trigger with this ttr-trigger-idheader exists, or it's not the kind ttr-scheduled-atheader asks for. See Trigger IDs. |
| 410Gone | The trigger can no longer be updated by ID: the one-shot trigger isn't registered anymore, or the recurring trigger was cancelled. |
| 431 | The 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
/declareor/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,meansbilling,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:
| Message | Cause |
|---|---|
Maximum 10 tags allowed | More 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ébecomesCafé), and characters outside ISO-8859-1, such as emoji, can't be sent in a header at all. For full Unicode titles, use thetitlefield of/declareor/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".triggerIdidentifies the recurring trigger, andscheduledAtis the time of the next instance. - Date operations can't be combined with
cron(...). ttr-titleheader is ignored. The past-dated rule andttr-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
/schedulepath 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 plain431 Request Header Fields Too Largewith 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.