Fan-out / fan-in

You have a list of items. Each one needs an external API call — slow, rate-limited, occasionally flaky. When all of them are done, something else has to happen.

Doing that in one request handler doesn't work: it takes minutes, and a deployment in the middle kills it. So you fan the work out into TimeTriggers, one trigger per item. Then you hit the hard part — which one was last?

A batch answers that. It is a set of triggers plus one continuation trigger that we fire exactly once, when every member has finished.

POST /batches ──┬── member 1 ─┐
├── member 2 ─┤ ordinary triggers: tags, tag policies,
├── … ├─ retries, executions, dashboard
└── member N ─┘
│ all finished
▼
continuation ──► POST https://your.app/checks-done

Why not just count in your own database

You can, and the counting itself is one statement:

UPDATE batches SET remaining = remaining - 1 WHERE id = $1 RETURNING remaining;
-- fire the follow-up when it returns 0

That's atomic and race-free. It works right up until a member never decrements — it exhausted its retries and became a dead letter, it was cancelled, it was past-dated and skipped, or the pod running it died. Your app can't see any of that; we're the ones making the calls.

That gap is what a batch is for. Everything else here follows from it.

Create a batch

Members are ordinary triggers. They are not rate-limited by the batch — give them a tag and the tag's policy provides throughput windows, a concurrency cap, timeouts and retries, exactly as it does for any other trigger.

curl -X POST https://api.timetriggers.io/batches \
-H "ttr-api-key: $TTR_API_KEY" \
-H "content-type: application/json" \
-d '{
"customKey": "inventory-check-2026-09-19",
"title": "Nightly inventory check",
"tags": ["inventory-check"],
"expiresAt": "now | add 2h",
"continuation": {
"url": "https://your.app/internal/inventory-check-done",
"method": "POST",
"body": { "run": "2026-09-19" }
},
"items": [
{ "customKey": "item-1", "url": "https://your.app/internal/check?id=1", "method": "POST" },
{ "customKey": "item-2", "url": "https://your.app/internal/check?id=2", "method": "POST" }
]
}'
FieldDescription
customKeyIdempotency handle. Re-POSTing a key that matches a live batch returns the existing batch instead of fanning out again — which is what lets you retry the create across a deploy. The response's idempotentReplay tells you which happened.
titleFree-form label, shown in the dashboard.
tagsMerged into every member's own tags. This is how you rate-limit the fan-out.
expiresAtRequired. Same date DSL as ttr-scheduled-at, and must be in the future.
sealDefault true. See Adding members later.
continuationThe follow-up request. Same shape as an item, minus scheduledAt — fan-in decides when it runs.
itemsThe members. Same shape as /bulk's upserts. Up to 1000 per call.

Everything runs in one transaction: either the whole batch exists or none of it does. There is no window in which half a batch is armed.

expiresAt is required, and that's deliberate

A batch with no deadline can hang forever on one member that never finishes — which is the exact failure you came here to avoid, moved inside our system where you can't fix it.

So every batch carries a deadline. When it passes, still-pending members are cancelled and the continuation fires anyway, carrying ttr-batch-reason: timed_out.

The guarantee is "the continuation fires exactly once". It is not "the continuation fires once everything succeeded" — those are different promises, and only the first one can be kept unconditionally.

What the continuation receives

The continuation fires with exactly the body you declared — we never rewrite it. The outcome arrives as headers:

HeaderExample
ttr-batch-idheader7f3a…21bc
ttr-batch-keyheaderinventory-check-2026-09-19
ttr-batch-reasonheadercompleted or timed_out
ttr-batch-totalheader120
ttr-batch-succeededheader118
ttr-batch-failedheader2
ttr-batch-cancelledheader0
ttr-batch-skippedheader0
ttr-batch-runningheader0

If you only need "we're done, 2 failed", read the headers and never call back. If you need to know which items failed, ask for them:

curl "https://api.timetriggers.io/batches/$BATCH_ID/items?outcome=failed" \
-H "ttr-api-key: $TTR_API_KEY"

We don't inline per-item results into the continuation's body on purpose: that payload would grow with the size of your batch, which is precisely what breaks at ten thousand items.

A member counts as succeeded when its last attempt returned a 2xx. Anything else terminal — a non-2xx, a network error, an exhausted retry budget — is failed.

The continuation is itself an ordinary trigger. It gets retries, a tag policy if you tag it, dashboard visibility and a replay button, like anything else.

Adding members later

If you already have every item, the default (seal: true) is all you need.

If you discover items by paging an upstream API, create the batch with seal: false, add members as you find them, and seal when you're done:

curl -X POST ".../batches/$BATCH_ID/items" -d '{"items": [ … ]}' # repeat
curl -X POST ".../batches/$BATCH_ID/seal"

An open batch never fans in, however finished its members look. That's the point: without it, a fast member 1 that finishes before member 2 is even scheduled would fire your follow-up on a batch of one.

Adding to a batch that is already running

Adding to a sealed batch is safe but not always possible. "Running" can become "completed" between your decision to add and the add arriving. If we latched first, the continuation has already fired and there is no correct way to accept the new member — you get a 409.

There is one case that can never hit the 409:

A still-running member is its own guard. If member M hasn't finished, the batch provably can't have completed. So a member adding work to its own batch, from inside its own handler, is always accepted — which is how you do recursive discovery without leaving the batch open indefinitely.

When something failed

Fixed the bug and want the follow-up to run for real?

curl -X POST ".../batches/$BATCH_ID/retry-failed" -H "ttr-api-key: $TTR_API_KEY"

Every failed member is replayed and fan-in is re-armed, so the continuation runs again once the replays settle.

Lifecycle

StatusMeaning
openMembers can still be added. Fan-in is not armed.
sealedMembership frozen, fan-in armed.
completedEvery member finished; the continuation was created.
timed_outThe deadline passed. Stragglers cancelled, continuation fired anyway.
cancelledYou abandoned the batch. Pending members cancelled, no continuation.

All endpoints

Authentication is the same as /declare: an ttr-api-key header, an Authorization: Bearer ttr_…, or a dashboard session.

EndpointPurpose
POST /batchesCreate (and by default seal) a batch
POST /batches/{id}/itemsAdd members
POST /batches/{id}/sealFreeze membership, arm fan-in
GET /batchesList; filter by status, customKey
GET /batches/{id}One batch, with outcome counts
GET /batches/{id}/itemsMembers; filter by outcome
POST /batches/{id}/retry-failedReplay failed members, re-arm fan-in
DELETE /batches/{id}Cancel pending members, skip the continuation

What a batch is not

It tracks scheduling, not your business state. We know that a call returned 2xx; we don't know that item 47 failed validation, and a handler that catches its own error and returns 200 looks like a success to us. Keep your own per-item results if you need them — what you no longer need is the coordination.

Batches also don't nest. If your follow-up work is itself a fan-out, the continuation creates a new batch. Chaining composes; nesting doesn't.

TimeTriggers — Schedule HTTP requests at any time.