Skip to Content
IntegrationsWebhooks

Webhooks

Receive AI-generated results from Convoy via HTTP callbacks. This is the foundational pattern that all integrations build upon.

Testing tip — Build and verify this integration end-to-end with convoy-mock first. It’s a free, synthetic model that returns a callback in ~60 seconds and is never billed. Once your wiring works, swap "model": "convoy-mock" for a production model like claude-3-haiku.

How Convoy Webhooks Work

When you submit a prompt to Convoy, you can include a callback_url. Once batch processing completes, Convoy sends an HTTP POST request to that URL with the results.

Webhooks are optional. If you omit callback_url, the submission is “mailbox-only”: no webhook is sent and you fetch the stored result with GET /cargo/{cargo_id}/result instead — same payload shape, no public endpoint required. Results are stored server-side for 30 days either way. Running on AWS Lambda durable functions? Direct AWS Durable Callbacks skip webhooks entirely — Convoy resumes your suspended execution through IAM, with no endpoint, receiver, or signing scheme to build.

┌──────────┐ POST /cargo/load ┌─────────┐ │ Your │ ─────────────────────────────────▶ │ Convoy │ │ System │ { params, callback_url } │ API │ └──────────┘ └────┬────┘ │ (batch processing) │ ┌──────────┐ POST to callback_url ┌────▼────┐ │ Your │ ◀───────────────────────────────── │ Convoy │ │ Webhook │ { cargo_id, success, response } │ Worker │ └──────────┘ └─────────┘

Convoy batches requests for cost efficiency. Results typically arrive within minutes to hours, depending on queue volume and batch size (typically within 24 hours). Design your webhook receiver to handle asynchronous delivery.


Callback Payload

When processing completes, Convoy POSTs this JSON payload to your callback_url:

Success Payload

{ "cargo_id": "cargo_abc123def456", "success": true, "response": { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "type": "message", "role": "assistant", "content": [ { "type": "text", "text": "Here is the generated content..." } ], "model": "claude-sonnet-4.5", "stop_reason": "end_turn", "usage": { "input_tokens": 150, "output_tokens": 500 } }, "error_message": null, "created_at": "2026-08-29T14:03:22.512000+00:00", "metadata": { "order_id": "ord_8842" } }

Error Payload

{ "cargo_id": "cargo_abc123def456", "success": false, "response": null, "error_message": "Model rate limit exceeded. Request will be retried.", "created_at": "2026-08-29T14:03:22.512000+00:00" }

Field Reference

FieldTypeDescription
cargo_idstringUnique identifier for the request (starts with cargo_)
successbooleanWhether processing completed successfully
responseobject | nullThe AI model’s response (null on failure)
response.content[].textstringThe generated text content
response.modelstringThe model that processed the request
response.stop_reasonstringWhy the model stopped (end_turn, max_tokens, stop_sequence)
response.usage.input_tokensintegerTokens consumed by the prompt
response.usage.output_tokensintegerTokens generated in the response
error_messagestring | nullError description (null on success)
created_atstringISO 8601 timestamp of when the result was created
metadataobject | nullThe metadata object you supplied on /cargo/load, echoed back verbatim (null if you didn’t supply one)

Building a Webhook Receiver

Requirements

Your webhook endpoint must:

  1. Accept POST requests with a JSON body
  2. Return a 2xx status code within 30 seconds to acknowledge receipt
  3. Be publicly accessible via HTTPS (for production use)

If your endpoint doesn’t respond with a 2xx within 30 seconds, Convoy will retry the delivery. Design your handler to be idempotent — use the cargo_id to deduplicate.

Python (Flask)

from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/webhook/convoy", methods=["POST"]) def convoy_callback(): payload = request.get_json() cargo_id = payload["cargo_id"] success = payload["success"] if success: text = payload["response"]["content"][0]["text"] model = payload["response"]["model"] tokens = payload["response"]["usage"]["output_tokens"] print(f"[{cargo_id}] Received {tokens} tokens from {model}") # Process the result — save to DB, send notification, etc. else: error = payload["error_message"] print(f"[{cargo_id}] Failed: {error}") # Handle the error — alert, retry logic, etc. # Always return 200 to acknowledge receipt return jsonify({"received": True}), 200 if __name__ == "__main__": app.run(port=5000)

Python (FastAPI)

from fastapi import FastAPI, Request from pydantic import BaseModel from typing import Optional app = FastAPI() class ConvoyCallback(BaseModel): cargo_id: str success: bool response: Optional[dict] = None error_message: Optional[str] = None @app.post("/webhook/convoy") async def convoy_callback(payload: ConvoyCallback): if payload.success: text = payload.response["content"][0]["text"] # Process the result print(f"[{payload.cargo_id}] Result: {text[:100]}...") else: # Handle the error print(f"[{payload.cargo_id}] Error: {payload.error_message}") return {"received": True}

Node.js (Express)

