Declare a set of triggers

POST /declare takes a tag and the complete list of triggers that should carry it. TimeTriggers compares your list with the pending triggers in your project that carry the tag, then adds, updates or cancels triggers until the two match. Items that you identify with a customKey and that haven't changed are left as they are.

Use it to mirror data you already keep, such as one reminder per upcoming appointment. Whenever something changes, send the whole set again instead of working out which triggers to create, move or cancel.

curl -X POST https://api.timetriggers.io/declare \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "reminders",
"items": [
{
"customKey": "appointment-42",
"title": "Reminder for appointment 42",
"scheduledAt": "2030-03-14T09:00:00Z | subtract 1d",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"appointmentId": 42}
},
{
"customKey": "appointment-43",
"title": "Reminder for appointment 43",
"scheduledAt": "2030-03-20T14:00:00Z | subtract 1d",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"appointmentId": 43}
}
]
}'
{
"tag": "reminders",
"summary": { "unchanged": 0, "added": 2, "skipped": 0, "updated": 0, "cancelled": 0 },
"operations": [
{ "operation": "added", "customKey": "appointment-42", "kind": "job", "id": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f" },
{ "operation": "added", "customKey": "appointment-43", "kind": "job", "id": "8c4e2a1f-5b7d-4f3e-a9c8-2d1e0f6b7a54" }
]
}

Later, appointment 42 moves to 15 March, appointment 43 is called off and appointment 44 is booked. Send the new full list with the same tag:

{
"tag": "reminders",
"items": [
{
"customKey": "appointment-42",
"title": "Reminder for appointment 42",
"scheduledAt": "2030-03-15T09:00:00Z | subtract 1d",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"appointmentId": 42}
},
{
"customKey": "appointment-44",
"title": "Reminder for appointment 44",
"scheduledAt": "2030-04-02T10:00:00Z | subtract 1d",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"appointmentId": 44}
}
]
}
{
"tag": "reminders",
"summary": { "unchanged": 0, "added": 1, "skipped": 0, "updated": 1, "cancelled": 1 },
"operations": [
{ "operation": "updated", "customKey": "appointment-42", "kind": "job", "id": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f", "changedFields": ["scheduledAt"] },
{ "operation": "added", "customKey": "appointment-44", "kind": "job", "id": "d7e6f5a4-b3c2-4d1e-8f9a-0b1c2d3e4f5a" },
{ "operation": "cancelled", "customKey": "appointment-43", "kind": "job", "id": "8c4e2a1f-5b7d-4f3e-a9c8-2d1e0f6b7a54" }
]
}

Request

Send your API key in ttr-api-keyheader or as Authorization: Bearer YOUR_API_KEY. If you send both, only ttr-api-keyheader is checked. A signed-in dashboard session works too. See Authentication.

The body is a JSON object:

FieldTypeRequiredDescription
tagstringYesThe tag this call manages. Every pending trigger in your project that carries it is compared with items, and tag is added to the tags of every item.
itemsarrayYesThe complete list of triggers that should carry tag. See Item format. An empty list [] cancels every trigger with the tag that isn't running or completed. See What gets cancelled.
runMissedbooleanNotrue fires past-dated one-shot items right away instead of skipping them. Defaults to false. See Past-dated items.

Good to know

  • Send Content-Type: application/json. Letter case and parameters such as ; charset=utf-8 don't matter, and a request with no Content-Type at all is read as JSON too. Any other type gets 415. curl -d sends application/x-www-form-urlencoded unless you set the header.
  • runMissed must be a JSON boolean: the string "true" is rejected with 400.
  • Use a tag in the recommended format. /declare doesn't check it.

Item format

Each entry of items describes one trigger. /bulk uses the same fields in its upserts, but matches keys across your whole project, adds no tag and handles a few cases differently: see Bulk or declare.

{
"customKey": "appointment-42",
"title": "Reminder for appointment 42",
"scheduledAt": "2030-03-14T09:00:00Z | subtract 1d",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"appointmentId": 42},
"tags": ["emails"]
}
FieldTypeDefaultDescription
urlstringrequiredThe full target URL, query string included.
scheduledAtstringnowWhen to fire, in the same format as ttr-scheduled-atheader: an ISO date, now, date operations such as now | add 1h, or cron(...) for a recurring trigger.
customKeystringnoneYour identifier for the trigger. It matches the item with the pending trigger that carries tag and has the same key, whichever endpoint created it. See How the diff works. Items without one are replaced on every call.
methodstringGETThe HTTP method to fire with. It's upper-cased: post becomes POST.
headersobject{}Headers sent to your target. All values must be strings. See Headers.
bodyany JSONno bodyThe request body. See Body.
titlestringnoneA label shown in the dashboard for one-shot triggers. Unlike ttr-titleheader, it can hold non-ASCII text such as accents and emoji.
tagsarray of strings[]Extra tags. tag is added for you. See Tags.

