How triggers fire
When a trigger's time comes, TimeTriggers sends its stored request to your target and records the result:
- At the scheduled time, the trigger is queued.
- Usually within a second, the request is sent and the trigger is running. A tag policy can hold it back first.
- A
2xxresponse is a success. Anything else is a failure, which is retried only if a tag policy asks for it. - The trigger ends up completed, whether it succeeded or failed.
Each attempt to send the request is recorded as an execution. You follow statuses and executions in the dashboard. No API endpoint returns them (see Reading trigger status and results), and TimeTriggers doesn't notify you of the outcome.
Trigger statuses
A one-shot trigger, and each instance of a recurring trigger, has one of these statuses:
| Status | Dashboard label | Meaning |
|---|---|---|
registered | Scheduled | Waiting for its scheduled time. |
skipped | Skipped | Created, or updated by custom key, with a time already in the past. It doesn't fire unless you update it by custom key to a time that isn't in the past, though a pending retry still goes out. See Past-dated triggers. |
queued | Queued | Due and waiting to be sent: normally for about a second, and for as long as needed while a tag policy's throughput or concurrency limit holds it back. |
running | Running | The request is being sent, or TimeTriggers is waiting for the response. |
retrying | Retrying | An attempt failed and a retry is planned. A retry held back by a tag policy keeps this status. |
completed | Completed | Done: an attempt succeeded, or the last attempt failed and no retry follows. |
cancelled | Cancelled | Cancelled. It doesn't fire anymore. |
The usual path is registered → queued → running → completed. With retries, running → retrying → running repeats until an attempt succeeds or the retries stop.
Good to know
completeddoesn't mean success. A trigger whose last attempt failed iscompletedtoo. Its executions tell the difference: a failed final attempt is a dead letter, or was closed with anExecutor diederror.- A trigger held back by a tag policy has no status of its own. It shows as
queued, or asretryingfor a retry. - Replay now and Retry now in the dashboard put a
completedtrigger back toqueuedand send its request again. - What you can change depends on the status. Only
registeredtriggers can be edited by trigger ID.registered,skippedandretryingtriggers can be edited by custom key or cancelled.queuedandrunningtriggers can't be edited or cancelled with/scheduleor/cancel. See Edit a trigger and Cancel a trigger.
A recurring trigger itself is either active or cancelled. Each of its runs is an ordinary trigger with the statuses above. See How instances are created.
Execution statuses
Each attempt to send a trigger's request is an execution:
| Status | Dashboard label | Meaning |
|---|---|---|
planned | Planned | Waiting to be sent: the first attempt of a due trigger, a retry, or a replay. |
started | Started | The request is in flight. |
complete | Complete | Finished, successfully or not. There's no "failed" status: look at responseStatus, errorMessage and isDeadLetter. |
cancelled | Cancelled | A planned retry that was dropped because /declare or /bulk replaced its trigger. |
triggeredBy says what created the execution:
triggeredBy | Created by |
|---|---|
scheduler | The trigger's scheduled time. |
retry | An automatic retry, or the dashboard's Retry now button. The two look the same. |
replay | The dashboard's Replay now button. |
What is sent
Each attempt sends one HTTP request with the trigger's method, URL, headers and body.
- Headers are the ones you sent to
/schedule, minusttr-headers and connection-level headers. See Headers sent to your target. This includes headers your HTTP client adds by itself, such asAccept-Encoding, which affects how responses are recorded (see Compressed responses). Triggers created with/declareor/bulkcarry only the headers listed in the item. - Retries and replays resend the stored request. Unless you edit the trigger in between, every attempt is identical except for the
traceparentandb3tracing headers, which get new values each time. Editing aretryingtrigger changes what its next attempt sends, but not when; see Triggers waiting to retry. - No delivery ID, attempt number or signature header is added. See Delivery guarantees.
- Redirects aren't followed. See Success and failure.
Success and failure
Every attempt ends in one of three ways:
| Outcome | When | What's recorded |
|---|---|---|
| Success | Your target answers with a status from 200 to 299. | responseStatus, response headers and body |
| HTTP failure | Your target answers with any other status: 3xx, 4xx or 5xx. | responseStatus, response headers and body |
| Network failure | No usable response: connection refused, DNS or TLS failure, connection closed before the response headers, a timeout, or the connection breaking while the body is read (even after a 2xx status). | errorMessage, no responseStatus |
Redirects aren't followed. A 301, 302, 303, 307 or 308 is recorded with that status and counts as a failure. The Location URL is never requested, so point ttr-urlheader at the final URL.
After a failure:
- No retry policy (the default): the failure is final. The execution is marked as a dead letter and the trigger becomes
completed. - With a retry policy: the failure is retried if the policy's
retryOncovers it and attempts remain. By default,retryOncovers network failures (timeouts included) and5xxresponses; see What is retried. The trigger isretryinguntil the next attempt is sent.
Either way, the trigger ends up completed. Only its executions show whether it succeeded.
Timeouts
Each attempt has a timeout of 300 seconds (5 minutes) by default. A tag policy can change it with requestTimeoutSeconds, from 1 to 3600 seconds; see Request timeout.
- If several of a trigger's tags set a timeout, the shortest one applies. A tag can also set a timeout longer than the default.
- The timeout is fixed when an attempt starts, so a policy change applies from the next attempt.
What the timeout covers:
- Covered: connecting, sending the request, and waiting for the response status and headers. When the timeout runs out, TimeTriggers closes the connection and records a network failure with no status. Your endpoint may still finish its work.
- Not covered: receiving the response body. Reading stops after 10 MB. If the response is still arriving more than 30 seconds after the timeout, the attempt can be closed as
Executor died(within about 30 more seconds) with no response recorded, and the response is discarded when it finally arrives. See When a request can be lost.
A timed-out attempt gets the errorMessage {"errorMessage":"Request timed out after <N>s"}, where <N> is the timeout in seconds, and a TIMEOUT badge in the dashboard. For retries it counts as a network failure, so every retryOn value retries it.
If your endpoint does slow work, answer with a 2xx right away and do the work afterwards. A timeout doesn't stop your endpoint, so a retry after a timeout can arrive while your endpoint is still processing the first request.
Retries
Retries are off by default: a failed attempt is final. To turn them on, tag the trigger and give the tag a tag policy with retries, with retryStrategy set to fixed or exponential.
retryOndecides which failures are retried. See What is retried.- Each retry is a new execution with
triggeredByset toretry. It's planned for the moment the failure was recorded plus the policy's delay, so unlike scheduled times, retries don't fall on whole seconds. - While a retry waits, the trigger is
retrying. Retries wait for the tag's throughput and concurrency limits like first attempts do. retryMaxAttemptsis the total number of attempts, counting the first one and any manual replays. If it isn't set, retryable failures are retried indefinitely.- Cancelling a
retryingtrigger stops further attempts. - If a trigger has several tags, their policies are combined; see Multiple tags.
Firing precision
- Whole seconds. Scheduled times are stored to the second. Milliseconds are dropped, not rounded, so a trigger can fire up to a second before a sub-second time you asked for. An absolute time within the current second therefore counts as past and is skipped: use
nowto fire right away. - Never early, usually within a couple of seconds. A trigger is never sent before its scheduled second. Without a backlog or tag policy limits, it's normally sent 0 to 2 seconds after it: TimeTriggers checks for due triggers about once a second, and sends them in a second pass that also runs about once a second.
What can make a trigger later:
- Bursts. Due triggers are picked up oldest first, in batches of up to 100 about once a second, so a burst of thousands of triggers due at the same moment takes several seconds or more to go out.
- Tag policies. A trigger held back by a throughput or concurrency limit stays
queueduntil the policy lets it through. There's no upper bound on that wait. - Recurring triggers. Runs less than about 5 seconds apart can't all fire. See Skipped ticks.
- Service interruptions. There's no maximum lateness and no expiry: triggers that came due while the service was unavailable are sent as soon as it's back, oldest first. A recurring trigger fires only its pending instance late; the runs it missed are skipped. A request that was in flight when the service stopped isn't sent again (see When a request can be lost).
This is different from a trigger whose time is already in the past when you create it: it's stored as skipped instead of firing, unless you send ttr-run-missedheader: true. See Past-dated triggers.
Delivery guarantees
TimeTriggers doesn't guarantee that a request is delivered exactly once, or even at least once. Your endpoint can receive the same request twice and, in rare cases, not at all. Make your endpoint idempotent, and check the dashboard for attempts that ended without a response.
TimeTriggers doesn't add a delivery ID, attempt number or signature to the request. Request signing (HMAC) is on the roadmap. The traceparent and b3 headers change on every attempt, so they can't identify duplicates either. Instead, put your own identifier in a header or in the body when you schedule. It's sent unchanged on every attempt:
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 "Idempotency-Key: appointment-42-reminder" \-H "Content-Type: application/json" \-d '{"appointmentId": 42}'
Your endpoint can then ignore a key it has already processed.
When a request can arrive twice
- Retries after a timeout or network error. A timeout only means TimeTriggers stopped waiting. If your endpoint processed the request, or is still processing it, the retry repeats it. The same goes for a
5xxreturned after the work was done (or, undernon_2xx, any error status). Without a retry policy, there are no retries. - Replays. Replay now and Retry now in the dashboard send the identical request again. The button only appears once the trigger is
completed, but clicking it again, for example in a second browser tab that still shows it, once the first replay is in flight sends the request a second time. - Re-sending a custom key too late. A custom key only updates a trigger that is
registered,skippedorretrying. Once the trigger isqueuedorrunning, sending the same key again creates a second trigger, and both fire. The same applies to/declareand/bulkitems. See Edit by custom key.
When a request can be lost
If the server sending a request stops in the middle of an attempt, for example during a crash, restart or deploy, the request isn't sent again:
- The execution stays
startedand the trigger staysrunninguntil the attempt's timeout plus 30 seconds has passed (330 seconds with the default timeout). - Within about 30 more seconds, TimeTriggers closes the attempt. The execution becomes
completewith theerrorMessageExecutor died (status=started past reap_after)and no response data, and the trigger becomescompleted. - No retry is scheduled, even with a retry policy. The execution isn't a dead letter either, so the dead-letter filter doesn't show it. The dashboard shows it with an ERROR badge.
Your endpoint may or may not have received, or even processed, the request. Check on your side, then use Retry now in the dashboard if the request needs to go out again. For a recurring trigger, only that one run is affected.
Until the attempt is closed, it takes up a slot in the concurrency limit of each of its tags, so other triggers with those tags may have to wait.
The same Executor died error also ends attempts whose response body is still arriving long after the timeout, and attempts whose response can't be stored (see Compressed responses).
Dead letters
A dead letter is a failed attempt that TimeTriggers decided not to retry. It has isDeadLetter: true and a red DEAD badge in the dashboard. A failed attempt becomes a dead letter when:
- No retry policy applies. None of the trigger's tags has a policy with retries. This is the default, so without a retry policy every failed attempt is a dead letter.
retryOndoesn't cover the failure. For example, a404under the default5xx_and_network.- The attempts are used up. The attempt number has reached
retryMaxAttempts. Manual replays count toward it.
The trigger then becomes completed, like a successful one, and nothing more is sent unless you replay it.
Good to know
- The flag stays on that execution. A later replay adds a new execution and doesn't clear it.
- Two kinds of final failure aren't flagged: attempts closed as
Executor died, and the last failed attempt of a trigger that was cancelled whileretrying. - No alerts. TimeTriggers doesn't send an email, webhook or any other notification for dead letters.
Where to find them, in the dashboard only:
- The trigger's page marks each dead-lettered attempt with DEAD.
- The Executions page shows the same badge and has an Only dead letters filter.
- The triggers list shows these triggers as Completed, like successful ones.
- The dashboard's own endpoints, which need a dashboard session, return
isDeadLetteron each execution, andGET /executions?deadLetterOnly=truelists only dead letters. Only the exact lowercase valuetrueturns that filter on; any other value is ignored.
Execution records
Every attempt is stored as an execution. In the dashboard, the trigger page lists each attempt with its number, source, result, start time and duration; click one to see its error, response headers and body. The Executions page lists attempts across triggers. The field names below are the ones the dashboard's endpoints return.
| Field | Description |
|---|---|
attemptNumber | 1, 2, 3 and so on within a trigger. It counts every attempt: the scheduled one, retries and replays. Each run of a recurring trigger starts at 1, and so does the new trigger that /declare or /bulk creates when it replaces a retrying one. |
triggeredBy | scheduler, retry or replay. See Execution statuses. |
status | planned, started, complete or cancelled. See Execution statuses. |
plannedAt | When the attempt is due to be sent. For a retry, that's the failure time plus the policy's delay, so it can be in the future. |
startedAt, completedAt | When the attempt was sent and when it finished. null until then. |
responseStatus | The status your target returned. null when no response was recorded. |
responseHeaders | Your target's response headers, with lowercase names. Repeated headers are joined with , . null when no response was recorded. |
responseBody | The first 10 KB of the response body. "" for an empty body, null when no response was recorded. See Response bodies. |
responseSize | The number of body bytes received, not the Content-Length header. Reading stops at 10 MB, so 10485760 means "10 MB or more". null when no response was recorded. |
errorMessage | null when a response arrived, even a 4xx or 5xx. Otherwise one of the error messages below. |
isDeadLetter | true when the attempt failed and TimeTriggers decided not to retry it. Attempts closed as Executor died stay false. See Dead letters. |
Error messages
errorMessage | Meaning |
|---|---|
Transport error (<METHOD> <url>) | No response: connection refused, DNS or TLS failure, the connection closed before the response headers, or a URL scheme other than http or https. The message doesn't include the underlying cause. |
InvalidUrl error (<METHOD> <url>) | The stored URL isn't a valid URL. Only triggers created with /declare or /bulk can have one, because those endpoints don't check url. |
Decode error (<status> <METHOD> <url>) | The connection broke while the response body was being read. responseStatus stays null, even when the status was 2xx. |
{"errorMessage":"Request timed out after <N>s"} | No response headers within the timeout of <N> seconds. The message is stored in this JSON form. |
Executor died (status=started past reap_after) | TimeTriggers closed the attempt after its timeout plus 30 seconds without a recorded result. See When a request can be lost. |
<url> is the full target URL, query string included. The dashboard shows a TIMEOUT badge for timeouts and an ERROR badge for the other errors.
Response bodies
- Only the first 10,240 bytes (10 KB) are kept, decoded as UTF-8. Invalid bytes, and a multi-byte character cut at the 10 KB mark, become the replacement character
U+FFFD, so binary bodies aren't stored faithfully. - Bodies are stored as received. Compressed bodies aren't decompressed.
Compressed responses
The Accept-Encoding header of your /schedule request is forwarded to your target like any other header. If it asks for compression and your target compresses its response, you don't get a readable record:
- Compressed responses usually aren't recorded at all. A response isn't recorded when its body has a zero byte (
0x00) in its first 10 KB. gzip bodies always do, and brotli and deflate bodies usually do. So do many binary formats. - A compressed body without a zero byte is stored as unreadable text, and
responseSizeis the compressed size.
When a response can't be recorded, the attempt stays started and is closed as Executor died after its timeout plus 30 seconds. It has no status or body, isn't retried and isn't marked as a dead letter, even though your target received the request and answered it.
Node's built-in fetch adds an Accept-Encoding header to every request by itself (br, gzip, deflate for https:// URLs such as the TimeTriggers API), so triggers you schedule with it ask your target for compressed responses. curl doesn't send the header unless you pass --compressed. To keep responses readable, send Accept-Encoding: identity with your /schedule request, or make sure your target doesn't compress its responses:
await fetch("https://api.timetriggers.io/schedule", {method: "POST",headers: {"ttr-api-key": "YOUR_API_KEY","ttr-url": "https://example.com/webhooks/reminder","ttr-scheduled-at": "now | add 1h","Content-Type": "application/json","Accept-Encoding": "identity",},body: JSON.stringify({ reminderId: 42 }),});
Triggers created with /declare or /bulk only send Accept-Encoding if you list it in the item's headers.