Kestra (workflow orchestrator)¶
Kestra runs declarative YAML flows — call an API, wait, retry, chain steps. Your service talks to it in two directions, and both already fit the SDK's building blocks:
- Service → Kestra: the service triggers a flow (webhook or execution
API) with the
HTTPClient. - Kestra → service: the flow calls a service route through the
io.kestra.plugin.core.http.Requesttask, protected bymake_token_dependency.
And since triggering a flow from a database write is the classic dual
write, the finish is the outbox (delivery to Kestra) +
WebhookInbox (receives the result exactly once) pair.
No new SDK module, on purpose
The whole contract is "POST with a header" in both directions, and
HTTPClient already brings retry, circuit breaker and default_headers.
A KestraClient would only pay off if two services copied the same call.
There is no new dependency either: not even Kestra's Python SDK, which
would be an HTTP wrapper carrying its version bounds into your lock.
Everything here was executed
Every command and every output on this page came from a real run against
Kestra 2.0.6 OSS (kestra/kestra:v2.0.6-slim, digest
sha256:82f7c84f00fb883a6ff93c6d07e31f30eaabd0c84d229fb56a80edeeecd8dbba)
with a local FastAPI service, Python 3.12. Another Kestra version may
answer differently — the tenant section
exists because of that.
1. Start Kestra for testing¶
docker run -d --name lab-kestra -p 18080:8080 \
--add-host=host.docker.internal:host-gateway \
-e SECRET_SERVICE_TOKEN=dG9vbC1zZWNyZXQtMTIz \
-e SECRET_WEBHOOK_KEY=aG9vay1rZXktYWJjMTIz \
-e KESTRA_CONFIGURATION='
kestra:
server:
basic-auth:
username: admin@example.com
password: Kestra12345!
' \
kestra/kestra:v2.0.6-slim server local
What each piece does:
--add-host=host.docker.internal:host-gatewaylets the flow (inside the container) reach your service, which runs on the host.SECRET_<NAME>becomes{{ secret('<NAME>') }}in the flow. The value is base64 (echo -n 'tool-secret-123' | base64). That is how the protected route's token and the webhook key stay out of the YAML.basic-authturns on API authentication; without credentials it answers401(measured in section 5).
Wait for the server to come up and check the version:
Pin the image version
The latest-slim tag points to a nightly (the
org.opencontainers.image.version label of the image I pulled said
nightly). Use v2.0.6-slim, or whichever you validate.
2. The tenant path (measured)¶
Kestra's documentation is inconsistent: some examples use
/api/v1/main/..., others /api/v1/.... Measured on 2.0.6, with Basic Auth:
| Call | With /main |
Without /main |
|---|---|---|
GET /flows/search |
200 |
200 |
POST /executions/{namespace}/{flow} |
200 |
200 |
POST /executions/webhook/{namespace}/{flow}/{key} |
200 |
200 |
GET /outputs/executions/{id} |
200 |
400 |
GET /configs |
404 |
200 |
The 400 says why, and it holds for any segment that is not main:
{"type":"https://kestra.io/docs/api-reference/problems/bad-request","title":"Bad request","status":400,"detail":"Tenant must be 'main' for OSS version", ...
The 400 for /api/v1/outputs/executions/<id> is the same one a tenant other
than main gets, which suggests that, without the segment, outputs was
read as the tenant. Some routes tolerate the omission, others do not — so
always use /api/v1/main/... on tenant routes. The only measured
exception is /api/v1/configs, which only answered without /main.
3. The service¶
A single file with everything the next sections use: the tool route the flow calls, the callback the flow sends when it finishes, and the order that writes the order and the event in the same transaction.
import asyncio
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
from typing import Annotated, Any
from uuid import UUID, uuid4
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
BaseModel,
BaseOutboxModel,
BaseRepository,
BaseSchema,
BaseWebhookEventModel,
HTTPClient,
OutboxRelay,
WebhookInbox,
make_token_dependency,
register_exception_handlers,
)
TOKEN_SECRET: str = os.environ.get("TOKEN_SECRET", "tool-secret-123")
KESTRA_URL: str = os.environ.get("KESTRA_URL", "http://127.0.0.1:18080")
KESTRA_WEBHOOK_PATH: str = os.environ.get(
"KESTRA_WEBHOOK_PATH",
"/api/v1/main/executions/webhook/tempest/order_created/hook-key-abc123",
)
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///./kestra_demo.db")
require_token = make_token_dependency(TOKEN_SECRET)
Session = Annotated[AsyncSession, Depends(db.session_dependency)]
class OrderModel(BaseModel):
"""Service order."""
__tablename__ = "orders"
reference: Mapped[str] = mapped_column()
status: Mapped[str] = mapped_column(default="created")
class OutboxModel(BaseOutboxModel):
"""Events waiting to be delivered to Kestra."""
__tablename__ = "outbox"
class WebhookEventModel(BaseWebhookEventModel):
"""Callbacks received from Kestra, deduplicated per execution."""
__tablename__ = "webhook_events"
class OrderCreateSchema(BaseSchema):
"""Body of `POST /api/orders`."""
reference: str
class EnrichRequestSchema(BaseSchema):
"""Body the flow sends to the tool route."""
order_id: str
source: str
class CallbackSchema(BaseSchema):
"""Body the flow sends when it finishes."""
execution_id: str
order_id: str
enriched: dict[str, Any]
inbox: WebhookInbox[WebhookEventModel] = WebhookInbox(
db, model=WebhookEventModel, backoff_base_seconds=1.0
)
kestra: HTTPClient = HTTPClient(base_url=KESTRA_URL, timeout=15.0)
async def publish(event: BaseOutboxModel) -> None:
"""Deliver an outbox event to Kestra's webhook.
Args:
event (BaseOutboxModel): The outbox row.
Raises:
httpx.HTTPStatusError: If Kestra does not answer 2xx; the relay schedules a retry.
"""
response = await kestra.post(KESTRA_WEBHOOK_PATH, json=event.payload)
response.raise_for_status()
async def apply_callback(event: WebhookEventModel, session: AsyncSession) -> None:
"""Apply Kestra's callback: mark the order as enriched.
Args:
event (WebhookEventModel): The claimed event.
session (AsyncSession): The session of the transaction that also closes the row.
"""
order = await session.get(OrderModel, UUID(event.payload["order_id"]))
if order is not None:
order.status = "enriched"
async def loop_forever(step: Any) -> None:
"""Run `step()` every half second until cancelled.
Args:
step (Any): Coroutine function without arguments to repeat.
"""
while True:
await step()
await asyncio.sleep(0.5)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Create the tables, start the outbox relay and the inbox sweep."""
await db.connect()
await db.create_tables()
relay = OutboxRelay(db, model=OutboxModel, publish=publish, backoff_base_seconds=1.0)
async def sweep() -> None:
await inbox.drain_once(apply_callback)
tasks = [
asyncio.create_task(relay.run(poll_interval=0.5)),
asyncio.create_task(loop_forever(sweep)),
]
yield
for task in tasks:
task.cancel()
for task in tasks:
with suppress(asyncio.CancelledError):
await task
await kestra.aclose()
await db.disconnect()
app = FastAPI(lifespan=lifespan)
register_exception_handlers(app)
@app.post("/api/orders", status_code=201)
async def create_order(data: OrderCreateSchema, session: Session) -> dict[str, str]:
"""Create the order and the `orders.created` event in the same transaction."""
repo: BaseRepository[OrderModel] = BaseRepository(session, model=OrderModel)
order_id = uuid4()
event = OutboxModel.new_event(
"orders.created", {"order_id": str(order_id), "reference": data.reference}
)
order = await repo.save_with_outbox(
OrderModel(id=order_id, reference=data.reference), event
)
return {"id": str(order.id), "status": order.status}
@app.get("/api/orders/{order_id}")
async def get_order(order_id: UUID, session: Session) -> dict[str, str]:
"""Return the order's current state."""
order = await BaseRepository(session, model=OrderModel).get_by_id(order_id)
return {"id": str(order.id), "status": order.status}
@app.post("/api/tools/enrich-order", dependencies=[Depends(require_token)])
async def enrich_order(data: EnrichRequestSchema) -> dict[str, str]:
"""Tool route the flow calls, protected by `X-Token`."""
return {"order_id": data.order_id, "caller": data.source, "tier": "gold"}
@app.post("/api/callbacks/kestra", dependencies=[Depends(require_token)])
async def kestra_callback(data: CallbackSchema) -> dict[str, bool]:
"""Record the callback in the inbox and answer 200, even for a repeat."""
new_id = await inbox.record(
"kestra", data.execution_id, "flow.finished", data.model_dump()
)
return {"recorded": new_id is not None}
Piece by piece:
make_token_dependency(TOKEN_SECRET)compares theX-Tokenheader withhmac.compare_digest. The tool route and the callback share the same dependency;POST /api/ordersstays open because the service itself is the one calling it.create_orderwritesOrderModeland the outbox row in one transaction (save_with_outbox) — see the outbox recipe.publishis thepublish(event)adapterOutboxRelayrequires: a single argument, andraise_for_status()so a non-2xx becomes a retry.kestra_callbackwrites to theWebhookInboxkeyed by(provider, external_id)and answers200even for a repeat — the sender only needs to stop retrying. Theexternal_idis Kestra's execution id.apply_callbackruns inside the transaction that also marks the inbox row as processed.
Start the service (bound to 0.0.0.0 here because the caller is a container,
a separate origin):
Check the tool route's protection, with and without the token:
curl -s -X POST localhost:18000/api/tools/enrich-order \
-H 'X-Token: tool-secret-123' -H 'content-type: application/json' \
-d '{"order_id":"1","source":"curl"}'
curl -s -X POST localhost:18000/api/tools/enrich-order \
-H 'content-type: application/json' -d '{"order_id":"1","source":"curl"}' -w ' %{http_code}\n'
{"order_id":"1","caller":"curl","tier":"gold"}
{"detail":"Invalid or missing token","code":"UNAUTHORIZED","details":{}} 401
4. The flows¶
Two flows, both calling the protected route with the token coming from
secret(...). The first is started through the execution API, with an
input:
id: report
namespace: tempest
inputs:
- id: order_id
type: STRING
tasks:
- id: enrich
type: io.kestra.plugin.core.http.Request
uri: http://host.docker.internal:18000/api/tools/enrich-order
method: POST
contentType: application/json
headers:
X-Token: "{{ secret('SERVICE_TOKEN') }}"
body: |
{"order_id": "{{ inputs.order_id }}", "source": "kestra"}
outputs:
- id: enriched
type: STRING
value: "{{ outputs.enrich.body }}"
The second is started by webhook; the body arrives in trigger.body, and
the trigger's key also comes from a secret. When it finishes it notifies
the service (callback), sending execution.id — the id the inbox uses to
deduplicate:
id: order_created
namespace: tempest
tasks:
- id: enrich
type: io.kestra.plugin.core.http.Request
uri: http://host.docker.internal:18000/api/tools/enrich-order
method: POST
contentType: application/json
headers:
X-Token: "{{ secret('SERVICE_TOKEN') }}"
body: |
{"order_id": "{{ trigger.body.order_id }}", "source": "kestra"}
- id: callback
type: io.kestra.plugin.core.http.Request
uri: http://host.docker.internal:18000/api/callbacks/kestra
method: POST
contentType: application/json
headers:
X-Token: "{{ secret('SERVICE_TOKEN') }}"
body: |
{
"execution_id": "{{ execution.id }}",
"order_id": "{{ trigger.body.order_id }}",
"enriched": {{ outputs.enrich.body }}
}
outputs:
- id: callback_status
type: STRING
value: "{{ outputs.callback.code }}"
triggers:
- id: webhook
type: io.kestra.plugin.core.trigger.Webhook
key: "{{ secret('WEBHOOK_KEY') }}"
Import both (on 2.0.6 the response was 200):
curl -s -u admin@example.com:Kestra12345! -X POST localhost:18080/api/v1/main/flows \
-H 'Content-Type: application/x-yaml' --data-binary @flows/report.yml -o /dev/null -w '%{http_code}\n'
curl -s -u admin@example.com:Kestra12345! -X POST localhost:18080/api/v1/main/flows \
-H 'Content-Type: application/x-yaml' --data-binary @flows/order_created.yml -o /dev/null -w '%{http_code}\n'
5. Trigger from the service¶
import asyncio
from base64 import b64encode
from typing import Any
from tempest_fastapi_sdk import HTTPClient, RetryPolicy
TENANT: str = "main"
def make_kestra_client(base_url: str, username: str, password: str) -> HTTPClient:
"""Build the Kestra client with Basic Auth, retry and circuit breaker.
Args:
base_url (str): Kestra root, without a trailing slash.
username (str): Basic Auth user.
password (str): Basic Auth password.
Returns:
HTTPClient: Client ready for the API and the webhooks.
"""
credentials: str = b64encode(f"{username}:{password}".encode()).decode()
return HTTPClient(
base_url=base_url,
timeout=30.0,
retry_policy=RetryPolicy(max_attempts=3),
default_headers={"Authorization": f"Basic {credentials}"},
)
async def trigger_webhook(
client: HTTPClient,
namespace: str,
flow_id: str,
key: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Trigger a flow through its Webhook trigger.
Args:
client (HTTPClient): The Kestra client.
namespace (str): Flow namespace.
flow_id (str): Flow id.
key (str): The trigger's `key`; it is the webhook's only check.
payload (dict[str, Any]): JSON body, which the flow reads in `trigger.body`.
Returns:
dict[str, Any]: The created execution (`id`, `state`, ...).
Raises:
httpx.HTTPStatusError: If Kestra does not answer 2xx.
"""
response = await client.post(
f"/api/v1/{TENANT}/executions/webhook/{namespace}/{flow_id}/{key}",
json=payload,
)
response.raise_for_status()
return dict(response.json())
async def start_execution(
client: HTTPClient,
namespace: str,
flow_id: str,
inputs: dict[str, str],
*,
wait: bool = False,
) -> dict[str, Any]:
"""Create an execution through the API, with the flow's inputs.
Args:
client (HTTPClient): The Kestra client.
namespace (str): Flow namespace.
flow_id (str): Flow id.
inputs (dict[str, str]): Flow inputs; sent as `multipart/form-data`.
wait (bool): Hold the connection until the execution finishes.
Returns:
dict[str, Any]: The execution (`id`, `state`, ...).
Raises:
httpx.HTTPStatusError: If Kestra does not answer 2xx.
"""
response = await client.post(
f"/api/v1/{TENANT}/executions/{namespace}/{flow_id}",
params={"wait": str(wait).lower()},
files={name: (None, value) for name, value in inputs.items()},
)
response.raise_for_status()
return dict(response.json())
async def read_outputs(client: HTTPClient, execution_id: str) -> dict[str, Any]:
"""Read the flow outputs of a finished execution.
Args:
client (HTTPClient): The Kestra client.
execution_id (str): Execution id.
Returns:
dict[str, Any]: The outputs declared under `outputs:` in the flow.
Raises:
httpx.HTTPStatusError: If Kestra does not answer 2xx.
"""
response = await client.get(f"/api/v1/{TENANT}/outputs/executions/{execution_id}")
response.raise_for_status()
return dict(response.json())
async def main() -> None:
"""Call the webhook and the API against a local Kestra and print the result."""
client = make_kestra_client("http://127.0.0.1:18080", "admin@example.com", "Kestra12345!")
try:
created = await trigger_webhook(
client, "tempest", "order_created", "hook-key-abc123", {"order_id": "demo-1"}
)
print("webhook:", created["id"], created["state"]["current"])
execution = await start_execution(
client, "tempest", "report", {"order_id": "demo-2"}, wait=True
)
print("api:", execution["id"], execution["state"]["current"])
print("outputs:", await read_outputs(client, execution["id"]))
finally:
await client.aclose()
if __name__ == "__main__":
asyncio.run(main())
webhook: 49vM41z1Hsqrd2r0umfjdl CREATED
api: 2EvSyvxiQWdVrzX3Co2MqN SUCCESS
outputs: {'enriched': '{"order_id":"demo-2","caller":"kestra","tier":"gold"}'}
What the measurement showed, and what changes how you write the client:
- The webhook answers immediately, with the freshly created execution
(
state.current=CREATED); the flow keeps running behind it. To learn the result, use the callback from section 6 — or?wait=trueon the API. - The
keyis the webhook's only check. A call without Basic Auth, with just the right URL, got200; with the wrong key it got404("detail":"Webhook not found"). Treat the key as a secret (that is why it comes fromsecret('WEBHOOK_KEY')) and use a long one. - The execution API takes inputs as
multipart/form-data.-d order_id=44(urlencoded) got415withContent Type [application/x-www-form-urlencoded] not allowed. Allowed types: [multipart/form-data]. That is whystart_executionuses httpx'sfiles={name: (None, value)}, which forcesmultipartwithout attaching a file. - Without credentials the API answers
401. wait=trueholds the connection and returns the finished execution (SUCCESS); without it the state comes back asRUNNING.- The flow outputs are not in the execution JSON (the
outputsfield is absent on 2.0.6). They come fromGET /api/v1/main/outputs/executions/{id}, which is whatread_outputscalls.
6. Guaranteed delivery: outbox → Kestra, inbox ← Kestra¶
Create an order. The service answers 201 and does not wait for Kestra:
curl -s -X POST localhost:18000/api/orders -H 'content-type: application/json' \
-d '{"reference":"PED-001"}'
sleep 1
curl -s localhost:18000/api/orders/a88f7992-d764-4415-b2da-a045d7ee6647
{"id":"a88f7992-d764-4415-b2da-a045d7ee6647","status":"created"}
{"id":"a88f7992-d764-4415-b2da-a045d7ee6647","status":"enriched"}
In the service log, the full order — the relay delivered the event, the flow called the tool route with the token and then the callback:
INFO: 127.0.0.1:47757 - "POST /api/orders HTTP/1.1" 201 Created
INFO: 172.17.0.2:58240 - "POST /api/tools/enrich-order HTTP/1.1" 200 OK
INFO: 172.17.0.2:58254 - "POST /api/callbacks/kestra HTTP/1.1" 200 OK
Kestra down¶
With the container frozen (docker pause lab-kestra), a new order answers
201 all the same. Twenty-five seconds later, the outbox row is still
pending and the order created; unfrozen (docker unpause lab-kestra),
the row becomes sent and the order enriched:
{"id":"906c4c47-21b4-486f-af8d-6e308d53b19d","status":"created"}
('orders.created', 'pending', 0, None) # the new order's row, frozen, 25 s later
{"id":"906c4c47-21b4-486f-af8d-6e308d53b19d","status":"created"}
('orders.created', 'sent', 0) # after the unpause
{"id":"906c4c47-21b4-486f-af8d-6e308d53b19d","status":"enriched"}
What this test measures and what it does not
docker pause freezes the process; the connection hangs instead of being
refused, and at 25 s the relay had not yet recorded a failure
(attempts still 0). This test shows the event waits, durable, in the
table and goes out when Kestra is back. The path with attempts
climbing, last_error and exponential backoff is the one for fast
failures, described in the outbox recipe.
The repeated callback¶
The inbox answers 200 and stores once. Two deliveries with the same
execution_id:
It is at-least-once in both directions
The outbox may publish the same event twice (worker killed between the
send and the commit), so the flow may run twice for the same order.
Make the flow idempotent on trigger.body.order_id, or keep the order's
idempotency key. On our side, the inbox already deduplicates the callback
by (provider, external_id).
Tear the environment down¶
Recap¶
- Service → Kestra:
HTTPClient+POST /api/v1/main/executions/webhook/{ns}/{flow}/{key}(immediate answer, thekeyis the only check) orPOST /api/v1/main/executions/{ns}/{flow}withmultipart/form-data(?wait=trueto wait). - Tenant: always use
/api/v1/main/...; on 2.0.6 any other segment gets400, and omitting/mainonly works on some routes. - Kestra → service:
http.Requesttask withX-Token: "{{ secret('...') }}"against a route withmake_token_dependency. - Guaranteed delivery:
save_with_outbox+OutboxRelayon the way out;WebhookInbox.recordkeyed by the execution id on the way back. - No new module: all of this is
HTTPClient,make_token_dependency,OutboxRelayandWebhookInbox.