Rules for every field:

  • Omit optional fields instead of sending null. null is rejected with 400 for every field except body, where it means no body. The full spec marks these fields as nullable, but the API doesn't accept null for them.
  • Unknown fields are ignored without an error, in items and at the top level. Check your spelling: with a misspelled scheduledAt (say scheduled_at), the item is scheduled for now and fires right away. With a misspelled customKey, it's replaced on every call.
  • url isn't checked when you declare. Unlike /schedule, a value that isn't an absolute URL is accepted. The trigger then fails when it fires, with an error such as InvalidUrl error (GET not a url). This counts as a network error: it's final unless a tag policy retries network errors, and every retry fails the same way. Validate URLs before you send them.
  • method isn't checked either. Any value is sent as written (upper-cased), so check its spelling.
  • An update replaces the whole trigger. If an item leaves out a field, the trigger goes back to that field's default.

Body

body valueWhat your target receives
Omitted or nullNo body
An object, array, number, boolean or stringThe value as compact JSON. A string keeps its quotes: "hello" is sent as "hello", quotes included.
{"_tag": "text", "value": "..."}The string as UTF-8 text, without quotes
{"_tag": "base64", "value": "..."}The decoded bytes, for binary payloads

For example, to send plain text:

{
"customKey": "appointment-42-sms",
"url": "https://example.com/webhooks/sms",
"method": "POST",
"headers": {"Content-Type": "text/plain; charset=utf-8"},
"body": {"_tag": "text", "value": "Your appointment is tomorrow at 09:00"}
}
  • No Content-Type is added for you. A body sent without a Content-Type in headers arrives as application/octet-stream, JSON bodies included. Add "Content-Type": "application/json" to headers when you send JSON.
  • The body is sent with every method, including the default GET. Set method to POST, PUT or PATCH when you send a body.
  • Only an object whose _tag is exactly text or base64 and whose value is a string is unwrapped. Anything else, such as {"_tag": "json", "value": {...}}, is sent as JSON exactly as written.
  • Base64 isn't validated: characters outside the base64 alphabet are skipped, so a typo silently changes the bytes. An empty text or base64 value sends no body.

Headers

The headers object is stored and sent as written. Unlike /schedule, nothing is filtered out:

  • ttr- headers are forwarded to your target. Never put your TimeTriggers API key in headers.
  • Names arrive in lowercase. Values are unchanged.
  • Host replaces the Host header your target sees. The request still goes to the host in url.
  • A Content-Length you set is ignored. It's computed from the body.
  • Leave out connection-level headers. Keep-Alive, Transfer-Encoding, Upgrade, or a Connection value other than close or keep-alive, make every delivery attempt fail with Transport error (<METHOD> <url>).

Headers are stored in plain text and shown in the dashboard, like the ones forwarded by /schedule. Prefer short-lived or narrowly scoped credentials for your target.

Tags

  • tag is added to the tags of every item. You don't need to list it, but listing it once is fine.
  • A tag listed twice in the same item is rejected with 400.
  • An update replaces the whole tag set with the item's tags plus tag.
  • Tags are case-sensitive: reminders and Reminders are different tags.
  • /declare doesn't check the format or number of tags, unlike ttr-tagsheader. Still use 1 to 50 characters from lowercase a-z, digits, _ and -: tag policies only accept tags in that format, so a tag with uppercase letters, spaces or other characters can never get one.

Response

A successful call returns 200 with a JSON body, like the examples at the top of this page.

FieldDescription
tagThe tag you sent.
summaryHow many entries of operations have each result: unchanged, added, skipped, updated and cancelled.
operationsOne entry per item, in the order you sent them, followed by one entry per cancelled trigger: one-shot triggers and instances first, then recurring triggers.

Each entry of operations has these fields:

FieldDescription
operationWhat happened. See the next table.
customKeyThe item's key. null for keyless items and for instances of recurring triggers.
kindjob for a one-shot trigger or an instance, generator for a recurring trigger.
idThe trigger's ID, a UUID. For a recurring trigger, the ID of the recurring trigger itself. Use it with /cancel.
changedFieldsOnly on updated: the fields that changed.
operationMeaning
addedA new trigger was created.
skippedA new one-shot trigger was created with a time in the past. It's stored as skipped and never fires. See Past-dated items.
unchangedThe matching trigger already looks exactly like the item and was left as it is. For recurring triggers, see the limitation on their upcoming instance.
updatedThe matching trigger was changed to match the item.
cancelledA trigger that carries the tag but is no longer in items was cancelled. See What gets cancelled.

