Bulk apply
POST /bulk applies a batch of changes to your project in one call:
upsertscreates triggers, or updates the ones that already have the samecustomKey.cancelscancels 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 against | Your whole project | Triggers carrying the request's tag |
| Triggers you don't mention | Left alone | Cancelled, if they carry the tag |
Items without customKey | Always create a new trigger | Replaced on every call |
| Tags stored | Exactly the item's tags | The 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 trigger | Its upcoming run keeps the old settings (details) | Its upcoming run is replaced |
| Explicit cancels | Yes, by ID, custom key or tag | No |
| All or nothing | Yes | No |
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.
| Field | Type | Default | Description |
|---|---|---|---|
upserts | array of items | [] | Triggers to create or update, applied in order. See Upserts. |
cancels | array of targets | [] | Triggers to cancel, applied in order after all upserts. See Cancels. |
runMissed | boolean | false | Fire 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.nullis rejected with400, although the OpenAPI spec marks these fields as nullable. - Unknown fields are ignored. A body shaped for
/declare({"tag": ..., "items": [...]}) returns200and 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:
urlis required.scheduledAtuses the same syntax asttr-scheduled-atheader and defaults tonow. 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,bodyandtagsare optional.
How items are matched
- Without
customKey, an item always creates a new trigger. - With
customKey,/bulklooks 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,skippedorretrying; - a
cron(...)item matches an active recurring trigger.
- a one-shot item matches a one-shot trigger that is
What happens next:
| Situation | Result | operation |
|---|---|---|
| A trigger matches and every field is identical | Nothing is written. | unchanged |
| A trigger matches and something differs | It's updated in place and keeps its ID. changedFields lists what changed. | updated |
| No trigger matches | A 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,completedorcancelled, 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-basedscheduledAt(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
customKeyin one call are rejected with400, 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 out | Becomes |
|---|---|
scheduledAt | now: 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 |
method | GET |
headers | No headers |
body | No body |
title | No title |
tags | No 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 noscheduledAt), which is never skipped; or - the request has
"runMissed": true. The trigger is then reported asaddedand 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:
| Target | Example | Matches |
|---|---|---|
| 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 ornull, rejects the whole request with400.
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,skippedorretryingis 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,queuedorretrying. 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
cancelsappears 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 ascancelled. - 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
400means nothing changed, cancels included.
Response
A successful call returns 200 with a summary and one entry per operation:
| Field | Description |
|---|---|
summary | Counts of operations entries per type: unchanged, added, skipped, updated, cancelled. |
operations | One entry per upsert, in order, then one per cancelled trigger, in order. |
operations[].operation | added, skipped (new past-dated one-shot trigger that won't fire), updated, unchanged or cancelled. |
operations[].customKey | The 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[].kind | job for a one-shot trigger or an instance, generator for a recurring trigger. |
operations[].id | The trigger's ID. Use it with /cancel or /schedule. |
operations[].changedFields | Only 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 notagfield. Unlike/schedule, it has noscheduledAtand nomonthQuotaRemaining.
Status codes
| Status code | Message | When |
|---|---|---|
| 200OK | See Response | The 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 Request | Duplicate customKey in upserts: <key> | Two upserts have the same customKey. |
| 400Bad Request | Upsert <key>: duplicate tag "<tag>" in tags | An item lists a tag twice. |
| 400Bad Request | Upsert <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: .... |
| 401Unauthorized | Unauthorized | No API key and no dashboard session. |
| 401Unauthorized | Invalid api key | The key in ttr-api-keyheader or Authorization: Bearer doesn't exist. |
| 415 | Unsupported 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
POSTis accepted. Other methods get404with an empty body. /bulknever returns402: it doesn't use quota and keeps working when your monthly quota is used up.- It never returns
404or410for a cancel target; see Repeating a cancel.