Gareth Wilson Gareth Wilson

Guide to camelAI Webhooks: Features and Best Practices

Published


camelAI runs AI agents for you on camelRun, its hosted agent runtime. An agent run can take a while, can stop to ask a person a question, and costs money with every model response, so camelRun's webhooks report exactly those moments: a run started, finished or failed, an agent is waiting for input, usage was recorded, or a balance is running low. (camelAI is not CAMEL-AI.org, the open-source multi-agent framework.)

Here you'll find camelRun's event envelope and its 20 types, how endpoints and secrets are managed through the API, how the Standard Webhooks signature works during a rotation, and what camelRun's 3-day retry window means for your receiver.

What are camelAI webhooks?

A camelRun webhook endpoint is a URL with a list of event types, created through the API or the camelAI console. You can have up to 16. Each endpoint gets its own whsec_ secret, which camelRun returns once, and every request is signed per Standard Webhooks.

The body is always an envelope of id, type, created and data. camelRun keeps data small, with IDs and key facts, and expects you to fetch anything larger, such as a run's reply, with your API key. One useful detail: the metadata you attach to a message when you start a run comes back on that run's events, so you can route an event to the right conversation or tenant without a lookup.

camelAI webhook features

FeatureDetails
ConfigurationPOST https://run.camelai.com/v1/webhooks or the console. GET, PATCH, DELETE to manage
EndpointsUp to 16. OAuth apps can't create or change them
SignatureStandard Webhooks HMAC-SHA256
Headerswebhook-id (the event id), webhook-timestamp (Unix seconds), webhook-signature
Secretwhsec_... per endpoint, shown once. Replacing it keeps the old one signing for 24 hours
BodyJSON envelope {id, type, created, data}
Event routingBody field type
Response window2xx within 10 seconds
RetriesExponential backoff, 5 seconds to an hour apart, for 3 days
OrderingNot guaranteed. Retries can reorder events
Versioningdata only gains fields. Breaking changes come as new types
Source IPsNot published

Events and payloads

EventTriggered WhenCommon Use Cases
run.startedAn agent run beginsTrack run lifecycle
run.completedA run finishes successfullyCollect results, notify users
run.failedA run failsAlerting, retries
input.requestedA run asks for inputRoute a question to a person
input.resolvedA requested input is answeredResume or audit a run
usage.recordedUsage is recordedMetering and cost tracking
voice.session.startedA voice session startsOpen a call record
voice.session.endedA voice session endsClose a call record
voice.session.errorA voice session errorsAlerting
voice.utteranceAn utterance is producedLive transcripts
voice.speech.startedSpeech startsTurn-taking, barge-in
voice.speech.endedSpeech endsTurn-taking
billing.balance.lowThe account balance is lowTop-up reminders
billing.balance.depletedThe account balance is used upPause workloads
end_user.balance.lowAn end user's balance is lowPrompt the end user to top up
end_user.credit.exhaustedAn end user's credit runs outBlock or upsell
end_user.credit.grantedCredit is granted to an end userLedger sync
end_user.credit.refundedCredit is refunded to an end userLedger sync
end_user.credit.adjustedAn end user's credit is adjustedLedger sync
end_user.allowance.lowAn end user's allowance is lowWarn the end user

camelRun's documented envelope, for a completed run:

{"id": "evt_3f9c0a1b2c3d4e5f60718293", "type": "run.completed", "created": 1790000000,
 "data": {"agentId": "client_...", "requestId": "...", "method": "prompt", "metadata": {"source": "web"},
          "replyIndex": 3, "messageCount": 4,
          "usage": {"responses": 2, "input": 2300, "output": 140, "cacheRead": 0, "cacheWrite": 0, "costUsd": 0.0081}}}

A few fields need attention:

  • run.completed doesn't always mean the agent finished its task. If the run stopped early, stopped says why: input_required (with inputIds), spend_limit or turn_limit.
  • run.failed adds error and, where the error has a name, code, such as cancelled, aborted, model_stream_stalled or turn_limit. uncertain marks a run that a camelRun restart cut short.
  • input.requested carries inputId, toolCallId, kind and expiresAt, so a human-in-the-loop handler knows how long it has.
  • usage.recorded has a kind of response, compaction, transcription, image or speech. camelRun says kinds may be added, so treat unknown ones as other usage.
  • Billing amounts are integers in micro-USD. billing.balance.low fires when the balance crosses the threshold (default $2), not on every charge below it. A threshold change can also fire it, with source: "threshold_changed" and no previousBalance.

The voice and end-user events are described on camelAI's voice calls and end-user credits pages.

Setting up camelAI webhooks

Register the endpoint and the event types it wants:

curl https://run.camelai.com/v1/webhooks \
  -H "Authorization: Bearer $CAMELAI_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/camel-ai", "events": ["run.completed", "run.failed", "input.requested"], "description": "prod"}'

The response holds the endpoint's id (we_...) and its secret. Save the secret as CAMELAI_WEBHOOK_SECRET before doing anything else, because camelRun won't show it again. The console offers the same thing without code, and the Python SDK has create_webhook, webhooks, update_webhook, delete_webhook and rotate_webhook_secret on AgentRuntime.

Each endpoint records who set it in setBy (token:<id>, console or oauth:<grant id>). Revoking an API token doesn't stop the endpoints it created; the revoke response lists them in left so you can review them.

An endpoint only sees runs that start after it exists, so create it before you kick off the runs you want to watch. For local testing, the Hookdeck CLI gives you a public URL:

