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_reason | Cause |
|---|---|
agent_unavailable | The agent was deleted or disabled. |
agent_has_datasets | Datasets 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.