> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jelou.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Deferred runs

> Schedule a future run of your function: reminders, follow-ups and abandoned carts with ctx.jelou.schedule, cancellation and fire-time guards.

A **deferred run** is an invocation you book to happen once in the future: a reminder 24 hours out, an abandoned-cart follow-up, a survey 2 days after purchase.

<Info>
  **Cron or deferred?**

  * **[Cron](/en/guides/functions/cron)** — repeats on a fixed schedule (every day at 9:00). Defined in code.
  * **Deferred** — happens once, at a time computed at runtime (24 hours after *this* order). Booked from the handler or from the HTTP request.
</Info>

## Book from the handler

`ctx.jelou.schedule()` books a run relative to "now":

```typescript index.ts theme={null}
import { define, z } from "@jelou/functions";

export default define({
  name: "abandoned-cart",
  description: "Receives the abandoned-cart event and books the follow-up",
  input: z.object({
    userId: z.string(),
    cartId: z.string(),
  }),
  handler: async (input, ctx) => {
    const booking = await ctx.jelou.schedule({
      in: "24h",
      path: "/send-followup",
      // Include the botId: no channel is resolved at fire time
      payload: { userId: input.userId, cartId: input.cartId, botId: ctx.bot.id },
      key: `cart:${input.userId}:${input.cartId}`,
      subject: `user_${input.userId}`,
    });

    ctx.log("Follow-up booked", { id: booking.id, when: booking.scheduledAt });
    return { booked: true, id: booking.id };
  },
});
```

### Parameters

| Field            | Type               | Required | Description                                              |
| ---------------- | ------------------ | -------- | -------------------------------------------------------- |
| `in`             | `string \| number` | Yes      | Duration: `"30m"`, `"24h"`, `"1h30m"`, `"3d"` or seconds |
| `path`           | `string`           | Yes      | Route of your function that will run. Starts with `/`    |
| `payload`        | `object \| array`  | No       | Data the handler receives when it fires                  |
| `key`            | `string`           | No       | Tag for looking up or cancelling later                   |
| `subject`        | `string`           | No       | Audience, typically the user ID                          |
| `idempotencyKey` | `string`           | No       | Prevents double-booking on retries                       |
| `pinDeployment`  | `boolean`          | No       | Pins the run to the current deployment                   |

Durations accept compound units from largest to smallest: `"1h30m"` is valid, `"30m1h"` is not.

<Warning>
  For very short follow-ups use **5 seconds or more**. Below that, network latency can push the booked time into the past and the platform rejects it.
</Warning>

### Absolute time

`ctx.jelou.scheduleAt()` takes a date instead of a duration:

```typescript theme={null}
await ctx.jelou.scheduleAt({
  at: new Date("2026-12-24T18:00:00Z"), // or the ISO 8601 string
  path: "/christmas-greeting",
  payload: { userId: input.userId },
});
```

It throws if the date is in the past or unparseable. Every other field behaves like in `schedule()`.

### Avoid duplicates

If the service calling your function retries, `idempotencyKey` guarantees a single booking:

```typescript theme={null}
const booking = await ctx.jelou.schedule({
  in: "24h",
  path: "/send-followup",
  payload: { userId: input.userId, cartId: input.cartId },
  idempotencyKey: `abandoned-cart:${input.eventId}`,
});

if (booking.idempotentReplay) {
  ctx.log("Already booked, not duplicated", { id: booking.id });
}
```

Reusing the same `idempotencyKey` with a different `payload` returns a `409` error.

## Book from an HTTP request

Any caller can defer a call by adding a header to the normal POST. Without schedule headers, the function runs immediately as always.

```bash theme={null}
curl -X POST https://reminders.fn.jelou.ai/send-reminder \
  -H "Authorization: Bearer <your-api-key>" \
  -H "X-Jelou-Schedule-In: 5m" \
  -H "X-Jelou-Key: reminder:user-42" \
  -H "Content-Type: application/json" \
  -d '{"userId":"42","message":"Your appointment is tomorrow"}'
```