npx hookdeck-cli listen 3000 camel-ai --path /webhooks/camel-ai

With Hookdeck. Make a Hookdeck source of type camelAI, paste the endpoint's whsec_ secret, and register the source URL as the camelRun endpoint. The source only accepts POST. camelRun gets a 200 for every request; any request whose signature fails is kept in Hookdeck as failed verification and is not delivered onward.

Securing camelAI webhooks

camelRun signs {webhook-id}.{webhook-timestamp}.{raw body} with HMAC-SHA256, keyed with the base64 part of the secret after whsec_, and sends v1,<base64> in webhook-signature. For 24 hours after you replace a secret, both the old and new secrets sign each request, and the header holds two space-separated signatures. The function below passes if any v1 entry matches:

const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

In the route, deduplicate on id and use the metadata you sent with the message to find the conversation:

const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/camel-ai', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.CAMELAI_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const event = parseJsonObject(req.body);
  if (!event) return res.sendStatus(400); // signed, but not a JSON object
  if (typeof event.type !== 'string' || !event.data || typeof event.data !== 'object') return res.sendStatus(400);
  try {
    if (!(await recordEventOnce(event.id))) return res.sendStatus(200);

    if (event.type === 'input.requested') {
      await askHuman(event.data.metadata, event.data.inputId, event.data.expiresAt);
    } else if (event.type === 'run.failed') {
      await notifyThread(event.data.metadata, event.data.code || event.data.error);
    }
  } catch {
    await releaseEvent(event.id).catch(() => {}); // free the claim so the retry is processed
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

recordEventOnce, askHuman and notifyThread are placeholders for your own storage and messaging.

camelAI's Python helper, verify_webhook, rejects any request sent more than 5 minutes ago, and the TypeScript standardwebhooks library it suggests does the same. Hookdeck has no timestamp tolerance and keeps camelRun's original webhook-timestamp when it retries or replays, so those helpers would reject events that were simply delayed. Use a signature-only check like the one above when Hookdeck is in front of your handler, and lean on deduplication by id for replay protection.

Make camelAI webhooks production-ready. Hookdeck Event Gateway verifies camelRun's signatures, stores every run and billing event, and delivers them to your workers at a pace they can handle.

camelAI webhook limitations and pain points

The secret appears once

The Problem: You need the signing secret again and the API won't return it.

Why It Happens: camelRun returns secret only in the create response.

Workarounds:

  • Call POST /v1/webhooks/{webhookId}/secret to get a new one. The old secret keeps signing for 24 hours, so deploy the new one within that window.
  • Accept both secrets in your verifier during the changeover, as the Python SDK does when you pass a list.

How Hookdeck Can Help: Behind Hookdeck, a rotation means updating the secret on one source rather than in every service.

Retries reorder events

The Problem: run.started lands after run.completed, or two balance events arrive backwards.

Why It Happens: camelRun delivers at least once and says retries can reorder deliveries.

Workarounds:

  • Order by created and ignore anything older than what you have stored.
  • Deduplicate by id, which a retried event keeps.

How Hookdeck Can Help: Hookdeck's deduplication can discard repeated event IDs before your handler sees them.

run.completed isn't always done

The Problem: A run reported as completed hasn't produced the answer you expected.

Why It Happens: A run that stops for input or hits a spend or turn limit still ends as run.completed, with the reason in stopped.

Workarounds:

  • Check data.stopped before marking a task as finished.
  • Subscribe to input.requested if your product needs to answer agents' questions.

How Hookdeck Can Help: Hookdeck filters can route completed runs that carry stopped to a different destination from those that don't.

The deprecated usage webhook is a different format

The Problem: A handler written for endpoint events breaks on usage callbacks from an older integration.

Why It Happens: The legacy PUT /v1/usage-webhook posts each model response's usage as a bare body with no envelope and no type, and camelAI doesn't describe how, or whether, it is signed.

Workarounds:

  • Move to an endpoint subscribed to usage.recorded, which camelAI recommends for new integrations.
  • Keep any remaining usage-webhook traffic on a separate route.

How Hookdeck Can Help: A separate Hookdeck source for the legacy URL keeps that traffic out of the verified camelAI source.

Best practices

Answer within 10 seconds, then do the work

camelRun counts anything slower as a failure. Its own guidance is to queue the event and return 200, then fetch the run's reply from GET /v1/agents/{id}/requests/{requestId} afterwards. See why to process webhooks asynchronously.

Keep the body raw

Signatures are computed over the exact bytes. Use express.raw({ type: '*/*' }), await request.text() in a Next.js route handler, or request.get_data() in Flask.

Put routing hints in metadata

Send your own IDs as metadata when you start a run. They come back on its events, so a handler can act on the right thread or customer without a database lookup.

Tolerate new fields and kinds

camelRun adds fields to data over time and may add usage.recorded kinds. Ignore fields you don't use and don't fail on unknown kinds. See our guide to webhook idempotency for handling the duplicates that at-least-once delivery brings.

Conclusion

camelRun's webhooks are built for agent workloads: small envelopes that point back to the run, your own metadata carried through, and dedicated events for human input and spend. Verify with the endpoint's secret and accept two signatures during a rotation, check stopped before trusting run.completed, order by created, and answer inside 10 seconds.

When your handler is down for longer than camelRun's 3 days of retries, events are gone. Hookdeck Event Gateway accepts and stores them first, so that limit stops applying to you. Open a free Hookdeck account to connect camelRun.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.