changedFields uses these names:

  • One-shot triggers: scheduledAt, url, method, headers, body, title, tags.
  • Recurring triggers: cronExpr, tz, url, method, headers, body, title, tags.
  • A switch between one-shot and recurring: only kind:job→generator or kind:generator→job.

The response doesn't include trigger statuses or the time of the next run of a recurring trigger. Follow them in the dashboard.

How the diff works

Each call works on the pending triggers in your project that carry tag: one-shot triggers that are registered, skipped or retrying, and active recurring triggers (see Trigger statuses). It doesn't matter how they were created: by /declare, by /bulk, by /schedule with ttr-tagsheader, or with any API key of your project.

An item with a customKey is matched with the trigger in that set that has the same key:

Matching triggerResultid
Noneadded, or skipped if it's a past-dated one-shotNew
Same kind, every field equalunchanged, the trigger is left as it isSame
One-shot, registered or skipped, something differsupdated in placeSame
One-shot, retrying, something differsupdated: the trigger and its pending retry are cancelled and a new trigger replaces itNew
Recurring, something differsupdated in place, and its upcoming instance is replaced (see Recurring items)Same
The other kind (one-shot vs. recurring)updated with changedFields ["kind:job→generator"] or ["kind:generator→job"]. The old trigger is cancelled.New
  • The fields compared are the time (or the cron expression and time zone), url, method, headers, body, title and the tags. The trigger's status and runMissed aren't compared.
  • When a trigger is replaced by a new one, the old one gets no cancelled entry of its own. Use the new id from then on.
  • A now-based scheduledAt (now, now | add 1h, or none at all) is computed again on every call. While the trigger is still pending, a call in a later second reports it updated and moves it relative to the new now. A trigger due right away is usually no longer pending by the next call, so that call adds and fires a new one (see Re-declaring a trigger that already fired). Use an absolute time for triggers you declare repeatedly.

What gets cancelled

/declare owns its tag across your whole project. Every trigger that carries the tag and isn't in items is cancelled, whichever endpoint created it, unless it's already running or completed. "items": [] cancels all of them. Use a tag that only this list uses.

Each call cancels these triggers that carry tag:

  • One-shot and recurring triggers whose customKey isn't in items.
  • Every keyless trigger from before this call. See Keyless items.
  • One-shot triggers that are already queued (due and waiting to be sent, for example held back by a tag policy), unless their customKey is in items. They don't fire. Queued instances of recurring triggers you keep declaring aren't cancelled.
  • When a recurring trigger is cancelled, its upcoming and queued instances are cancelled with it.

Triggers that are running, completed or already cancelled are never touched, and neither is anything without the tag.

Each trigger cancelled this way gets a cancelled entry in operations. Instances of recurring triggers appear with "kind": "job" and "customKey": null. No entry is added for:

  • queued instances cancelled together with their recurring trigger,
  • the upcoming instance replaced when a recurring item changes,
  • a trigger replaced by a new one (see How the diff works), and the instances cancelled with it when it was recurring.

Keyless items

An item without a customKey can't be matched with anything. Instead, each call cancels every keyless pending trigger that carries the tag and creates the call's keyless items again, with new IDs.

  • Re-sending the same N keyless one-shot items reports added: N and cancelled: N, or skipped: N instead of added: N for past-dated ones. cancelled is lower when some of the old triggers are already running or completed.
  • Keyless triggers that /schedule or /bulk created with the tag are replaced too.
  • Keyless items may repeat within one call.

Give every item a stable customKey unless you really want the whole set replaced on every call. Don't use an empty string as a key: later calls never match or cancel a trigger with that key.

Past-dated items and runMissed

A one-shot item whose time is at or before the moment of your request is stored as skipped and never fires. Its entry says skipped. Two exceptions:

  • A now-based value (now, now | subtract 1h, or no scheduledAt) is never skipped. It fires right away.
  • With "runMissed": true, past-dated items are stored as pending and fire right away. Their entries say added. The flag applies to every item in the call.

This is the same rule as for /schedule (see Past-dated triggers). It doesn't apply to recurring items. Milliseconds are dropped, so a timestamp of the current instant ends up in the past: use now to fire immediately.

Good to know

  • Moving a trigger into the past skips it silently. Without runMissed, the trigger becomes skipped and won't fire, but its entry says updated and summary.skipped stays 0.
  • runMissed alone doesn't revive a skipped trigger. If nothing else changed, the item is unchanged and stays skipped. To make it fire, move it to the future, or send "runMissed": true together with a change to the item.
  • A skipped trigger stays in the tag's set: a later call that leaves it out cancels it.

Recurring items

