Bulk apply

POST /bulk applies a batch of changes to your project in one call:

  • upserts creates triggers, or updates the ones that already have the same customKey.
  • cancels cancels triggers by ID, custom key or tag.

Either every change is applied, or none is.

curl -X POST https://api.timetriggers.io/bulk \
-H "ttr-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"upserts": [
{
"customKey": "appointment-42-reminder",
"title": "Reminder for appointment 42",
"scheduledAt": "2030-03-13T09:00:00Z",
"url": "https://example.com/webhooks/reminder",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": { "appointmentId": 42 },
"tags": ["reminders"]
},
{
"customKey": "daily-report",
"scheduledAt": "cron(0 9 * * 1-5, Europe/Paris)",
"url": "https://example.com/webhooks/daily-report"
}
],
"cancels": [
"appointment-41-reminder",
{ "tag": "trial-reminders" }
]
}'
{
"summary": { "unchanged": 0, "added": 1, "skipped": 0, "updated": 1, "cancelled": 2 },
"operations": [
{ "operation": "updated", "customKey": "appointment-42-reminder", "kind": "job", "id": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f", "changedFields": ["scheduledAt"] },
{ "operation": "added", "customKey": "daily-report", "kind": "generator", "id": "8a1d4f2e-5b6c-4d7e-8f90-1a2b3c4d5e6f" },
{ "operation": "cancelled", "customKey": "appointment-41-reminder", "kind": "job", "id": "c7e9a2b4-1d3f-4e5a-9b8c-7d6e5f4a3b2c" },
{ "operation": "cancelled", "customKey": "trial-ending-user-7", "kind": "job", "id": "5d8e1f3a-2b4c-4d6e-8f1a-3b5c7d9e1f2a" }
]
}

Here the reminder for appointment 42 already existed and was moved to a new time, daily-report is a new recurring trigger, appointment-41-reminder was cancelled, and one pending trigger carried the trial-reminders tag.

Bulk or declare

/bulk and /declare take the same items. With /bulk you send changes; with /declare you send the complete list of triggers that should carry a tag.

/bulk/declare
A customKey is matched againstYour whole projectTriggers carrying the request's tag
Triggers you don't mentionLeft aloneCancelled, if they carry the tag
Items without customKeyAlways create a new triggerReplaced on every call
Tags storedExactly the item's tagsThe item's tags plus the request's tag
Same key, other kind (one-shot or recurring)A second trigger is created (details)The trigger switches kind
Changing a recurring triggerIts upcoming run keeps the old settings (details)Its upcoming run is replaced
Explicit cancelsYes, by ID, custom key or tagNo
All or nothingYesNo

Compared with /schedule, /bulk takes a JSON body instead of ttr- headers, handles many triggers per call and doesn't use quota. It can't update a trigger by its ID, only by customKey, and it doesn't validate the URL or the tag format; see Item format.

Request

Send POST /bulk with a JSON body and Content-Type: application/json. Authenticate with ttr-api-keyheader or Authorization: Bearer YOUR_API_KEY; see Authentication.

FieldTypeDefaultDescription
upsertsarray of items[]Triggers to create or update, applied in order. See Upserts.
cancelsarray of targets[]Triggers to cancel, applied in order after all upserts. See Cancels.
runMissedbooleanfalseFire one-shot upserts whose time is already past instead of skipping them. Applies to every upsert in the call. See Past-dated items.

Good to know

  • Every field is optional, but the body isn't: send at least {}, which changes nothing and returns zero counts.
  • Leave a field out instead of sending null. null is rejected with 400, although the OpenAPI spec marks these fields as nullable.
  • Unknown fields are ignored. A body shaped for /declare ({"tag": ..., "items": [...]}) returns 200 and changes nothing.

Upserts

Each item in upserts describes one trigger. The fields are the same as for /declare and are described in Item format. In short:

  • url is required.
  • scheduledAt uses the same syntax as ttr-scheduled-atheader and defaults to now. A date creates a one-shot trigger ("kind": "job"), cron(...) a recurring trigger ("kind": "generator"). A new recurring trigger gets its first instance within about 5 seconds, at the next matching time after that.
  • customKey, title, method, headers, body and tags are optional.

How items are matched

  • Without customKey, an item always creates a new trigger.
  • With customKey, /bulk looks in your whole project for a trigger of the same kind with that key, whatever its tags and whichever endpoint created it:
    • a one-shot item matches a one-shot trigger that is registered, skipped or retrying;
    • a cron(...) item matches an active recurring trigger.

What happens next:

SituationResultoperation
A trigger matches and every field is identicalNothing is written.unchanged
A trigger matches and something differsIt's updated in place and keeps its ID. changedFields lists what changed.updated
No trigger matchesA new trigger is created.added, or skipped for a past-dated one-shot

Good to know

  • If the trigger with that key is already queued, running, completed or cancelled, nothing matches. A new trigger is created and the old one is left alone, so a queued one still fires and your target receives both requests. See Trigger statuses.
  • A now-based scheduledAt (including an omitted one) resolves to a new time on every call. Re-sending such an item moves a pending trigger to the new time, or schedules it again if it has already fired.
  • Two items with the same customKey in one call are rejected with 400, even if one is one-shot and the other recurring. Items without a key can repeat.

An upsert replaces the whole trigger

An update isn't a patch. Every field you leave out goes back to its default:

Field left outBecomes
scheduledAtnow: a matching one-shot trigger is moved to fire right away. On a recurring trigger's key, the item is a separate one-shot trigger that fires right away
methodGET
headersNo headers
bodyNo body
titleNo title
tagsNo tags
Time zone in cron(...)UTC

Always send the complete item. /bulk adds no tag of its own: if an item matches a trigger created by /declare and leaves out that request's tag, the tag is removed and the trigger is no longer managed by /declare calls for that tag.

Past-dated items

A new one-shot item whose scheduledAt is already past is stored as skipped and never fires, unless:

  • its time is based on now (now, now | subtract 1h, or no scheduledAt), which is never skipped; or
  • the request has "runMissed": true. The trigger is then reported as added and fires right away.

Updates follow the same rules as /declare: moving a pending trigger into the past skips it although its entry says updated, and runMissed alone doesn't revive a skipped trigger. See Past-dated items and runMissed, and Past-dated triggers for how the past is determined.

Triggers waiting to retry

If a changed item matches a trigger that is retrying (it failed and waits for its next retry), that trigger is cancelled, so its pending retry never runs, and a new trigger is created from the item. The updated operation carries the new ID. An identical item returns unchanged and leaves the trigger retrying.

The new trigger gets only the item's tags, so keep the tag that carries your retry policy in the item if you want it to apply.

One-shot and recurring triggers with the same key

/bulk never switches a trigger between one-shot and recurring. A one-shot item that uses the key of an active recurring trigger creates a separate one-shot trigger, and the recurring trigger keeps running. The reverse is also true. Both triggers then share the key, and a cancel by that key (with /cancel or in cancels) hits the recurring trigger first; a second cancel by the same key hits the one-shot trigger.

To replace one kind with the other in a single call, upsert the new item and cancel the old trigger by its ID ({"id": "..."}) in cancels. A cancel by key would hit the recurring trigger, which may be the one you just created. /schedule switches kinds for you.

Updating a recurring trigger

A cron(...) item that matches an active recurring trigger updates it in place: cron expression, time zone, URL, method, headers, body, title and tags. The response has the same ID, "operation": "updated" and changedFields, but no next run time.

The upcoming run keeps its old settings. A recurring trigger's next run is created in advance, and an update through /bulk doesn't replace it. That run still fires at its original time, with the old URL, method, headers, body and tags, and the tag policies of the old tags. The new settings apply from the run after it. On a rare schedule this can take a long time: changing a yearly schedule to every minute has no effect until the yearly run comes due.

To apply a change to the very next run, edit the recurring trigger with /schedule instead, by its ID or custom key; /declare also replaces the upcoming run of the recurring triggers it manages. With /bulk, cancel the recurring trigger in one call and upsert it in a later call; it gets a new ID.

Cancels

Each entry in cancels is one of these targets:

TargetExampleMatches
A string"appointment-41-reminder"A trigger whose ID or custom key equals the string
{"id": ...}{"id": "3f2b8c1e-7d4a-4e5b-9c2f-1a2b3c4d5e6f"}The ID of a one-shot trigger, an instance or a recurring trigger
{"customKey": ...}{"customKey": "appointment-41-reminder"}The custom key only
{"tag": ...}{"tag": "trial-reminders"}Every cancellable trigger carrying the tag

Matching is exact and case-sensitive: no prefixes, no wildcards. Only triggers in your project are matched.

Good to know

  • Put one field in each target object. If an object has several, only one is used, in the order customKey, id, tag, with no fallback to the next: {"customKey": "missing", "id": "..."} cancels nothing.
  • Any other entry, such as {}, {"key": "..."}, a number or null, rejects the whole request with 400.

Cancel by ID or custom key

A string, {"id"} or {"customKey"} target cancels at most one trigger. Recurring triggers are checked first:

  • Active recurring trigger: it's cancelled, together with its upcoming instance. Instances whose time has already come (queued or retrying) aren't cancelled and still fire.
  • Otherwise, a one-shot trigger or instance that is registered, skipped or retrying is cancelled. A retrying trigger makes no further attempts.
  • Queued, running, completed or cancelled triggers are left alone. A queued trigger can't be stopped by ID or key. If it carries a tag, a cancel by tag stops it, along with every other trigger with that tag.

Instances have no custom key, so you can only target them by ID. Cancelling an upcoming instance doesn't skip a run: the recurring trigger creates a new instance for the same time within seconds. See Cancel a trigger.

Cancel by tag

A {"tag"} target cancels everything carrying that tag that isn't running or finished:

  • every active recurring trigger with the tag, together with its upcoming instance;
  • every one-shot trigger and instance with the tag that is registered, skipped, queued or retrying. Queued triggers cancelled this way don't fire.

Running and completed triggers are left alone. Instances carry their recurring trigger's tags, so if you give a recurring trigger a tag only it uses, a cancel by that tag also stops its queued and retrying instances.

Repeating a cancel

Targets that don't exist or can't be cancelled anymore are skipped silently: no operation entry, no error. Re-sending the same cancels is safe, and a target listed twice in one call counts once.

The exception is a key shared by a one-shot and a recurring trigger: each string or {"customKey"} cancel by that key cancels one of them, the recurring trigger first. Listing the key twice, or re-sending the cancel, cancels both.

Order and atomicity

  • All upserts run first, in array order, then all cancels, in array order, wherever cancels appears in your JSON.
  • A cancel can target a trigger upserted in the same call. It then appears twice in operations: first with its upsert result, then as cancelled.
  • You can't cancel and re-create the same key in one call: the cancel runs last and would hit the trigger the upsert just created or updated.
  • All changes are applied together or not at all. Every upsert is validated before anything is written, so a 400 means nothing changed, cancels included.

Response

A successful call returns 200 with a summary and one entry per operation:

FieldDescription
summaryCounts of operations entries per type: unchanged, added, skipped, updated, cancelled.
operationsOne entry per upsert, in order, then one per cancelled trigger, in order.
operations[].operationadded, skipped (new past-dated one-shot trigger that won't fire), updated, unchanged or cancelled.
operations[].customKeyThe item's key, or null. For a cancel: the key you sent with {"customKey"}, otherwise the cancelled trigger's own key, or null if it has none.
operations[].kindjob for a one-shot trigger or an instance, generator for a recurring trigger.
operations[].idThe trigger's ID. Use it with /cancel or /schedule.
operations[].changedFieldsOnly on updated entries. For one-shot triggers: scheduledAt, url, method, headers, body, title, tags. For recurring triggers: cronExpr, tz, url, method, headers, body, title, tags.

Good to know

  • A tag cancel adds one entry per cancelled trigger: recurring triggers first, then one-shot triggers and instances. Instances have "customKey": null.
  • The upcoming instance cancelled along with a recurring trigger gets no entry of its own and isn't counted.
  • Unlike /declare, the response has no tag field. Unlike /schedule, it has no scheduledAt and no monthQuotaRemaining.

Status codes

Status codeMessageWhen
200OKSee ResponseThe changes were applied.
400Bad Request(empty body)The body isn't valid JSON or doesn't match the format: it's empty, an item has no url, a value has the wrong type, a field other than an item's body is null, or a cancel target has an unknown shape. This is checked before your API key.
400Bad RequestDuplicate customKey in upserts: <key>Two upserts have the same customKey.
400Bad RequestUpsert <key>: duplicate tag "<tag>" in tagsAn item lists a tag twice.
400Bad RequestUpsert <key>: <error>An item's scheduledAt can't be read, for example Upsert appointment-42-reminder: Invalid date: tomorrow or Upsert daily-report: Invalid cron expression: ....
401UnauthorizedUnauthorizedNo API key and no dashboard session.
401UnauthorizedInvalid api keyThe key in ttr-api-keyheader or Authorization: Bearer doesn't exist.
415Unsupported content-type: <type>A Content-Type other than application/json is sent, for example curl's -d default, application/x-www-form-urlencoded. The body is plain text.

For items without a key, <key> reads (no customKey). The 400 and 401 errors with a message have a JSON body such as {"_tag": "BadRequest", "message": "Duplicate customKey in upserts: daily-report"}; see Errors.

Good to know

  • Only POST is accepted. Other methods get 404 with an empty body.
  • /bulk never returns 402: it doesn't use quota and keeps working when your monthly quota is used up.
  • It never returns 404 or 410 for a cancel target; see Repeating a cancel.

TimeTriggers — Schedule HTTP requests at any time.