Guides

Schedules and webhooks

Schedule one-time or recurring events and verify signed delivery.

Schedules fire durable events. Two closed action types exist, derived from the create body's shape: webhook delivers the stored payload to an organization-owned webhook endpoint, and agent_run (agent_id + message) creates a durable agent run carrying the stored task message.

Create a webhook endpoint

An org-admin registers an HTTPS URL:

curl -H "Authorization: Bearer $ORG_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/webhook-endpoints" -d '{
  "name":"Application events",
  "url":"https://app.example.com/webhooks/deeplinq"
}'

Copy the returned signing secret immediately. Later list responses expose only a masked hint.

Endpoint URLs are SSRF-guarded and HTTPS-only outside explicit hermetic development mode.

Verify delivery signatures

Deeplinq sends the signature in X-Signature:

t=<unix-seconds>,v1=<hex-hmac>

Compute:

HMAC-SHA256(endpoint_secret, timestamp + "." + exact_request_body_bytes)

Compare the decoded MAC in constant time and reject timestamps outside a short replay window. Verify against the exact bytes received before parsing JSON; re-serializing the payload changes the signature.

Create a one-time schedule

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/schedules" -d '{
  "kind":"once",
  "fire_at":"2026-08-01T09:00:00Z",
  "endpoint_id":"<endpoint-id>",
  "payload":{"type":"follow_up","case_id":"case-1842"}
}'

Create a recurring schedule

curl -H "Authorization: Bearer $DEEPLINQ_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST "$BASE_URL/v1/schedules" -d '{
  "kind":"cron",
  "cron":"0 9 * * 1",
  "timezone":"Europe/Lisbon",
  "runs_left":12,
  "endpoint_id":"<endpoint-id>",
  "payload":{"type":"weekly_review"}
}'

Cron expressions use plain five-field POSIX syntax. Use until_at or runs_left to bound recurring work.

Schedule an agent run

Set agent_id and message on the create body — no endpoint_id, no payload; both are rejected alongside message. When due, the schedule creates a durable run for that agent and owner carrying the stored task message, instead of enqueueing webhook delivery:

{
  "kind": "cron",
  "cron": "0 8 * * 1-5",
  "timezone": "Europe/Lisbon",
  "agent_id": "<agent-id>",
  "message": "Prepare the morning mailbox summary"
}

The message is screened through the agent's input guardrail at create and stored as the task; each fire's run executes exactly that text. Create answers 404 for an agent the caller cannot see and 409 for one that is disabled or knowledge-backed, before any row is written.

A task may also say which mailbox it uses when several are connected: connection_ids, on either message shape, names the caller's own connections every fire's run reaches through (see Choose which mailbox a run uses). It is stored with the task, echoed as connection_ids on the schedule, and copied onto every run the schedule fires. Each id must be one of the caller's own active connections (404 otherwise); the field is rejected on the webhook and compatibility shapes.

A task may also name the dataset it reads: dataset_id, on either message shape, is the dataset every fire's run is started with (see Name the dataset a run reads). It is checked at create with the identity the fired runs will execute under, which carries no roles — the caller must own the dataset, hold a direct user or team grant on it, own the project it belongs to, or hold a user or team grant on that project, so one shared only through a role is refused here rather than on the first fire — and unknown, unreadable and malformed ids all answer 404. It is stored with the task in canonical form, echoed as dataset_id on the schedule, and copied onto every run the schedule fires. Like connection_ids, it is rejected on the webhook and compatibility shapes: with agent_id and no message the refusal is dataset_id requires message, and with neither it is model, tools, connection_ids and dataset_id require message. Behind both sits the same rule stated once more in the service — only an agent_run task chooses a dataset — so a task that is not an agent run can never carry one.

project_id is where every fired run's thread LIVES and, for a task that names no dataset, what every fired run READS. Unlike connection_ids and dataset_id it is a top-level field accepted on every shape, not a task field: it predates them, and on a webhook schedule it is delivery bookkeeping copied onto the webhook_events row and a filter for GET /v1/schedules?project_id=.

