Jump toHow it works
Webhooks
Webhooks, both ways
Start a workflow from any system with a signed request, and get signed events back when something happens in your organization. Both directions use a shared secret, so each side knows the other is who it says.
How it works
A webhook trigger belongs to a workflow. When the workflow is published, the trigger gets an address and a secret. A request to that address is checked, stored and answered at once with 202; the workflow runs right after, never inside the request.
01The sender posts
Any body, signed with the trigger's secret.
02MyDeepAgents checks
The signature, the size and whether it has seen this event before.
03The workflow runs
With the payload as its input, shaped by your rules if you set any.
- Webhooks start workflows. To have an agent answer a webhook, put an Agent step in the workflow.
- The other direction: add a webhook endpoint and MyDeepAgents posts signed events to it when runs finish, approvals wait, connections change and more.
Add a webhook trigger
In the workflow builder, open the workflow's When card and choose Webhook. Or add it to the definition yourself:
Publish the workflow. Publishing gives the trigger its address and creates its secret. Each alias has its own address and secret, so the staging and live versions never share one; events sent to a staging address are marked as tests.
{
"id": "incoming",
"type": "webhook"
}Find the address and secret
Select the trigger in the builder: its panel shows the address. Publishing also creates a secret but never shows it, so choose Show a new secret in the panel to get one you can give the sender. Through the API, GET /v1/workflows/{id}/triggers lists each trigger with its url, and POST /v1/triggers/{id}/secret makes a new secret. The address stays the same when you publish again.
https://api.mydeepagents.com/v1/in/k7m2p9qa/trg_01jcte7b16k06c684ac89xccgxSend an event
Send a POST to the trigger's address. There is no API key: the signature is the proof.
BODY='{"order_id":"A-1001","amount":42}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MDA_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "https://api.mydeepagents.com/v1/in/k7m2p9qa/trg_01jcte7b16k06c684ac89xccgx" \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIGNATURE" \
-d "$BODY"How the body is read
| Field | What it means |
|---|---|
| JSON | Read as JSON. A JSON object becomes the run's input unless you map it. |
| Form | application/x-www-form-urlencoded becomes a flat object; a repeated name keeps its last value. |
| Anything else | Kept as text, available to your mapping rules. |
| Empty | Treated as an empty object. |
A body can be up to 64 KB. Larger requests are refused with 413.
Verify with signatures
The default check is an HMAC-SHA256 of the raw body, keyed with the trigger's secret, sent in the X-Signature header. MyDeepAgents computes the same value and compares the two in constant time.
The header
X-Signature: sha256= followed by the hex digest. The sha256= prefix is optional. You can choose another header name, and base64 instead of hex.
- Sign the exact bytes you send. Re-serializing the JSON, even with different spacing, changes the signature.
- The HMAC key is the secret exactly as shown, as text. Don't decode it.
Add a timestamp against replays
Name a timestamp header in the trigger, such as X-Timestamp, and the signed text becomes the timestamp, a dot, then the raw body. The timestamp is Unix seconds and must be within 5 minutes of the time it arrives, so a captured request can't be replayed later.
{
"id": "incoming",
"type": "webhook",
"verify": {
"scheme": "hmac_sha256",
"header": "X-Signature",
"encoding": "hex",
"timestamp_header": "X-Timestamp"
},
"dedupe": {
"from": "header",
"name": "X-Event-Id"
},
"headers": [
"X-Event-Id"
]
}Sign a request
BODY='{"order_id":"A-1001","amount":42}'
TS=$(date +%s)
SIGNATURE=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$MDA_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "https://api.mydeepagents.com/v1/in/k7m2p9qa/trg_01jcte7b16k06c684ac89xccgx" \
-H "Content-Type: application/json" \
-H "X-Timestamp: $TS" \
-H "X-Signature: sha256=$SIGNATURE" \
-d "$BODY"The simpler alternative: a static token
For senders that can't compute a signature, the static_token check compares a header with the secret exactly. It proves less, since anyone who sees one request can reuse it, so organizations under the privacy or HIPAA profiles can't publish it.
Signature tester
Compute a signature here to check your code against. Everything runs in your browser; nothing is sent anywhere.
The trigger's secret, as shown when it was created.
Exactly the bytes you will send.
{"order_id":"A-1001","amount":42}Enter a secret to see the signature.
Responses
The answer is short on purpose: a refusal never explains itself beyond its status, so it teaches nothing to someone probing your address.
| Status | What it means |
|---|---|
202 | Received. The body holds the event's ID. A repeat of an event already received gets the first event's ID. {"received": true, "event_id": "evt_…"} |
401 | The signature or timestamp is missing or wrong. |
404 | No such trigger at this address. |
410 | The trigger was retired, or the organization is not active. |
413 | The body is over 64 KB. |
Shape the payload
By default the JSON object you send becomes the workflow's input. To use only part of it, rename fields or skip some events, add a filter and a map to the trigger. Both are expressions over event:
event | What it means |
|---|---|
event.id | The event's ID. |
event.body | The parsed body. |
event.headers | The headers you chose to keep, plus content-type and user-agent. |
event.query | The query string, as an object. |
event.received_at | When it arrived. |
Start only for paid orders, with two fields
- filter must be true for the workflow to start; otherwise the event is logged as
filtered. - map builds the input. An expression on its own keeps its type, so a number stays a number.
- The input is then checked against the workflow's input schema. If it doesn't fit, the event is logged as
map_errorand no run starts.
{
"id": "incoming",
"type": "webhook",
"filter": "event.body.status == 'paid'",
"map": {
"order_id": "${event.body.order.id}",
"total": "${event.body.order.total}"
}
}Duplicates
Senders retry, so the same event can arrive twice. MyDeepAgents remembers each event for 24 hours and answers a repeat with 202 and the first event's ID, without starting another run.
dedupe.from | What it means |
|---|---|
hash | By default, two requests are the same event when their bodies are identical. |
header | Use a header instead, such as an event ID the sender puts in every request. |
body | Or a field in the body, by its dotted path, like data.id. |
Rotate the secret
Rotate a secret when it may have leaked, or on a schedule. The new secret works at once, and the old one keeps working for 24 hours, so you can update the sender without losing events.
curl -X POST "https://api.mydeepagents.com/v1/triggers/trg_01jcte7b16k06c684ac89xccgx/secret" \
-H "Authorization: Bearer $MDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'To use a secret the sender gave you instead, send it in the body (16 to 512 characters).
Test and monitor
Send a test event without signing anything: it starts the workflow as a real event would, marked as a test. It works on any active webhook or schedule trigger.
curl -X POST "https://api.mydeepagents.com/v1/triggers/trg_01jcte7b16k06c684ac89xccgx/test" \
-H "Authorization: Bearer $MDA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"order_id": "A-1001",
"amount": 42
}
}'The event log
Each trigger keeps a log of its events, newest first: where each came from, whether it was a test, what happened, and the run it started. Payloads are not in the log. Events are kept for 30 days after their run finishes.
{
"object": "list",
"data": [
{
"id": "evt_01jc0yrbnm84d8zbtsv0h4ksah",
"object": "trigger_event",
"source": "webhook",
"is_test": false,
"status": "started",
"status_reason": null,
"run_id": "wfr_01jcvk7x45pq07bh867msccyhv",
"created_at": "2026-11-10T09:30:00.000Z"
},
{
"id": "evt_01jc0yrbnm84d8zbtsv0h4q7rd",
"object": "trigger_event",
"source": "webhook",
"is_test": false,
"status": "filtered",
"status_reason": null,
"run_id": null,
"created_at": "2026-11-10T09:12:41.000Z"
}
],
"has_more": false
}| Status | What it means |
|---|---|
received | Accepted and waiting to start the workflow. |
started | Started a run. |
filtered | The filter said no. |
filter_error | The filter could not be evaluated. |
map_error | The mapped input didn't fit the workflow's input schema. |
dropped_running | Skipped because a run was already going and the trigger allows only one. |
skipped_paused | Arrived while the trigger was paused. |
Trigger settings
Every setting of a webhook trigger, with its default. The builder sets the common ones; the rest go in the definition.
| Field | Value | What it means |
|---|---|---|
id | — | The trigger's name inside the workflow. Lowercase letters, digits and underscores. |
type | webhook | Always webhook. |
verify.scheme | hmac_sha256 | hmac_sha256 or static_token. |
verify.header | X-Signature | The header that carries the signature or token. |
verify.encoding | hex | hex or base64, for HMAC. |
verify.timestamp_header | null | Turns on timestamped signatures when set. |
dedupe.from | hash | hash, header or body. |
dedupe.name · dedupe.path | null | The header or dotted body path to de-duplicate on. |
headers | [] | Extra headers to keep for your rules. |
filter | null | An expression that must be true to start a run. |
map | {} | How to build the run's input from the event. |
Receive events
Add a webhook endpoint, the address of a server you run, and choose the events it should hear about. MyDeepAgents then posts each event to it as JSON, signed with the endpoint's own secret.
Add an endpoint in Developers → Webhooks → Endpoints, or with POST /v1/webhook_endpoints. The address must be https on the internet. The signing secret, starting whsec_, is shown once, when the endpoint is made.
What arrives
The body is deliberately thin: IDs and states, never content such as messages or files. Read the details with your API key when you need them. This keeps customer data out of every log the event passes through.
{
"id": "evt_01jc0yrbnm84d8zbtsv0h4ksah",
"type": "agent_run.state_changed",
"created_at": "2026-11-10T09:30:00.000Z",
"tenant_id": "ten_01jcs79j0bwr7ahcbyn0fjsmk6",
"workspace_id": "ws_01jccj30nr8ad7mx1yc5v9zx3h",
"data": {
"object": "agent_run",
"id": "run_01jcr77659p6hws1zjqkthay73",
"run_id": "run_01jcr77659p6hws1zjqkthay73",
"state": "completed"
}
}Headers
| Header | What it means |
|---|---|
MDA-Signature | t= the Unix time it was signed, then v1= the signature. During a secret rotation there are two v1 values. |
MDA-Event-Id | The event's ID. The same event can arrive more than once; use this to notice. |
MDA-Delivery-Id | This delivery's ID, different for each redelivery. |
MDA-Event-Type | The event's type, so you can route it before parsing the body. |
User-Agent | Always MyDeepAgents-Webhooks/1. |
Verify an event
Check every request before you trust it. The signature is an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the endpoint's secret exactly as shown, whsec_ included.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a Buffer), before JSON parsing.
export function verifyMdaSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((part) => part.trim().split("="));
const t = Number(parts.find(([key]) => key === "t")?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return parts
.filter(([key]) => key === "v1")
.some(([, value]) => {
const given = Buffer.from(value, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: keep the raw body for this route.
app.post("/hooks/mydeepagents", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyMdaSignature(req.body, req.get("MDA-Signature") ?? "", process.env.MDA_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
res.sendStatus(204); // answer fast; do the work after
});- Use the raw bytes of the body as received, before any JSON parsing.
- Refuse a timestamp more than 5 minutes from your clock, so a captured request can't be replayed.
- Accept the request if any
v1matches, and compare in constant time. - Deliveries come at least once and in no particular order: skip an event ID you have already handled, and trust the state in the body over the order of arrival.
Retries and redelivery
If your server doesn't answer with a 2xx, MyDeepAgents tries again: after 10 seconds, then each wait three times longer, up to 4 hours apart, with a little randomness, for 24 hours in all. That is about 13 attempts.
After a day of failures the organization's admins are told the endpoint is failing. After three days it is turned off, and they are told again. Turning it back on sends a test event first and works only if your server answers.
Each endpoint's page lists every delivery for 30 days, with each attempt's answer and timing. From there you can send a test event, send any delivery again, pause the endpoint or rotate its secret; the old secret keeps signing for 24 hours so you can switch without missing events.
Event types
Subscribe an endpoint to every event, to all events about one thing (agent_run.*), or to the ones you list. New types can appear; an endpoint on every event gets them automatically. Test events have the type ping.
| About | Events |
|---|---|
agent_run | agent_run.state_changed |
workflow_run | workflow_run.state_changed |
approval | approval.requestedapproval.resolved |
agent | agent.publishedagent.alias.moved |
workflow | workflow.publishedworkflow.alias.moved |
widget | widget.publishedwidget.alias.movedwidget.status.changed |
connection | connection.authorizedconnection.health_changed |
mcp_server | mcp_server.tools_approvedmcp_server.tool_changedmcp_server.health_changed |
member | member.addedmember.removed |
api_key | api_key.createdapi_key.revoked |
delegation | delegation.accepteddelegation.revoked |
budget | budget.threshold_crossed |
key_config | key_config.health.changed |
notification | notification.created |
Build with us
Ready for your first call?
Early access is open. Tell us what you want your agents to do, and we will help you get your first key.