Skip to content

HTTP routes

@bot.route(method, path) registers a plain HTTP handler on the same Lambda function your bot already runs on. The request skips Discord signature verification, and the handler gets the raw event plus the bot instance, so it can call send_message, execute_webhook, and anything else on bot.

Use it for requests that need to land on the function without going through Discord: incoming webhooks from other services, OAuth redirect callbacks, uptime health checks.

import json
@bot.route("POST", "/webhook")
async def incoming_webhook(event, bot):
payload = json.loads(event["body"])
await bot.send_message(CHANNEL_ID, f"event {payload['id']}")
return {"received": True}

The handler is called as handler(event, bot):

  • event is the raw Lambda proxy event. Payload format 2.0, so event["headers"] (lowercase keys), event["body"] (a string, or base64 when event["isBase64Encoded"] is true), event["queryStringParameters"], event["requestContext"]["http"].
  • bot is your Cordless() instance.

It runs with no signature check. Whatever sends the request signs it its own way, verify that yourself inside the handler using the relevant header.

A {name} segment matches one path segment and lands on event["pathParameters"]:

@bot.route("POST", "/gh/{repo}/hook")
async def github_hook(event, bot):
repo = event["pathParameters"]["repo"]
...

A trailing {name+} is greedy and captures the rest of the path. A static route always wins over one with a parameter in the same position, so /u/me and /u/{name} can coexist.

Return Response
None 200, empty body
a status int that status, empty body
a str 200, text/plain
a dict or list 200, application/json
(status, body) that status, body coerced as above
(status, body, headers) plus those headers
bytes 200, application/octet-stream, base64 encoded
a full Lambda proxy dict (has statusCode) passed through unchanged

A handler that raises CordlessError returns 400; any other exception returns 500 with the traceback in CloudWatch.

POST / is Discord’s interaction endpoint and cannot be registered as a route. Every other method and path is available.

Routes work whether your endpoint is function_url (the default) or api_gateway.

  • function_url: every path reaches the function and cordless does the matching. Nothing to configure. An unknown path wakes the Lambda so cordless can return its own 404.
  • api_gateway: cordless deploy syncs your routes onto the API next to the Discord commands, creating new ones and pruning routes you have removed from the code. Unknown paths get a 404 at the edge without invoking the function. This is also the endpoint you need for a custom domain.

Switching to api_gateway is an opt-in in cordless.toml:

[deploy]
endpoint = "api_gateway"

cordless deploy and cordless doctor both list the registered routes either way.

@cog.route works the same as @bot.route and takes effect when the cog is added:

from cordless import Cog
cog = Cog()
@cog.route("GET", "/health")
async def health(event, bot):
return {"ok": True}
bot.add_cog(cog)

cordless dev serves routes alongside interactions. GET, POST, PUT, PATCH, and DELETE are all handled.

Terminal window
curl -X POST "http://127.0.0.1:8787/gh/cordless/hook" \
-H "Content-Type: application/json" \
-d '{"ref": "main"}'

testing.invoke_route dispatches a route through the bot’s real router with no HTTP layer:

from asyncio import run
from cordless import testing
def test_health():
resp = run(testing.invoke_route(bot, "GET", "/health"))
assert resp.status == 200
assert resp.body == {"ok": True}

It returns a RouteResponse(status, body, headers). body is decoded from JSON when the response is JSON, otherwise returned as text.