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.

That refusal is a 400 when the project is one you can read and your tasks cannot:

{ "error": { "type": "invalid_request", "field": "project_id",
  "message": "project_id: a task runs without your organization roles, and this project is shared with you only through one" } }

You are looking at a project you can open, so 404 would be a lie and 403 would explain nothing. The sentence names the actual cause, and the remedy is a grant that survives losing the roles: be given the project directly, as a user or a team, rather than through role:org-member.

This refusal is also why the schedules and runs doors began sending the envelope's optional field at all — they had composed type and message alone. Every validation refusal on them that names an input now carries it, not just this one, and a refusal that names none carries no field key: branch on its presence. The message keeps its own project_id: prefix as well, so prose you were already parsing still reads the same.

The engine reaches that answer by asking twice, and only after the first ask refuses: rolelessly as the fires will, then again with your own roles. The 400 needs the second ask to SUCCEED, which is why it says nothing to a stranger — a project you cannot see is refused by both asks and keeps exactly what the projects ladder said. That is still two shapes here: 404 for a project that is unknown, malformed, cross-organization or no longer active, and 403 project_forbidden for a same-org project nobody has shared with you at all. POST /v1/runs and POST /v1/agents/{id}/runs collapse those two into 404, so for a project you genuinely cannot see the doors still answer differently and you should expect either. For the role-only project they now agree: the same 400, the same sentence, on all four entry points — POST /v1/runs, POST /v1/agents/{id}/runs, POST /v1/schedules with an agent_run task, and the project_id half of PATCH /v1/schedules/{id}.

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. This is the only door on which you can say both, which is why the pair is kept rather than refused for symmetry — read dataset X, thread into project Y has nowhere else to be said.

The schedule says which of the two its runs read: reads_from. It is "dataset" when the task names a dataset_id, "project" when it names only a project_id, and absent when it names neither — there is then no place to read, and answering "project" would assert one that does not exist. A webhook schedule never carries it at all, because it fires no run. The field is on the create response, the read, the patch response and every row of the listing alike:

{
  "id": "<schedule-id>",
  "action_type": "agent_run",
  "project_id": "<project-id>",
  "dataset_id": "<dataset-id>",
  "reads_from": "dataset"
}

It is worth reading because it is derived, not restated: the engine asks the same function dataset.query obeys when it resolves what a fired run is looking at. The two facts travel to a run separately — the dataset is stamped on the run row, the project on the conversation the fire opens — and only that one function puts them in order, so reads_from cannot drift from what the fires actually do. Treat it as the answer rather than deriving your own from the two ids.

A schedule's project_id can be changed after create, unlike agent_id: PATCH /v1/schedules/{id} takes it on an agent_run task, a string to move the task to another project and null to take it out of every project. It passes the same gate a create does, so a project only a role reaches is the 400 above. The task fields are owner-only and agent_run-only — sending one on a webhook schedule is 400 task: task fields require agent_run — and moving the task changes where LATER fires open their threads; the conversations earlier fires already opened stay where they were.

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 and ui.surface, the engine's two tools, included only if the caller can read a dataset or a project at that moment — and that grant is not refreshed on later fires. Name tools and the agent is granted exactly those, so each engine tool you want has to be among them; name an agent_id and that agent's own grants decide. See Query a dataset's tables and Answer with a surface.

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 and, when the task names a dataset or a project, reads_from; 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