A cron(...) value in scheduledAt makes the item a recurring trigger. The syntax is the same as for /schedule: see Cron syntax and Time zones.

{
"customKey": "daily-digest",
"scheduledAt": "cron(0 9 * * 1-5, Europe/Paris)",
"url": "https://example.com/webhooks/daily-digest",
"method": "POST",
"headers": {"Content-Type": "application/json"},
"body": {"digest": "daily"}
}

Good to know

  • The first instance comes within about 5 seconds, not during the call as with /schedule. It's set to the next matching time after it's created, so a matching time that falls before then doesn't run. The response has no next-run time.
  • A change replaces the upcoming instance within about 5 seconds. When an item changes, the upcoming instance is cancelled and a new one is created from the new settings within about 5 seconds, where /schedule creates it right away. Instances that are already queued keep their old data and still fire.
  • title isn't shown anywhere. It's stored on the recurring trigger and a change to it reports updated, but the dashboard doesn't display it and instances have no title. To find the runs in the dashboard, search by tag. The method, headers, body and tags are copied onto each instance, as described in How instances are created.
  • runMissed and the past-dated rule don't apply.

Limitation: every call also cancels and re-creates the upcoming instance of each recurring trigger that carries the tag, even when the recurring trigger itself is unchanged. An instance waiting for a retry is cancelled as well, so that retry doesn't happen.

  • These instances appear as cancelled entries with "kind": "job" and "customKey": null, so summary.cancelled isn't 0 even when nothing changed.
  • The new instance is created within about 5 seconds. If your call lands in the last few seconds before a run is due, that run can be skipped.

If every run matters, avoid calling /declare for the tag just before its recurring triggers are due, or create those recurring triggers with /schedule under a tag that no /declare call manages.

Re-declaring a trigger that already fired

A customKey only matches a pending trigger. Once a one-shot trigger is queued, running, completed or cancelled, the next call that still lists its key creates a new trigger with that key and a new ID:

  • With a future time, a now-based time or "runMissed": true, the new trigger is added and fires again. An item without scheduledAt therefore fires again on every call made after its previous trigger became due.
  • With a past absolute time and no runMissed, the new trigger is skipped and never fires. Later calls report it as unchanged.
  • If the old trigger is still queued, for example held back by a tag policy, it isn't cancelled, so both fire.

To fire a one-shot item only once, remove it from the list after your endpoint has received the request. Don't remove it earlier: dropping an item while its trigger is still pending or queued cancels it.

Custom keys outside the tag

customKey is matched only among the triggers that carry tag. A pending trigger elsewhere in your project with the same key isn't seen. If it's the other kind (a one-shot trigger for a cron(...) item, or the other way around), /declare creates a second trigger and reports it added. Your project then has a one-shot and a recurring trigger with the same key, and a cancel by custom key cancels the recurring one first.

Use keys that are unique across your project, for example by prefixing them with the tag (reminders-appointment-42), and don't reuse a key that a pending trigger outside the tag still holds.

If a call fails

Every item is checked before anything is written, so a 400 means nothing changed.

Beyond that, /declare isn't all-or-nothing. If a call fails partway through, the items processed before the failure stay applied and the cancellations, which come last, haven't happened. If a call fails or times out, send the same declaration again: items already applied come back unchanged and the rest is applied. If you need all-or-nothing changes, use /bulk.

Errors

Status codeBodyWhen
400Bad RequestEmptyThe body isn't valid JSON or doesn't match the format: tag, items or an item's url is missing, a field has the wrong type (such as "runMissed": "true" or a header value that isn't a string), or an optional field other than body is null.
400Bad RequestJSONAn item is invalid. See the messages below.
401UnauthorizedJSONInvalid api key if the key doesn't exist, Unauthorized if no key or session was sent.
415TextUnsupported content-type: <type>: the Content-Type isn't application/json.

Messages of the JSON 400 errors:

MessageCause
Duplicate customKey in items: <key>Two items have the same customKey.
Item <key>: duplicate tag "<tag>" in tagsAn item lists the same tag twice.
Item <key>: <error>scheduledAt can't be read. <error> is Invalid date: ..., Invalid duration: ..., Unknown pipe operation: ... or Invalid cron expression: ..., as on /schedule.

<key> is (no customKey) for items without a key, as in Item (no customKey): Invalid date: tomorrow. Items are checked in order and only the first error is returned.

Good to know

  • The Content-Type and the body format are checked before your API key, so a malformed request gets 415 or 400 even without a valid key.
  • POST /declare never returns 402, 404 or 410.
  • See Errors for the error format.

Quota

/declare doesn't use or check your monthly quota. It keeps working when /schedule returns 402. See Quota.

TimeTriggers — Schedule HTTP requests at any time.