| Header                   | Effect                                           |
| ------------------------ | ------------------------------------------------ |
| `X-Jelou-Schedule-In`    | Duration (`"5m"`, `"1h30m"`, `"24h"` or seconds) |
| `X-Jelou-Schedule-At`    | Absolute ISO 8601 timestamp                      |
| `X-Jelou-Key`            | Tag for looking up or cancelling later           |
| `X-Jelou-Subject`        | Audience, typically the user ID                  |
| `X-Jelou-Pin-Deployment` | `"true"` to pin the current deployment           |
| `Idempotency-Key`        | Prevents double-booking on retries               |

The response is `202 Accepted` when the booking is created and `200 OK` when a retry reuses an existing booking. The body cannot exceed **64 KB**.

## Receive the fire

When the time comes, your function receives the original `payload` at the path you specified. Use `ctx.isScheduledFire` to tell the fire apart from a normal request:

```typescript theme={null}
handler: async (input, ctx) => {
  if (ctx.isScheduledFire) {
    ctx.log("Deferred run fired");
  }
  // ...
}
```

### Check before acting

Between booking and firing, reality can change: the customer already paid, the template got paused, the user opted out. `ctx.guard` chains those checks and skips the send if any of them fails:

```typescript theme={null}
export default define({
  name: "send-followup",
  description: "Sends the abandoned-cart follow-up if it still applies",
  input: z.object({
    userId: z.string(),
    cartId: z.string(),
    botId: z.string(),
  }),
  handler: async (input, ctx) => {
    // The botId travelled in the payload you booked earlier
    const registry = ctx.templateRegistry.for(input.botId);

    return ctx.guard
      .when("notYetPaid", async () => !(await fetchCart(input.cartId)).paid)
      .when("templateApproved", () => registry.has("abandoned_cart_v3"))
      .run(async () => {
        await ctx.jelou.sendTemplate({
          template: "abandoned_cart_v3",
          to: input.userId,
          params: ["María", `https://shop.com/recover?c=${input.cartId}`],
        });
        return { delivered: true };
      });
  },
});
```

If every check passes, `.run()` executes and its result is the response. If any fails, the chain short-circuits and returns `{ skipped: "<name>" }` without running the send.

<Note>
  The chain terminates with `.run(handler)`, not `.then()`. `ctx.guard` is a fresh chain on every request.
</Note>

<Warning>
  If the bot does not travel in the `payload`, `ctx.bot` is not resolved on a deferred fire and `ctx.templateRegistry` has no channel bound. Include the `botId` in the `payload` when booking and bind it with `ctx.templateRegistry.for(botId)`. See [WhatsApp templates](/en/guides/functions/mensajeria#validate-templates-before-sending).
</Warning>

## Look up what is booked

`ctx.jelou.findDeferred()` lists your function's pending runs:

```typescript theme={null}
const { data, total } = await ctx.jelou.findDeferred({
  subject: `user_${input.userId}`,
  status: "scheduled",
});

for (const row of data) {
  ctx.log("pending", row.id, row.scheduledAt, row.key);
}
```

| Field              | Description                                                                           |
| ------------------ | ------------------------------------------------------------------------------------- |
| `key`              | Filter by exact tag                                                                   |
| `subject`          | Filter by audience                                                                    |
| `status`           | `"active"` (default), `"scheduled"`, `"firing"`, `"fired"`, `"cancelled"`, `"failed"` |
| `page` / `perPage` | Pagination. Default `20`, max `100`                                                   |

## Cancel

`ctx.jelou.cancelDefer()` cancels in bulk by audience or tag. Exactly one of `subject`, `key` or `keyPrefix` is required:

```typescript theme={null}
// Opt-out: cancel every pending reminder for this user
const { cancelled, raced } = await ctx.jelou.cancelDefer({
  subject: `user_${input.userId}`,
});

