--- title: Handling Webhooks | cadenya --- Cadenya will send you webhooks for all types of events that happen during an agent’s objective. Tool calls, messages, and approvals will all be sent to the configured webhook URL on your agent. This makes it possible to steer agents as they need guidance during an objective, like for a tool approval. For example, if a tool approval is requested, you may want to send a message to Slack, an email to a customer, or a push notification to a device. These notifications are to let your end users know they need to hop in and provide their guidance to an action your AI agent wants to take. At a high level, this is the architecture of a simple app with Cadenya. ![High level app overview](/_astro/highlevel-app-overview-webhooks.BWgqjDv4_IWJGK.webp) ## Tools we love We recommend Svix Play as a destination for agent webhooks and the Standard Webhooks verifier as you play around with Cadenya. - [Svix Play](https://play.svix.com/) - [Standard Webhooks Verifier](https://www.standardwebhooks.com/verify) ## Webhooks Webhooks sent by Cadenya conform to [Standard Webhooks](https://www.standardwebhooks.com/). They are delivered as POST requests with a JSON envelope containing `type`, `timestamp`, and `data` (with flat `agent`, `agentVariation`, `objective`, and `objectiveEvent` keys). Signed via Standard Webhooks HMAC-SHA256 (`webhook-id`, `webhook-timestamp`, `webhook-signature` headers). | Type | Description | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `objective_event.user_message` | Triggered when a user message event occurs in an objective. | | `objective_event.assistant_message` | Triggered when an assistant message event occurs in an objective. | | `objective_event.tool_called` | Triggered when a tool call is executed in an objective. | | `objective_event.tool_result` | Triggered when a tool result event occurs in an objective. | | `objective_event.tool_error` | Triggered when a tool call encounters an error during execution. | | `objective_event.tool_approval_requested` | Triggered when a tool call requires approval (respond via [`ApproveToolCall`](/api/resources/objectives/subresources/tool_calls/methods/approve/index.md)/`DenyToolCall`). | | `objective_event.tool_approved` | Triggered when a tool call is approved via the [`ApproveToolCall`](/api/resources/objectives/subresources/tool_calls/methods/approve/index.md) RPC. | | `objective_event.tool_denied` | Triggered when a tool call is denied via the `DenyToolCall` RPC. | | `objective_event.sub_objective_created` | Triggered when a sub-objective is spawned from a parent objective. | | `objective_event.error` | Triggered when an error occurs during objective execution. | | `objective_event.memory_read` | Triggered when the agent loads a memory entry by resolving a key against the objective’s memory stack. | ## Handling a webhook Cadenya webhooks will contain `metadata` keys with the information about the event. For example, an `objective_event.assistant_message` will look like this: ``` { "timestamp": "2026-05-04T12:51:57Z", "type": "objective_event.assistant_message", "data": { "agent": { "id": "agent_01KQSGJRXYYR2W2Y34XXGPTQS9", "accountId": "account_01KQSF7M1S6S49WK2GSSYFRSHY", "workspaceId": "workspace_01KQSF7NQX9BMWKEFY06VDTDP4", "name": "Urgent Car Insurance Agent", "createdAt": "2026-05-04T12:49:32.094034Z" }, "agentVariation": { "id": "agentvar_01KQSGJRZ8Y40AFHK0JE0YAD45", "accountId": "account_01KQSF7M1S6S49WK2GSSYFRSHY", "workspaceId": "workspace_01KQSF7NQX9BMWKEFY06VDTDP4", "name": "Default", "createdAt": "2026-05-04T12:49:32.136648Z" }, "objective": { "id": "obj_01KQSGKYGKPXBNJMCRRETCP63X", "accountId": "account_01KQSF7M1S6S49WK2GSSYFRSHY", "workspaceId": "workspace_01KQSF7NQX9BMWKEFY06VDTDP4", "createdAt": "2026-05-04T12:50:10.579393Z", "profileId": "profile_01KQSF7NJ4KD83P8AEH69NXNFM" }, "objectiveEvent": { "metadata": { "id": "objevt_01KQSGQ6CRV0GJ4JTQ6NV8BF19", "accountId": "account_01KQSF7M1S6S49WK2GSSYFRSHY", "workspaceId": "workspace_01KQSF7NQX9BMWKEFY06VDTDP4", "createdAt": "2026-05-04T12:51:56.952342057Z" }, "data": { "type": "OBJECTIVE_EVENT_TYPE_ASSISTANT_MESSAGE", "assistantMessage": { "content": "Looks like you're trying to urgently contact someone about their cars insurance." } }, "contextWindowId": "objwin_01KQSGKYWQJSS9NS7DF7GMY4KM" } } } ``` ## Handling HMAC As mentioned, Cadenya webhooks are [Standard Webhooks](https://www.standardwebhooks.com/). They are verified using HMAC. Your webhook secret that is used to sign payloads is found in your [Cadenya dashboard](https://app.cadenya.com). But who doesn’t love pseudo-code to understand something quickly? Ruby is a good candidate to see this in action. First, here’s an example of a request your server will receive: ``` POST /your/callback HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: cadenya-golfswing (cadenya.com) webhook-id: whk_01J... # stable per webhook delivery webhook-timestamp: 1714838400 # unix seconds webhook-signature: v1,K8s9...= # base64 HMAC-SHA256, "v1," prefixed {"timestamp":"2026-05-04T17:20:00Z","type":"objective.completed","data":{...}} ``` The signed string is `{webhook-id}.{webhook-timestamp}.{raw-body}`, HMAC’d with your account’s webhook signing key. Ruby verifier (Sinatra-style) app.rb ``` require 'openssl' require 'base64' SIGNING_KEY = ENV.fetch('CADENYA_WEBHOOK_SECRET') # Retrieved from app.cadenya.com post '/webhooks/cadenya' do body = request.body.read webhook_id = request.env['HTTP_WEBHOOK_ID'] timestamp = request.env['HTTP_WEBHOOK_TIMESTAMP'] received = request.env['HTTP_WEBHOOK_SIGNATURE'] # "v1," halt 400, 'missing headers' unless webhook_id && timestamp && received halt 400, 'stale' if (Time.now.to_i - timestamp.to_i).abs > 300 signed_payload = "#{webhook_id}.#{timestamp}.#{body}" digest = OpenSSL::HMAC.digest('sha256', SIGNING_KEY, signed_payload) expected = "v1,#{Base64.strict_encode64(digest)}" halt 401, 'bad signature' unless Rack::Utils.secure_compare(expected, received) status 204 end ``` ## Example Workflow Say you want to let a user know when your AI Agent is requesting permission to use a tool. For example: approving an expense for a user. The flow would look like: 1. Cadenya sends your application a webhook 2. Your application sends an email to the user with a link to approve or reject 3. The customer clicks the “approve” link which is hosted by your app, and consequently sends Cadenya an [approve tool call](/api/resources/objectives/subresources/tool_calls/methods/approve/index.md) API request. 4. Your AI Agent in Cadenya continues You’d have a small app that looks like this in Ruby: app-with-tool-approval.rb ``` require 'sinatra' require 'json' require 'openssl' require 'base64' require 'net/http' require 'uri' require 'mail' CADENYA_SIGNING_KEY = ENV.fetch('CADENYA_WEBHOOK_SECRET') CADENYA_API_KEY = ENV.fetch('CADENYA_API_KEY') CADENYA_API_BASE = ENV.fetch('CADENYA_API_BASE', 'https://api.cadenya.com') APP_BASE_URL = ENV.fetch('APP_BASE_URL', 'http://localhost:4567') LINK_SIGNING_KEY = ENV.fetch('LINK_SIGNING_KEY') Mail.defaults { delivery_method :smtp, address: ENV['SMTP_HOST'], port: 587 } post '/webhooks/cadenya' do body = request.body.read webhook_id = request.env['HTTP_WEBHOOK_ID'] ts = request.env['HTTP_WEBHOOK_TIMESTAMP'] received = request.env['HTTP_WEBHOOK_SIGNATURE'] digest = OpenSSL::HMAC.digest('sha256', CADENYA_SIGNING_KEY, "#{webhook_id}.#{ts}.#{body}") expected = "v1,#{Base64.strict_encode64(digest)}" halt 401 unless Rack::Utils.secure_compare(expected, received) event = JSON.parse(body) return 204 unless event['type'] == 'objective_event.tool_approval_requested' data = event['data'] workspace_id = data['objective']['workspaceId'] objective_id = data['objective']['id'] tool_call_id = data['objectiveEvent']['data']['toolApprovalRequested']['toolCallId'] agent_name = data['agent']['name'] token = sign_link(workspace_id, objective_id, tool_call_id) Mail.deliver do to 'user@example.com' from 'agents@example.com' subject "#{agent_name} needs your approval" body <<~TXT Approve: #{APP_BASE_URL}/decide/approve?t=#{token} Deny: #{APP_BASE_URL}/decide/deny?t=#{token} TXT end 204 end get '/decide/:decision' do workspace_id, objective_id, tool_call_id = verify_link(params[:t]) action = params[:decision] == 'approve' ? 'approve' : 'deny' uri = URI("#{CADENYA_API_BASE}/v1/workspaces/#{workspace_id}/objectives/#{objective_id}/tool_calls/#{tool_call_id}/#{action}") req = Net::HTTP::Put.new(uri) req['Authorization'] = "Bearer #{CADENYA_API_KEY}" req['Content-Type'] = 'application/json' req.body = '{}' Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) } "Tool call #{action}d. The agent will continue." end def sign_link(*parts) payload = parts.join('|') + "|#{Time.now.to_i + 86_400}" sig = OpenSSL::HMAC.hexdigest('sha256', LINK_SIGNING_KEY, payload) Base64.urlsafe_encode64("#{payload}|#{sig}", padding: false) end def verify_link(token) raw = Base64.urlsafe_decode64(token) workspace_id, objective_id, tool_call_id, exp, sig = raw.split('|') payload = "#{workspace_id}|#{objective_id}|#{tool_call_id}|#{exp}" expected = OpenSSL::HMAC.hexdigest('sha256', LINK_SIGNING_KEY, payload) halt 401 unless Rack::Utils.secure_compare(expected, sig) && Time.now.to_i <= exp.to_i [workspace_id, objective_id, tool_call_id] end ``` ## Use Case: Smelly Expenses Say you are building the next expense tracking application (because there aren’t enough), and you want an AI Agent to validate and do any smell checks on the receipt automatically. If the receipt is suspicious, the agent might ask for approval before marking it as completed. Your agent’s system prompt might resemble: > You are an expense report manager that validates expenses match our general company policy. You will be given the details of a receipt, and you must validate them against our corporate policy in the memory `company/expenses/policy.md`. > > If a receipt is “smelly” - (IE: Took a road trip to Las Vegas to meet with a client and took them to a speakeasy) call the `confirm_smell` tool to request a second-glance from the employee’s manager. ![Tool call flow example](/_astro/tool-call-flow-example.B0Rv62rJ_1o8nEs.webp) ## Tricks It is recommended to leverage the `externalId` and `labels` fields of any metadata key in Cadenya to track your own state. For example, if the receipt in *your own database* has an ID of `receipt_cy1shs`, you can store it in the externalId of your objective on create: Terminal window ``` curl https://api.cadenya.com/v1/workspaces/$WORKSPACE_ID/objectives \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $CADENYA_API_KEY" \ -d '{ "agentId": "agentId", "data": {}, "metadata": { "externalId": "receiptId:receipt_cy1shs" } }' ``` Now, when Cadenya sends you webhooks during the agent’s loop, you’ll *always* receive that `externalId` you assigned in the payload: ``` { "type": "objective_event.tool_approval_requested", "timestamp": "2026-05-04T17:20:00Z", "data": { "objective": { "id": "obj_01J...", "externalId": "receiptId:receipt_cy1shs" # <-- your ID 🔥 }, "objectiveEvent": { "type": "tool_approval_requested", "data": { "toolApprovalRequested": { "toolCallId": "tc_01J..." } } } } } ```