Task webhooks
Set spec.webhookURL on a Task and the controller POSTs a JSON notice when the
Task reaches a terminal phase (Succeeded, Failed, or Cancelled). Every
notice is signed with an Ed25519 key. Delivery is durable across controller
restarts, retried with bounded backoff, and limited to destinations that are
safe for a controller to call.
apiVersion: core.orka.ai/v1alpha1
kind: Task
metadata:
name: nightly-report
spec:
type: ai
prompt: "Summarize yesterday's incidents"
webhookURL: https://hooks.example.com/orka
Where a notice may go
The controller fails closed. A destination must be one of:
- A public
httporhttpshost. The name is resolved when the controller connects, on every connection, and every address it resolves to must be globally routable. Loopback, RFC 1918, CGNAT (100.64.0.0/10), link-local (including169.254.169.254), IPv6 unique-local and link-local, multicast, broadcast, unspecified (0.0.0.0,::), IPv4-mapped forms of any of these, and the other IANA special-use ranges are refused. A DNS failure is a failed attempt, never a pass, and because the check runs at connect time a name that is re-pointed after validation (DNS rebinding) cannot reach an internal address. - A Service in the Task's own namespace, as
http://<name>.<namespace>.svc[.cluster.local][:port]/.... The Service must exist, have a selector and a ClusterIP, and not beExternalName. The controller connects straight to that ClusterIP without DNS. Services in other namespaces andkubernetes.defaultare refused.
The controller never follows redirects (a 3xx is a failed attempt) and never
sends webhooks through an HTTP proxy, even when HTTP_PROXY is set, because a
proxy would connect on its behalf and skip these checks. If your controller
needs an egress proxy, put a same-namespace Service in front of it.
A URL with a non-http(s) scheme, a literal non-public IP, a blocked host
name, or another namespace's Service is rejected without any network attempt:
the delivery goes straight to Failed.
The request
POST /orka HTTP/1.1
Content-Type: application/json
User-Agent: Orka-Controller/1.0
X-Orka-Task: nightly-report
X-Orka-Namespace: team-a
X-Orka-Event: task.succeeded
X-Orka-Delivery-ID: 6f1c3c2e-5b0a-4f7e-9a51-0d3f1f7f6a10
Idempotency-Key: 6f1c3c2e-5b0a-4f7e-9a51-0d3f1f7f6a10
X-Orka-Delivery-Attempt: 1
X-Orka-Signature: eyJhbGciOiJFZERTQSIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9...
{"deliveryId":"6f1c3c2e-...","eventType":"task.succeeded","occurredAt":"2026-11-06T10:00:00Z",
"taskUID":"0b9e0f43-...","taskName":"nightly-report","taskNamespace":"team-a",
"phase":"Succeeded","message":"...","startTime":"...","completionTime":"...",
"attempts":1,"resultRef":{"available":true}}
eventType is task.succeeded, task.failed, or task.cancelled. Every
attempt of one delivery sends a byte-identical body under the same delivery
ID; only X-Orka-Delivery-Attempt and the signature change.
Any 2xx response acknowledges the notice. Respond quickly; one attempt times
out after 30 seconds.
Verifying a notice
X-Orka-Signature is a compact JWS (a JWT) signed with alg: EdDSA
(Ed25519). Its header carries the signing key's kid, the RFC 7638
thumbprint of the public key. The public keys are published, without
authentication, by the Orka API:
GET https://<orka-api>/.well-known/orka/task-webhook-jwks.json
{"keys":[{"kty":"OKP","crv":"Ed25519","x":"...","kid":"...","alg":"EdDSA","use":"sig"}]}
Fetch the JWKS out of band or over a trusted channel: it is what your receiver trusts. Then, for each request:
- Read
kidfrom the JWS header and find that key in your cached JWKS. If it is missing, refetch the JWKS once (rate-limit this) and try again; reject if it is still missing. Accept onlyalg: EdDSA. - Verify the Ed25519 signature over the first two dot-separated segments.
- Check the claims:
issisorka.ai/task-webhook;audis exactly thewebhookURLconfigured on the Task;expis in the future andnbfis not, allowing about a minute of clock skew. Tokens live for five minutes and are minted for each attempt;payload_sha256equals the lowercase hex SHA-256 of the raw request body you received, before any JSON parsing.
- Use
task.uid,task.namespace,task.name,event, anddelivery_idfrom the verified claims rather than from headers or the body. - Deduplicate on
delivery_id(also sent asX-Orka-Delivery-IDandIdempotency-Key). Delivery is at-least-once: a controller that restarts mid-attempt sends the same delivery again.
The claim set looks like:
{
"iss": "orka.ai/task-webhook",
"sub": "0b9e0f43-...",
"aud": "https://hooks.example.com/orka",
"iat": 1793872800, "nbf": 1793872800, "exp": 1793873100,
"jti": "6f1c3c2e-....1",
"delivery_id": "6f1c3c2e-...",
"attempt": 1,
"event": "task.succeeded",
"task": {"uid": "0b9e0f43-...", "namespace": "team-a", "name": "nightly-report"},
"payload_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
Any standard JOSE library that supports EdDSA can do steps 1–3. In Go,
github.com/lestrrat-go/jwx/v3 verifies with
jwt.ParseString(token, jwt.WithKeySet(set), jwt.WithIssuer("orka.ai/task-webhook"), jwt.WithAudience(webhookURL))
followed by the payload_sha256 comparison. Orka's own reference
implementation is taskwebhook.Verify in internal/taskwebhook.
Retries and status
The delivery is recorded in the Task's status, so it survives controller restarts, upgrades, and leader changes:
status:
webhookDelivered: true
webhookDelivery:
deliveryID: 6f1c3c2e-5b0a-4f7e-9a51-0d3f1f7f6a10
eventType: task.succeeded
occurredAt: "2026-11-06T10:00:00Z"
payloadSHA256: 9f86d0...
state: Delivered # Pending, Delivered, or Failed
attempts: 1
lastStatusCode: 202
- A failed attempt (connection error, timeout, refused address, redirect, or a
non-
2xxresponse) is retried after 10s, 20s, 40s, and so on, capped at 15 minutes, for at most 8 attempts. After that the delivery isFailedand is never retried.lastErrorrecords a short reason with no URL or credential in it. - Each attempt is claimed in Task status before it is sent. A controller that dies mid-attempt leaves a two-minute lease; the next controller waits for it, then retries under the same delivery ID.
- Only one notice is pending per Task. If the Task's terminal state changes
before a pending notice is delivered, the controller supersedes it: the old
delivery ID is never sent again, a new one carries the newer state, its
attempts start over, and
supersededcounts the replaced notices. Supersession never shortens an existing backoff. - If signing is enabled but no signing key is available yet (for example, the
Secret cannot be read or holds a malformed key), nothing is sent and no
attempt is consumed; the delivery stays
Pendinguntil a key is available. - If signing is disabled (
--task-webhook-signing-secret=""), nothing is sent and the delivery is markedFailedat once. Enabling signing later does not retry notices that already failed this way.
Signing keys and rotation
The controller signs with the Ed25519 key in the current item of a Secret in
a namespace of its own: <release namespace>-task-webhook-signing for the Helm
chart (override with controller.taskWebhooks.signingNamespace), or
orka-task-webhook-signing for the kustomize install (flag
--task-webhook-signing-namespace). The Secret is
<fullname>-task-webhook-signing for the Helm chart, where <fullname> is the
chart's full name (orka for a release named orka; override the Secret name
with controller.taskWebhooks.signingSecretName), or
orka-task-webhook-signing for the kustomize install. On first use, the
controller creates that Secret and mints current if either is missing. Keys
are PEM-encoded PKCS#8, which is what
openssl genpkey -algorithm ed25519 writes. A malformed key is never
replaced: delivery pauses until it is fixed.
Every replica rereads the Secret at least every 15 seconds, so rotation needs no restart. To rotate:
NS=orka-task-webhook-signing # <release namespace>-task-webhook-signing for Helm
SECRET=orka-task-webhook-signing # <fullname>-task-webhook-signing for Helm
openssl genpkey -algorithm ed25519 -out new.pem
OLD=$(kubectl -n "$NS" get secret "$SECRET" -o jsonpath='{.data.current}')
kubectl -n "$NS" patch secret "$SECRET" --type merge \
-p "{\"data\":{\"previous\":\"$OLD\",\"current\":\"$(base64 -w0 < new.pem)\"}}"
shred -u new.pem
The new key signs from then on. The JWKS publishes both current and
previous, so a token signed by a replica that has not yet reloaded still
verifies, and receivers that see the new kid refetch the JWKS. After
TokenTTL plus the reload interval (in practice, wait at least ten minutes),
drop previous:
kubectl -n "$NS" patch secret "$SECRET" --type json -p '[{"op":"remove","path":"/data/previous"}]'
If a key is compromised, rotate and remove previous at once. Receivers that
keep the old JWKS cached keep trusting the old key until they refetch.
Setting --task-webhook-signing-secret="" disables signing. Since notices are
never sent unsigned, that also stops webhook delivery: notices for Tasks that
finish while signing is disabled are marked Failed and are not retried after
signing is enabled again.
Who can read the signing key
Anyone who can read or change the signing Secret can forge notices, so it
lives alone in its own namespace and only the controller is bound there:
get and update on that one Secret, and create on Secrets there. This
closes two paths:
- A Task's or Agent's
secretRefmounts a Secret from the Task's namespace. Tasks live in the release namespace, so asecretRefcannot name the key. - Worker roles are bound in the release namespace only, so no worker can read or replace the key with its own permissions.
The protection also depends on no worker being able to act as the controller.
The default AI worker RBAC profile lets the AI worker create Secrets and Jobs in
the release namespace (for code_exec and child Task credentials). A
ServiceAccount-token Secret or a Job could name the controller's
ServiceAccount, and a Job's Pod could carry the controller's labels, which
would put it behind the API Service that publishes the JWKS. The
<fullname>-ai-worker-identity-protection ValidatingAdmissionPolicy, which
both installs ship, blocks all three. The AI worker may only write Opaque
Secrets, may only create or delete code_exec's own ServiceAccounts (so it
cannot replace a platform account and inherit its RoleBindings), and may only
create code_exec Jobs: each runs as its own per-run
ServiceAccount with no token mounted, carries only code_exec labels, uses
only emptyDir and Secret volumes, gets no host namespaces, privilege or
capabilities, and runs under a RuntimeDefault or Localhost seccomp
profile. Keep that policy installed (Helm workers.ai.identityProtection
defaults to true; turning it off reopens the path to the controller's
identity under the default worker profile), and:
- Do not grant the AI worker other ways to run Pods or request tokens. An
OutboundAccessPolicy
serviceAccountRefgrants the AI worker TokenRequest for that account, so the controller refuses Orka's own ServiceAccounts there: any labeledorka.ai/platform-identity: "true", plus the controller and worker accounts it is configured with, labeled or not. The AI worker's own account is allowed, since a token for itself gives it nothing new. - Do not keep ServiceAccount-token Secrets in the release namespace. The default AI worker profile can read every Secret there, so any such Secret hands the worker that account's token.
The controller refuses to start with a signing namespace that is the watch, controller Pod, ACP runtime or agent-host namespace. The chart also refuses the client namespace when rendering; the controller cannot see that one, so kustomize and other installs must keep it apart themselves. Keep the signing namespace free of other workloads and of role bindings beyond the controller's and your cluster administrators'.