For an agent_run task it is checked at create with the identity the fired runs will execute under, which carries no roles, so a project shared only through a role:org-member grant is refused here rather than on the first fire. A webhook schedule's is checked with the caller's own roles, because nothing reads a project through it. The refusal is the projects ladder's own and has two shapes: 404 for a project that is unknown, malformed, cross-organization or no longer active, and 403 project_forbidden for one the asking identity can list but not read. That differs from POST /v1/runs, which collapses both into 404; expect either.

Each fire opens its conversation inside the project, so the thread appears wherever that project's conversations do, and dataset.query — called with no dataset argument — reads the project's own files plus the datasets attached to it, minus any the run cannot read in its own right. A task may carry BOTH project_id and dataset_id: they are not mutually exclusive here as they are on POST /v1/runs, and the task's dataset wins for reading while the project stays where the thread lives.

A schedule's project_id is fixed at create — it is not on the patch shape, like agent_id — so moving a scheduled task to another project means deleting it and creating it again.

Naming a dataset or a project does not by itself let the fired runs read it — the run also has to hold the dataset.query tool. A schedule that names no agent (the message + model shape, for which the engine makes and hides one) and no tools is granted what the caller can reach at create, dataset.query included only if the caller can read a dataset at that moment, and that grant is not refreshed on later fires. Name tools and the agent is granted exactly those, so dataset.query has to be among them; name an agent_id and that agent's own grants decide. See Query a dataset's tables.

Every fire copies the stored message onto a durable run and a backing conversation titled agent run: <name>. Read one task's fire history through the run list:

GET /v1/runs?schedule_id={schedule_id}

If the agent stops being usable between fires, the schedule pauses rather than running without it, and status_reason says which:

status_reasonCause
agent_unavailableThe agent was deleted or disabled.
agent_has_datasetsDatasets were attached to the agent, and durable runs do not perform retrieval.

The pause branch keys on the action type, never on the agent reference, so a deleted agent can never fall through into webhook delivery. Resuming re-checks the same two facts inside the resume transaction and answers 409 while the cause remains, so a resume cannot re-arm a task that would fail on its next fire.

agent_id and message are immutable. Changing what a task does is delete and recreate; PATCH may change timing and end conditions for either action type, and delivery fields for webhook only.

agent_id WITHOUT message is only the pre-agent_run compatibility shape: it stores the fixed message scheduled trigger, and any endpoint_id/ payload sent with it are retained as bookkeeping but never executed. Do not use it for new schedules — the task you meant to run would sit unread in payload while every fire executes scheduled trigger.

Manage lifecycle

GET    /v1/schedules?status=active&limit=50&cursor=<opaque>
GET    /v1/schedules/{id}
PATCH  /v1/schedules/{id}
POST   /v1/schedules/{id}/pause
POST   /v1/schedules/{id}/resume
DELETE /v1/schedules/{id}

Resuming a cron schedule computes the next future occurrence from the current time. It does not replay every missed interval.

Pause and resume are compare-and-set against the status they expect. Pause is valid only from active and resume only from paused; anything else is 409, including a second pause and a race with the sweep that just completed a one-time schedule. Two concurrent resumes succeed exactly once.

Every schedule response carries owned_by_caller, computed by the engine from the same owner identity authorization uses. Use it rather than comparing owner_user_id with an identity you hold, which is not a reliable comparison for an org-admin. Agent-run schedules also carry the stored message; the raw action configuration is never exposed.

Poll authoritative events

GET /v1/schedule-events?schedule_id={id}&limit=50&cursor=<opaque>

Event rows record:

  • fire and delivery status;
  • scheduled and actual fire times;
  • attempt count and last attempt;
  • response code or bounded error;
  • the original payload.

Webhook failure never rolls back a committed schedule event. Delivery retries through the durable state machine.

Rotate the signing secret

curl -H "Authorization: Bearer $ORG_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -X PATCH "$BASE_URL/v1/webhook-endpoints/$ENDPOINT_ID" \
  -d '{"rotate_secret":true}'

The replacement secret is shown once. Update the receiver before retiring the old verification path according to your deployment's overlap procedure.

An endpoint cannot be deleted while a non-completed schedule references it. Deactivate it first when you need to stop delivery without removing schedule history.

On this page