Teams still paste a trycloudflare.com URL into an agent callback field because it works in under a minute. Cloudflare Quick Tunnels deserve the praise they get for that loop: one cloudflared tunnel --url http://localhost:8000 command gets you a public encrypted URL with automatic HTTPS. Cloudflare handles edge DDoS protection, and you do not touch DNS or inbound router ports.
That convenience hides a boundary decision. The tunnel solves reachability. Your callback path still needs a stable identity, sender authentication, durable receipt, retry handling, and someone who owns the failure modes once the callback can change state.
Quick Tunnels are great at the local loop
Cloudflare describes Quick Tunnels as "ready in seconds" and "made for development loops". That matches the product. cloudflared opens an encrypted outbound connection to Cloudflare, requests flow back through that connection, and your router never accepts inbound traffic.
For agent work, that is the right kind of convenience. You can stand up a local receiver, point a provider at the generated URL, inspect payloads, and test signature code while you iterate. Automatic HTTPS matters, and so does the fact that the URL is globally reachable without exposing your IP.
A local session can be as plain as the command below. --output json helps if you want a script or coding agent to capture the generated URL.
cloudflared tunnel --url http://localhost:8000 --output json
Cloudflare says --output json exists for scripts or coding agents that need to read the result. That is exactly the sort of friction you want to remove while you are still learning the callback shape. The product promise stops there, which is fine. Quick Tunnels give you fast reachability. They do not give you a production callback boundary.
The boundary you still own
The Quick Tunnel page gives away the key detail in two places. The example hostname is a generated https://quiet-marble-otter.trycloudflare.com, and the connection "ends with the process". Your public identity is generated, process scoped, and disposable.
That changes the design question. By callback termination, I mean the first system under your control that accepts the request from an external sender. That system has to verify who sent the callback, record that it arrived, and hand work to the rest of your application in a way you can debug later. Once a callback can create a ticket, approve a workflow, or spend money, that receiver sits on a trust boundary.
The mechanics look different in development and in production:
sequenceDiagram
participant P as Agent platform
participant C as Cloudflare edge
participant T as cloudflared
participant L as Local app
participant I as Owned ingress
participant Q as Queue or log
participant W as Worker
Note over P,L: Development loop
P->>C: HTTPS callback
C->>T: Tunnel traffic
T->>L: Forward to localhost
L-->>P: 2xx or 4xx
Note over P,W: Production callback boundary
P->>I: HTTPS callback
I->>I: Verify sender auth
I->>Q: Record receipt
I-->>P: 2xx accepted
Q->>W: Deliver event
W->>W: Check idempotency key
W->>W: Apply mutation
Alt text: The development path forwards an external callback through Cloudflare to a local process. The production path first lands on an owned ingress that verifies the sender, records receipt durably, acknowledges, and only then hands work to a worker that applies the mutation.
A generated URL and a TLS connection do not answer some basic contract questions:
Who owns the hostname? A generated trycloudflare.com subdomain is fine for a dev session. A production callback needs a stable service identity under your DNS, split by environment, with ownership that survives one developer closing a terminal.
Where do retries land? A laptop process disappearing is normal during development. In production, the receiver has to stay available across retries, and the mutation path has to detect duplicates.
Where does auth terminate? Edge HTTPS protects transport. Your application still has to verify the sender, rotate secrets or keys, and map that sender to a principal you trust for that action.
Who sees failures and responds? A terminal window tells you that one request arrived. It does not give you an event ledger, accept versus reject metrics, retry counts, or a named owner when a sender starts failing at 03:00.
A small receiver can make the boundary concrete
You do not need a large platform to move this boundary. A workable first version can be one HTTP service and one Postgres database. The receiver verifies the signature on the raw body, inserts a receipt row keyed by the provider event ID, inserts a job row in the same transaction, and returns the success code your provider expects. A worker reads the job row later and applies the mutation behind the same idempotency key.
This sketch is illustrative, not production-tested. The header names are placeholders, so map them to whatever your provider sends.
# illustrative sketch
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
from psycopg import connect
app = FastAPI()
SECRET = os.environ["AGENT_WEBHOOK_SECRET"]
DATABASE_URL = os.environ["DATABASE_URL"]
def verify_signature(raw_body: bytes, timestamp: str, signature: str) -> bool:
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/callbacks/agent")
async def receive_callback(
request: Request,
x_event_id: str = Header(..., alias="X-Event-Id"),
x_timestamp: str = Header(..., alias="X-Timestamp"),
x_signature_sha256: str = Header(..., alias="X-Signature-SHA256"),
):
raw = await request.body()
if abs(int(time.time()) - int(x_timestamp)) > 300:
raise HTTPException(status_code=401, detail="stale timestamp")
if not verify_signature(raw, x_timestamp, x_signature_sha256):
raise HTTPException(status_code=401, detail="bad signature")
with connect(DATABASE_URL) as conn, conn.transaction():
inserted = conn.execute(
"""
insert into callback_receipts
(provider, provider_event_id, payload, state)
values
(%s, %s, %s::jsonb, 'accepted')
on conflict do nothing
returning 1
""",
("agent-platform", x_event_id, raw.decode()),
).fetchone()
if inserted:
conn.execute(
"""
insert into callback_jobs
(provider, provider_event_id)
values
(%s, %s)
on conflict do nothing
""",
("agent-platform", x_event_id),
)
# Many providers treat any 2xx as success. Check the docs for yours.
return {"status": "accepted"}
The idempotency record shape can stay plain. A single primary key on (provider, provider_event_id) gives you a hard duplicate boundary, and processed_at tells the worker whether it already applied the mutation.
create table callback_receipts (
provider text not null,
provider_event_id text not null,
received_at timestamptz not null default now(),
payload jsonb not null,
state text not null default 'accepted',
processed_at timestamptz,
apply_result jsonb,
primary key (provider, provider_event_id)
);
create table callback_jobs (
provider text not null,
provider_event_id text not null,
available_at timestamptz not null default now(),
attempts integer not null default 0,
locked_at timestamptz,
primary key (provider, provider_event_id)
);
A worker can then claim a row from callback_jobs, load the matching receipt, check processed_at, apply the mutation, set processed_at, and remove the job. If you keep the queue in the same database at first, you avoid the awkward case where the receiver writes the receipt but fails before it publishes to a separate broker. Later, if you need a dedicated queue, you can swap this for an outbox pattern or a message service without changing the contract at the edge.
This pattern is not theory pulled from agent tooling alone. Stripe's webhook docs recommend returning a successful response quickly, handling events asynchronously, and guarding against duplicate deliveries. The same pressure shows up in agent callbacks because the transport problem and the retry problem are the same.
The risk shows up when callbacks mutate state
Teams rarely begin with a dangerous callback. They begin with a status update or a tool result, then the same endpoint grows teeth because the path already exists. Someone lets it write a row, trigger a workflow, or advance a run.
The coupling creeps in. Tunnel lifetime stays tied to a local process. Public identity stays tied to a generated name. Delivery visibility stays tied to a developer terminal. Operational ownership stays tied to whoever started cloudflared.
That setup can look stable for longer than it deserves because the happy path is smooth. Cloudflare gives you automatic HTTPS and global reachability. The sender reaches your app. The demo works. Nobody separates receipt from processing because there is no immediate pressure to do it.
Once the sender retries on timeout or on a non-2xx response, a combined handler creates a hard-to-explain state machine. You have no durable receipt that proves the event arrived, no clean idempotency boundary, and no easy answer to the question that matters during an incident: did that timeout mean "nothing happened" or "it happened twice"?
I would move the boundary as soon as a callback can mutate state or advance a run. The receiver does not need to be fancy. It does need an owned hostname per environment, request authentication you control, durable receipt before business logic runs, and logs and metrics that tell you the difference between accept, reject, retry, and processing failure.
You can still keep local development fast. Develop the handler with a Quick Tunnel. Validate payload shape and headers. Exercise signature verification on your laptop. Keep using the generated URL while the endpoint is disposable. Then promote the callback path to owned infrastructure before the sender can do anything you would have to explain in an incident review.
Cloudflare's own copy points to the right boundary if you read it closely. Quick Tunnels are "made for development loops", and the public URL "ends with the process". That is clear product positioning. The trouble starts when a team treats development convenience as a service contract.
Quick Tunnels are good at the job they advertise. A generated public URL from one command is perfect for local webhook debugging, signature checks, or a preview environment running on your machine, and --output json is handy when another tool needs to capture the URL.
Use them to learn the callback and to test the edge of your receiver. Put a stable hostname, durable receipt, and idempotent worker behind the real callback before the endpoint can change state.
Steal this
Paste this into your PR template or design review for any agent callback. If you cannot check every box, keep the path in development and away from state-changing use.
Agent callback promotion gate
[ ] The callback hostname is owned by the service and environment, not generated per process
[ ] The receiver authenticates the sender and has a named secret rotation owner
[ ] The receiver records receipt durably before business logic runs
[ ] Mutations are idempotent on the provider event ID or run ID
[ ] Logs and metrics separate accept, reject, retry, and processing failures
[ ] An on-call team owns the endpoint, alerts, and incident response