ctx.log("opt-out processed", { cancelled: cancelled.length, in_flight: raced.length });
```

`raced` holds the runs that were already firing when the cancel arrived — for those, the `ctx.guard` check inside the handler is the last line of defence. Write your handlers to be idempotent.

Before a broad cancellation, preview the scope with `dryRun`:

```typescript theme={null}
const preview = await ctx.jelou.cancelDefer({
  keyPrefix: "summer-promo:",
  dryRun: true,
});

if (preview.wouldCancelCount < 5000) {
  await ctx.jelou.cancelDefer({ keyPrefix: "summer-promo:" });
}
```

<Note>
  There is no "reschedule": a booking is immutable. To change the time, cancel and book again.
</Note>

## Local testing

`schedule`, `scheduleAt`, `findDeferred` and `cancelDefer` only work in a **deployed** function. Locally they throw because there are no platform credentials.

To exercise the flow with `jelou functions dev`, turn on simulation:

```bash theme={null}
JELOU_FN_DEFER_DEV=1 jelou functions dev
```

In that mode arguments are validated, the booking is logged, and a synthetic result is returned — your handler keeps running, but nothing real is booked.

For unit tests use [`createMockContext`](/en/guides/functions/testing), whose `ctx.jelou` records the calls with no network or setup.

## Inspect from the CLI

```bash theme={null}
# Active bookings for the current project's function
jelou functions defer list

# The ones that failed
jelou functions defer list my-fn --status failed

# The ones for one user
jelou functions defer list my-fn --subject user_42

# Detail of one booking (includes the last error)
jelou functions defer get dinv_01ksqbvyqpe0prt91exc97mh4n
```

See the [CLI reference](/en/guides/functions/cli#deferred-runs).

## Limits

| Limit                       | Value          |
| --------------------------- | -------------- |
| Active bookings per company | 1,000          |
| Maximum lead time           | 30 days        |
| `payload` size              | 64 KB          |
| Bulk cancel per call        | 10,000 matches |

Bookings that already fired, were cancelled or failed do not count against the active limit.

## Common problems

<Tabs>
  <Tab title="It never fired">
    Check the booking's status and last error:

    ```bash theme={null}
    jelou functions defer get dinv_01ksqbvy... --payload
    ```

    If the status is `failed`, the error field says why delivery failed. If it is `cancelled`, something cancelled it first — review your `cancelDefer` calls.
  </Tab>

  <Tab title="It sent even though I cancelled">
    A cancel that arrives once the run already started does not stop it. It shows up in `raced` and your handler runs.

    That is why the check belongs in the handler, not only in the cancel:

    ```typescript theme={null}
    return ctx.guard
      .when("stillActive", async () => await userActive(input.userId))
      .run(async () => { /* send */ });
    ```
  </Tab>

  <Tab title="Error in local dev">
    ```
    scheduleAt unavailable: platform credentials missing
    ```

    You are calling the `schedule` family locally. Start the server with `JELOU_FN_DEFER_DEV=1 jelou functions dev` to simulate bookings.
  </Tab>

  <Tab title="It booked twice">
    The service calling your function retried. Add `idempotencyKey` (or the `Idempotency-Key` header) with a value derived from the event:

    ```typescript theme={null}
    idempotencyKey: `abandoned-cart:${input.eventId}`
    ```
  </Tab>
</Tabs>

<CardGroup cols={2}>
  <Card title="Cron" icon="clock" href="/en/guides/functions/cron">
    Recurring tasks on a fixed schedule.
  </Card>

  <Card title="Messaging" icon="message" href="/en/guides/functions/mensajeria">
    Send WhatsApp and validate templates.
  </Card>

  <Card title="Webhooks" icon="shield-check" href="/en/guides/functions/webhooks">
    Verify signatures from external services.
  </Card>

  <Card title="CLI" icon="terminal" href="/en/guides/functions/cli">
    The `defer list` and `defer get` commands.
  </Card>
</CardGroup>
