11. Know when it's done
A backend shouldn’t sit waiting for runs. Register a webhook, and Maitento calls your server whenever a run changes: when it starts, when it needs a person, when it finishes. You build a small receiver that checks each call really came from Maitento, then break it on purpose to see what Maitento does when your server says no. If your code can’t take calls from the internet, tutorial 12 gets the same news over a connection your code opens instead.
You’ll learn
- how to register a webhook for a kind of run
- how to check a delivery’s signature, so you know it came from Maitento and isn’t an old one sent again
- why deliveries can repeat or skip states, and how to handle it
- what Maitento does when your receiver fails, and where to see every delivery
New concepts: webhooks
Builds on: 9. Call your code, for a server that Maitento can reach
Time: ~20 min · Cost: under $0.01 on DeepSeek · Needs: a public address for your laptop, such as
cloudflared tunnel --url http://localhost:3001
1. The receiver
Section titled “1. The receiver”Maitento sends a PUT with the run’s latest state as the body, and four headers: X-MT-WebHook-Type (here
InteractionProcess), X-MT-WebHook-AssociatedId (the run’s id), X-MT-WebHook-Timestamp (when Maitento sent it)
and X-MT-WebHook-Signature. The signature is made from the body, the timestamp and the secret you register the
webhook with. The secret itself is never sent. The SDKs check the signature and read the run for you:
// Receives Maitento webhooks: Maitento PUTs the latest state of a run every time it changes.import express from "express";import { parseWebhook, verifyWebhook } from "@maitento/sdk";
const secret = process.env.WEBHOOK_SECRET;if (!secret) throw new Error("Set WEBHOOK_SECRET to the secret you registered the webhook with.");
const finished = new Set(["Finished", "Error", "Killed"]);const latest = new Map<string, number>(); // run id -> dateUpdated of the newest delivery we have seen
const app = express();// Keep the body as the bytes Maitento sent: the signature covers exactly those. A run's full state can be large, so// allow big bodies.app.use(express.raw({ type: "application/json", limit: "50mb" }));
app.put("/maitento/webhook", async (req, res) => { // Every delivery is signed with your secret. verifyWebhook checks the signature against the raw body, and that the // delivery was signed in the last 5 minutes. const body = req.body as Buffer; if (!(await verifyWebhook(req.headers, body, secret))) { console.log("401: a delivery without a valid signature"); res.sendStatus(401); return; }
const delivery = parseWebhook(req.headers, body); if (delivery.type !== "InteractionProcess") { res.sendStatus(204); return; } const run = delivery.entity;
// Deliveries can arrive more than once and out of order: keep only the newest state of each run. const updated = new Date(run.dateUpdated).getTime(); const seen = latest.get(run.id); if (seen !== undefined && seen >= updated) { res.sendStatus(204); return; } latest.set(run.id, updated);
console.log(`${delivery.type} ${run.id} is now ${run.status}`); if (finished.has(run.status) && run.output) console.log(` output: ${run.output}`);
// Answer quickly: Maitento waits up to 100 seconds, then tries again later. res.sendStatus(204);});
const port = Number(process.env.PORT ?? 3001);app.listen(port, () => console.log(`Webhook receiver listening on http://localhost:${port}/maitento/webhook`));# Receives Maitento webhooks: Maitento PUTs the latest state of a run every time it changes.# Run it with: uvicorn receiver:app --port 3001import osfrom datetime import datetime
from fastapi import FastAPI, Request, Responsefrom maitento import parse_webhook, verify_webhook
SECRET = os.environ.get("WEBHOOK_SECRET") or ""if not SECRET: raise RuntimeError("Set WEBHOOK_SECRET to the secret you registered the webhook with.")
FINISHED = {"Finished", "Error", "Killed"}latest: dict[str, datetime] = {} # run id -> dateUpdated of the newest delivery we have seen
app = FastAPI()
@app.put("/maitento/webhook")async def webhook(request: Request) -> Response: # Every delivery is signed with your secret. verify_webhook checks the signature against the raw body, exactly as # it arrived, and that the delivery was signed in the last 5 minutes. body = await request.body() if not verify_webhook(request.headers, body, SECRET): print("401: a delivery without a valid signature") return Response(status_code=401)
delivery = parse_webhook(request.headers, body) if delivery.type != "InteractionProcess": return Response(status_code=204) run = delivery.entity
# Deliveries can arrive more than once and out of order: keep only the newest state of each run. seen = latest.get(run.id) if seen is not None and seen >= run.date_updated: return Response(status_code=204) latest[run.id] = run.date_updated
print(f"{delivery.type} {run.id} is now {run.status}") if run.status in FINISHED and run.output: print(f" output: {run.output}")
# Answer quickly: Maitento waits up to 100 seconds, then tries again later. return Response(status_code=204)// Receives Maitento webhooks: Maitento PUTs the latest state of a run every time it changes.using System.Collections.Concurrent;using Maitento.Entities.Interactions;using Maitento.Sdk.Webhooks;
var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET") ?? throw new InvalidOperationException("Set WEBHOOK_SECRET to the secret you registered the webhook with.");
InteractionProcessStatus[] finished = [InteractionProcessStatus.Finished, InteractionProcessStatus.Error, InteractionProcessStatus.Killed];var latest = new ConcurrentDictionary<Guid, DateTime>(); // run id -> dateUpdated of the newest delivery we have seen
var app = WebApplication.Create(args);
app.MapPut("/maitento/webhook", async (HttpRequest request) =>{ // Every delivery is signed with your secret. Verify checks the signature against the body, exactly as it // arrived, and that the delivery was signed in the last 5 minutes. Read the body once, for both. using var reader = new StreamReader(request.Body); var body = await reader.ReadToEndAsync(); if (!MaitentoWebhooks.Verify(name => request.Headers[name], body, secret)) { Console.WriteLine("401: a delivery without a valid signature"); return Results.Unauthorized(); }
var delivery = MaitentoWebhooks.Parse(name => request.Headers[name], body); if (delivery.Entity is not InteractionProcess run) return Results.NoContent();
// Deliveries can arrive more than once and out of order: keep only the newest state of each run. if (latest.TryGetValue(run.Id, out var seen) && seen >= run.DateUpdated) return Results.NoContent(); latest[run.Id] = run.DateUpdated;
Console.WriteLine($"{delivery.Type} {run.Id} is now {run.Status}"); if (finished.Contains(run.Status) && run.Output != null) Console.WriteLine($" output: {run.Output}");
// Answer quickly: Maitento waits up to 100 seconds, then tries again later. return Results.NoContent();});
app.Run($"http://localhost:{Environment.GetEnvironmentVariable("PORT") ?? "3001"}");- Check the signature first. Only Maitento and you know the secret, so only Maitento can make a signature that matches. Someone who sees one delivery, in a log or on the way, can’t make another, because the secret isn’t in it. The SDKs compare signatures in constant time, so the answer can’t be guessed from timing.
- Check it against the raw body. The signature covers the body’s exact bytes. If anything parses the JSON first
and turns it back into text, the spacing or order can change, and the signature no longer matches. That’s why the
TypeScript receiver reads the body with
express.raw, notexpress.json, and why the Python and C# receivers read the body once and use it to verify and to parse. - The timestamp stops replays. It’s signed with the body, so it can’t be changed. A delivery whose timestamp is more than 5 minutes from your clock is refused, so a delivery someone recorded can’t be sent to you again later. Every try is signed afresh, so a retry still passes.
- Use an
httpsaddress. The signature proves who sent a delivery, but the body is the whole run:httpsstops anyone reading it on the way. - Keep the newest state of each run. A delivery can arrive twice, and two can arrive out of order. Each carries
the whole run, so keep the one with the latest
dateUpdated. - Answer quickly. Maitento waits up to 100 seconds. Do slow work after you’ve answered.
Pick a long random secret, and start the receiver on port 3001:
export WEBHOOK_SECRET="another-long-random-value"cd examples/11-webhooks/typescript && npm install && node receiver.tsexport WEBHOOK_SECRET="another-long-random-value"cd examples/11-webhooks/python && pip install -r requirements.txt && uvicorn receiver:app --port 3001export WEBHOOK_SECRET="another-long-random-value"cd examples/11-webhooks/csharp/WebhookReceiver && dotnet runDone when: it prints Webhook receiver listening on http://localhost:3001/maitento/webhook (Python: uvicorn’s
Uvicorn running on http://127.0.0.1:3001), and curl -X PUT http://localhost:3001/maitento/webhook makes it print
401: a delivery without a valid signature.
2. Make it reachable
Section titled “2. Make it reachable”In another terminal, give the receiver a public address, as in tutorial 9:
cloudflared tunnel --url http://localhost:3001Your webhook’s address is the tunnel’s address plus /maitento/webhook. Keep it, and the secret, in that terminal:
export WEBHOOK_URL="https://quiet-forest-1234.trycloudflare.com/maitento/webhook"export WEBHOOK_SECRET="another-long-random-value"Done when: curl -X PUT "$WEBHOOK_URL" also makes the receiver print the 401 line.
3. Register it
Section titled “3. Register it”import { Maitento } from "@maitento/sdk";
// Where Maitento should send interaction updates, and the secret it signs each one with.const url = process.env.WEBHOOK_URL;const secret = process.env.WEBHOOK_SECRET;if (!url || !secret) throw new Error("Set WEBHOOK_URL (your receiver's public address) and WEBHOOK_SECRET first.");
const mt = new Maitento();const hook = await mt.webhooks.create({ type: "InteractionProcess", url, secret });console.log(`Webhook ${hook.id} registered: interaction updates will be sent to ${url}`);import osimport sys
from maitento import Maitento
# Where Maitento should send interaction updates, and the secret it signs each one with.url = os.environ.get("WEBHOOK_URL")secret = os.environ.get("WEBHOOK_SECRET")if not url or not secret: sys.exit("Set WEBHOOK_URL (your receiver's public address) and WEBHOOK_SECRET first.")
mt = Maitento()hook = mt.webhooks.create(type="InteractionProcess", url=url, secret=secret)print(f"Webhook {hook.id} registered: interaction updates will be sent to {url}")#:package Maitento.Sdk@26.10.0
using Maitento.Entities.WebHooks;using Maitento.Sdk;
// Where Maitento should send interaction updates, and the secret it signs each one with.var url = Environment.GetEnvironmentVariable("WEBHOOK_URL");var secret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET");if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(secret)) throw new InvalidOperationException("Set WEBHOOK_URL (your receiver's public address) and WEBHOOK_SECRET first.");
await using var mt = MaitentoClient.CreateFromEnvironment();var hook = await mt.WebHooks.CreateAsync(WebHookType.InteractionProcess, url, secret, headers: []);Console.WriteLine($"Webhook {hook.Id} registered: interaction updates will be sent to {url}");mt webhook-set -t InteractionProcess -u "$WEBHOOK_URL" -s "$WEBHOOK_SECRET"Done when: it prints Webhook <id> registered: interaction updates will be sent to …, and the portal’s
Webhooks page lists it.
A webhook is for one kind of run: InteractionProcess here. The others are AppProcess, CapsuleProcess,
CodePipelineProcess and IngestedFile (a document a knowledge base finished reading). It belongs to your
tenant: it hears about every run of that kind, including ones other people start, and each delivery carries the
whole run, conversation and all. Treat what your receiver gets as you would your own data.
4. Run something
Section titled “4. Run something”This tutorial’s project has a small interaction to run:
// Something quick to run, so your receiver has a run to hear about.agent = { name = "FactTeller" agent = @ref("fact-teller")}task = "Tell me one surprising fact about %%topic%%."inputs.prompts = [ { name = "topic" description = "What the fact is about" required = true }]import { Maitento } from "@maitento/sdk";
// Deploys a small interaction and runs it. Your receiver hears about the run as it changes.const mt = new Maitento();await mt.deploy("../maitento");
const run = await mt.interactions.run("fun-fact", { inputs: { topic: "octopuses" } });console.log(`Run ${run.id} ended ${run.status}: ${run.output}`);from maitento import Maitento
# Deploys a small interaction and runs it. Your receiver hears about the run as it changes.mt = Maitento()mt.deploy("../maitento")
run = mt.interactions.run("fun-fact", inputs={"topic": "octopuses"})print(f"Run {run.id} ended {run.status}: {run.output}")#:package Maitento.Sdk@26.10.0
using Maitento.Sdk;
// Deploys a small interaction and runs it. Your receiver hears about the run as it changes.await using var mt = MaitentoClient.CreateFromEnvironment();await mt.DeployAsync("../maitento");
var run = await mt.Interactions.RunAsync("fun-fact", new Dictionary<string, string> { ["topic"] = "octopuses" });Console.WriteLine($"Run {run.Id} ended {run.Status}: {run.Output}");mt deploy ./maitentomt interaction-run fun-fact --input-topic octopusesRun 50116def-d9fa-44e7-b209-7897bbf657ac ended Finished: One surprising fact about octopuses is that they have three hearts and blue blood.Your receiver prints:
InteractionProcess 50116def-d9fa-44e7-b209-7897bbf657ac is now Finished output: One surprising fact about octopuses is that they have three hearts and blue blood.Done when: the receiver prints the same run id as the script, with Finished and the output.
The run also went through New and Running, but it took a second, and changes in quick succession are merged: you
were sent only its latest state. A run’s final state is always sent. A longer run, like tutorial 10’s refund, arrives
as Running, then PendingApproval, and so on.
5. The delivery log
Section titled “5. The delivery log”Maitento keeps a log of what it sent to each webhook: delivered updates for 24 hours, and ones it gave up on for 7
days. This script reads the log of the webhook registered for WEBHOOK_URL:
import { Maitento } from "@maitento/sdk";
// The delivery log of the webhook you registered: one entry per update sent, newest first.const url = process.env.WEBHOOK_URL;if (!url) throw new Error("Set WEBHOOK_URL to the address you registered.");
const mt = new Maitento();const hook = (await mt.webhooks.list()).find((h) => h.url === url);if (!hook) throw new Error(`No webhook is registered for ${url}: run register first.`);
for (const d of await mt.webhooks.deliveries(hook.id, { limit: 5 })) { const state = d.isSent ? "delivered" : d.isPermanentFailure ? "given up" : `next try ${d.nextAttemptTime.toISOString()}`; console.log(`run ${d.associatedId}: ${d.numberAttempts} attempt(s), ${state}`); if (d.failureDetails) console.log(` ${d.failureDetails}`);}import osimport sys
from maitento import Maitento
# The delivery log of the webhook you registered: one entry per update sent, newest first.url = os.environ.get("WEBHOOK_URL")if not url: sys.exit("Set WEBHOOK_URL to the address you registered.")
mt = Maitento()hook = next((h for h in mt.webhooks.list() if h.url == url), None)if hook is None: sys.exit(f"No webhook is registered for {url}: run register first.")
for d in mt.webhooks.deliveries(hook.id, limit=5): state = "delivered" if d.is_sent else "given up" if d.is_permanent_failure else f"next try {d.next_attempt_time.isoformat()}" print(f"run {d.associated_id}: {d.number_attempts} attempt(s), {state}") if d.failure_details: print(f" {d.failure_details}")#:package Maitento.Sdk@26.10.0
using Maitento.Sdk;
// The delivery log of the webhook you registered: one entry per update sent, newest first.var url = Environment.GetEnvironmentVariable("WEBHOOK_URL");if (string.IsNullOrEmpty(url)) throw new InvalidOperationException("Set WEBHOOK_URL to the address you registered.");
await using var mt = MaitentoClient.CreateFromEnvironment();var hook = (await mt.WebHooks.GetAllAsync()).FirstOrDefault(h => h.Url == url) ?? throw new InvalidOperationException($"No webhook is registered for {url}: run register first.");
foreach (var d in await mt.WebHooks.GetDeliveriesAsync(hook.Id, limit: 5) ?? []){ var state = d.IsSent ? "delivered" : d.IsPermanentFailure ? "given up" : $"next try {d.NextAttemptTime:O}"; Console.WriteLine($"run {d.AssociatedId}: {d.NumberAttempts} attempt(s), {state}"); if (d.FailureDetails != null) Console.WriteLine($" {d.FailureDetails}");}mt webhook-delivery-list <webhook id> --limit 5curl "https://api.maitento.com/web-hooks/by-id/<webhook id>/deliveries?limit=5" -H "Authorization: $MAITENTO_AUTH"run 50116def-d9fa-44e7-b209-7897bbf657ac: 1 attempt(s), deliveredThe portal shows the same log: Webhooks, then the webhook, then Delivery log.
6. Break it on purpose: the wrong secret
Section titled “6. Break it on purpose: the wrong secret”Stop the receiver with Ctrl+C, and start it again with a secret that isn’t the one you registered:
WEBHOOK_SECRET="not-the-registered-secret" node receiver.tsWEBHOOK_SECRET="not-the-registered-secret" uvicorn receiver:app --port 3001WEBHOOK_SECRET="not-the-registered-secret" dotnet runRun fun-fact again. The run finishes as before, but the signature your receiver works out with the wrong secret
doesn’t match the one Maitento sent, so it turns the delivery away:
401: a delivery without a valid signatureThe delivery log says what happened, and when Maitento will try again:
run 8a0ce522-3f63-43ca-aff0-8241c5615ac7: 1 attempt(s), next try 2026-10-26T20:07:41.640Z Unhandled exceptionSystem.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).Run the script again over the next minutes, and watch Maitento try again, at longer and longer gaps. The first three tries come within seconds of each other, in case the failure was a blip; then Maitento waits 2, 4 and 8 minutes. After the sixth try, about 14 minutes after the first, Maitento gives up on that update:
run 8a0ce522-3f63-43ca-aff0-8241c5615ac7: 6 attempt(s), given up Unhandled exceptionSystem.Net.Http.HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).An update that’s given up is never sent again, but nothing about the run is lost: its final state is still there to
read, with interactionRuns.get or GET /interactions/processes/by-id/{id}. After an outage, read the runs you care
about.
Now put it right before Maitento gives up: restart the receiver with the registered secret. (If it has given up
already, break it again and run fun-fact once more.) The next try is delivered, and the receiver prints the run’s
final state, a couple of minutes late but complete:
InteractionProcess d4a18f06-3163-4855-9d8b-304da90ef568 is now Finished output: One surprising fact about octopuses is that they have three hearts—two that pump blood to the gills and one that pumps it to the rest of the body—and the latter stops beating when they swim, which is why they prefer crawling.run d4a18f06-3163-4855-9d8b-304da90ef568: 2 attempt(s), deliveredDone when: the log shows the run delivered after more than one attempt.
If it didn’t work
Section titled “If it didn’t work”- The receiver prints nothing, and the log says
Connection refusedorName or service not known. Maitento can’t reach your receiver: the tunnel has stopped, or isn’t pointed at port 3001. A quick tunnel gets a new address every time it starts, so register the new one (see the last point). - Registering fails with
… [api:webhooks:address-blocked], or the log saysMaitento won't deliver to localhost: the address isn't on the public internet. The webhook points atlocalhost, a private address or a name on your own network, and Maitento only delivers to addresses on the public internet. Register the tunnel’s address. - The log says
404 (Not Found). The address you registered doesn’t end in/maitento/webhook. - The log says
301 (Moved Permanently)or another redirect. Maitento doesn’t follow redirects, so a delivery never goes anywhere you didn’t register. Register the address the redirect points at. - Every delivery is a
401. The receiver’sWEBHOOK_SECRETisn’t the one you registered. See step 6. - Every delivery is a
401, and the secret is right. Something changed the body before you verified it. The signature covers the bytes Maitento sent, and JSON that’s parsed and written out again can come out different. Look for anexpress.json(), or anything else that reads the body as JSON, ahead of the webhook route, and verify the raw body, as the receivers here do. - Still a
401, with the right secret and the raw body. Your computer’s clock is more than 5 minutes out, so every delivery looks too old (or too new) to be fresh. Set the clock automatically, from the internet. - Registering again fails with
WebHook with URL '…' and type 'InteractionProcess' already exists. It’s already registered: use it, or delete it first. - Your receiver prints runs you didn’t start. A webhook hears about every run of its kind in your tenant. Filter
on what you care about, such as the interaction (
run.interaction.id). - A new address. Delete the webhook and register the new address, or update it with
webhooks.update. Moving it to a new address needs the secret again, so only someone who knows it can send your deliveries somewhere else.
When you’re done, delete the webhook, or Maitento keeps sending to an address that no longer answers:
mt webhook-delete <webhook id>, webhooks.delete(id) in the SDKs, or Delete on the portal’s Webhooks
page.
All the files
Section titled “All the files”On GitHub: examples/11-webhooks · TypeScript · Python · C#
All the files in this project (2)
model = @ref("deepseek.deepseek-v4-flash")prompt = "You tell people surprising, true facts. Answer in one sentence."summary = "Tells one surprising fact"// Something quick to run, so your receiver has a run to hear about.agent = { name = "FactTeller" agent = @ref("fact-teller")}task = "Tell me one surprising fact about %%topic%%."inputs.prompts = [ { name = "topic" description = "What the fact is about" required = true }]Review
Section titled “Review”You built a receiver that Maitento calls whenever a run changes. It checks the signature, keeps the newest state of each run, and answers fast. You saw a failed delivery retried, and the log that records every one.
- Webhook: an address and a secret, registered for
one kind of run in your tenant. Maitento
PUTs the run’s whole state there whenever it changes. - The signature proves that a delivery came from Maitento and wasn’t changed, and its timestamp stops an old one
being sent again. Check it against the raw body, before you parse it, and use
https. - Repeats and merges: a delivery can come twice, and quick changes are merged. The final state always arrives.
Keep the newest
dateUpdated. - Failures (anything but a 2xx, or no answer within 100 seconds) are tried again: six tries over about 14 minutes, then the update is given up. The delivery log shows each one.
Challenge: in tutorial 10, someone had to watch a terminal to approve a refund. Make your receiver tell you
instead: when a run is PendingApproval, print which tool it wants to call, with what arguments, and the command that
approves it. Run tutorial 10’s refund (request from its solution starts one without waiting) and watch your
receiver.
Done when: your receiver prints the refund the agent wants, and approving it from the CLI refunds the order.
The solution is in examples/11-webhooks/solution: the receiver, with the
approval added, in all three languages.
InteractionProcess 83cb413d-3380-4da9-9d95-35d5f890be6e is now RunningInteractionProcess 83cb413d-3380-4da9-9d95-35d5f890be6e is now PendingApproval needs approval: Support wants to call refund-order with {"request":{"amount":5,"reason":"Tea sampler was missing two of its teas"},"additionalInputs":{"path.orderId":"A-1001"}} approve with: mt interaction-approve 83cb413d-3380-4da9-9d95-35d5f890be6eNext: 12. Live events gets the same news without a public address: your code opens one connection to Maitento, and every change arrives over it as it happens.