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_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; 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.