const express = require('express'); const app = express(); app.use(express.json()); app.post('/webhook/convoy', (req, res) => { const { cargo_id, success, response, error_message } = req.body; if (success) { const text = response.content[0].text; const model = response.model; console.log(`[${cargo_id}] Received from ${model}: ${text.slice(0, 100)}...`); // Process the result } else { console.error(`[${cargo_id}] Failed: ${error_message}`); // Handle the error } // Always respond 200 to acknowledge res.status(200).json({ received: true }); }); app.listen(3000, () => console.log('Webhook receiver running on :3000'));

Node.js (Next.js API Route)

// app/api/webhook/convoy/route.ts import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { const payload = await request.json(); const { cargo_id, success, response, error_message } = payload; if (success) { const text = response.content[0].text; // Save to database, trigger notification, etc. console.log(`[${cargo_id}] Result received`); } else { console.error(`[${cargo_id}] Error: ${error_message}`); } return NextResponse.json({ received: true }, { status: 200 }); }

Retry Behavior

Convoy retries failed callback deliveries with exponential backoff — up to 5 attempts total:

AttemptDelay before attemptTotal Time Elapsed
2nd attempt (1st retry)1 minute~1m
3rd attempt3 minutes~4m
4th attempt9 minutes~13m
5th attempt (final)27 minutes~40m

A delivery is considered failed if:

  • Your endpoint returns a non-2xx status code
  • The connection times out (30 second limit)
  • DNS resolution fails
  • The connection is refused

After all retries are exhausted, the callback status changes to callback_failed. The result is not lost — fetch it any time within its retention window (30 days by default) with GET /cargo/{cargo_id}/result. The body is identical to the webhook payload your endpoint would have received.


Idempotency

Your webhook handler should be idempotent — processing the same callback twice should not cause duplicate side effects.

Use the cargo_id as a deduplication key:

# Example: Track processed callbacks in a set or database processed_callbacks = set() @app.route("/webhook/convoy", methods=["POST"]) def convoy_callback(): payload = request.get_json() cargo_id = payload["cargo_id"] # Skip if already processed if cargo_id in processed_callbacks: return jsonify({"received": True, "duplicate": True}), 200 processed_callbacks.add(cargo_id) # ... process the result ... return jsonify({"received": True}), 200

For production, use a database or Redis to track processed IDs rather than an in-memory set.


Local Development

During development, your webhook endpoint isn’t publicly accessible. Use a tunneling tool to expose it:

Using ngrok

# Start your local server python app.py # Running on localhost:5000 # In another terminal, expose it ngrok http 5000

ngrok gives you a public URL like https://abc123.ngrok.io — use this as your callback_url:

curl -X POST https://api.cnvy.ai/cargo/load \ -H "Content-Type: application/json" \ -H "X-API-Key: convoy_sk_your_key_here" \ -d '{ "params": { "model": "claude-3-haiku", "max_tokens": 512, "messages": [{"role": "user", "content": "Hello, world!"}] }, "callback_url": "https://abc123.ngrok.io/webhook/convoy" }'

Polling Instead of Webhooks (No Receiver Needed)

If you don’t want to set up a webhook receiver — during development or in production — submit without a callback_url and poll for the result:

# Submit without a callback_url (mailbox-only) curl -X POST https://api.cnvy.ai/cargo/load \ -H "Content-Type: application/json" \ -H "X-API-Key: convoy_sk_your_key_here" \ -d '{ "params": { "model": "claude-3-haiku", "max_tokens": 512, "messages": [{"role": "user", "content": "Hello, world!"}] } }' # Check the status (cheap, status-only) curl -H "X-API-Key: convoy_sk_your_key_here" \ https://api.cnvy.ai/cargo/cargo_abc123def456/tracking # Fetch the full result once terminal (409 = not finished yet) curl -H "X-API-Key: convoy_sk_your_key_here" \ https://api.cnvy.ai/cargo/cargo_abc123def456/result

See Get Result for the full polling pattern and retention details.


Security Best Practices

1. Validate the Source

While Convoy doesn’t currently sign webhook payloads, you can add security by:

  • Using a unique, hard-to-guess URL path (e.g., /webhook/convoy/a8f3b2c1d4e5)
  • Verifying the cargo_id exists in your system before processing
  • Checking that the callback corresponds to a request you actually made

2. Use HTTPS

Always use HTTPS for your callback URL in production. Convoy will not deliver callbacks to plain HTTP endpoints in production environments.

3. Rate Limit Your Endpoint

Protect your webhook endpoint from abuse:

from flask_limiter import Limiter limiter = Limiter(app, default_limits=["100 per minute"]) @app.route("/webhook/convoy", methods=["POST"]) @limiter.limit("100 per minute") def convoy_callback(): # ...

4. Process Asynchronously

For heavy processing, acknowledge the webhook immediately and process in the background:

from threading import Thread @app.route("/webhook/convoy", methods=["POST"]) def convoy_callback(): payload = request.get_json() # Acknowledge immediately Thread(target=process_result, args=(payload,)).start() return jsonify({"received": True}), 200 def process_result(payload): # Heavy processing happens here # Save to DB, generate reports, send emails, etc. pass

Troubleshooting

Callback never arrives

  1. Check the request was accepted — The /cargo/load response should include a cargo_id
  2. Verify your endpoint is reachable — Test with curl -X POST your-url
  3. Check the tracking status — If it shows callback_failed, your endpoint may be unreachable
  4. Review your server logs — Look for incoming POST requests
  5. Ensure HTTPS — Production callbacks require HTTPS endpoints

Receiving duplicate callbacks

This is expected behavior during retries. Implement idempotency using the cargo_id as described above.

Payload is empty or malformed

  • Ensure your server parses the Content-Type: application/json body correctly
  • Check that your framework’s JSON body parser is enabled (e.g., express.json() in Express)

Timeout errors

  • Your endpoint must respond within 30 seconds
  • If processing takes longer, acknowledge immediately and process asynchronously
  • Consider using a message queue (SQS, Redis) for heavy workloads
Last updated on