Quickstart
This quickstart connects an existing HTTP backend to Relay. The backend receives a signed webhook when the user sends a text and replies with one POST.
-
Create an agent in the Relay dashboard.
Open Developers -> Agents -> New agent and enter:
- Display name:
Scheduler - Handle:
scheduler - Webhook URL:
https://your-domain.example/relay/webhook - Capabilities:
text,file,image,link_preview,card,buttons
Relay returns:
{"agent_id": "agt_01JZ8A1Relay","handle": "scheduler","api_key": "relay_live_your_api_key","webhook_secret": "whsec_your_webhook_secret"} - Display name:
-
Receive
message.received.Relay sends the event below to your webhook URL. Return any
2xxresponse quickly, then run your agent work.{"id": "evt_01JZ8F2W6R4S7E2D3Q9M0N1P2A","type": "message.received","created_at": "2026-07-06T17:24:00Z","agent": {"id": "agt_01JZ8A1Relay","handle": "scheduler"},"conversation": {"id": "cnv_01JZ8D9H3J7K4M2N1P6Q8R0S2T","kind": "direct","relay_user_id": "usr_01JZ8C0X2B5N7M9Q1R3S4T6V8W","thread_id": null,"created_at": "2026-07-06T17:20:00Z"},"message": {"id": "msg_01JZ8F2W7A8B9C0D1E2F3G4H5J","sender": {"kind": "user","id": "usr_01JZ8C0X2B5N7M9Q1R3S4T6V8W","display_name": "Advait"},"created_at": "2026-07-06T17:24:00Z","reply_to_message_id": null,"content": [{"id": "blk_01","type": "text","text": "Can you look at this PDF?"},{"id": "blk_02","type": "file","attachment_id": "att_01JZ8F4K9M2N6P8Q0R1S3T5V7W","name": "labs.pdf","mime_type": "application/pdf","size_bytes": 241882,"download_url": "https://cdn.relay.app/signed/att_01JZ8F4K9M2N6P8Q0R1S3T5V7W?expires=1800"}],"fallback_text": "Can you look at this PDF?"},"capabilities": {"content_blocks": ["text", "image", "audio", "video", "file", "link_preview", "card", "buttons"],"typing": true,"receipts": true,"streaming": ["http_stream", "message_delta_events"]}} -
Reply with
POST /v1/conversations/{conversationId}/messages.Use the
conversation.idfrom the webhook. Always send anIdempotency-Keyso retries do not create duplicate visible messages.
export RELAY_API_KEY="relay_live_your_api_key"export RELAY_CONVERSATION_ID="cnv_01JZ8D9H3J7K4M2N1P6Q8R0S2T"
curl -sS "https://api.relay.app/v1/conversations/${RELAY_CONVERSATION_ID}/messages" \ -H "Authorization: Bearer ${RELAY_API_KEY}" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-$(date +%s)" \ -d '{ "content": [ { "type": "text", "text": "I can review it. The PDF is attached to this thread." }, { "type": "actions", "layout": "quick_replies", "actions": [ { "actionId": "act_summarize", "label": "Summarize", "behavior": { "type": "postback", "value": "summarize" } }, { "actionId": "act_extract_values", "label": "Extract values", "behavior": { "type": "postback", "value": "extract_values" } } ] } ], "fallbackText": "I can review it. Summarize or extract values?", "replyToMessageId": "msg_01JZ8F2W7A8B9C0D1E2F3G4H5J" }'import jsonimport osimport timeimport urllib.request
api_key = os.environ["RELAY_API_KEY"]conversation_id = os.environ["RELAY_CONVERSATION_ID"]
payload = { "content": [ {"type": "text", "text": "I can review it. The PDF is attached to this thread."}, { "type": "actions", "layout": "quick_replies", "actions": [ { "actionId": "act_summarize", "label": "Summarize", "behavior": {"type": "postback", "value": "summarize"}, }, { "actionId": "act_extract_values", "label": "Extract values", "behavior": {"type": "postback", "value": "extract_values"}, }, ], }, ], "fallbackText": "I can review it. Summarize or extract values?", "replyToMessageId": "msg_01JZ8F2W7A8B9C0D1E2F3G4H5J",}
request = urllib.request.Request( f"https://api.relay.app/v1/conversations/{conversation_id}/messages", data=json.dumps(payload).encode("utf-8"), headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Idempotency-Key": f"quickstart-{int(time.time())}", }, method="POST",)
with urllib.request.urlopen(request) as response: print(response.read().decode("utf-8"))const apiKey = process.env.RELAY_API_KEY;const conversationId = process.env.RELAY_CONVERSATION_ID;
if (!apiKey || !conversationId) { throw new Error("Set RELAY_API_KEY and RELAY_CONVERSATION_ID");}
const response = await fetch( `https://api.relay.app/v1/conversations/${conversationId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": `quickstart-${Date.now()}`, }, body: JSON.stringify({ content: [ { type: "text", text: "I can review it. The PDF is attached to this thread.", }, { type: "actions", layout: "quick_replies", actions: [ { actionId: "act_summarize", label: "Summarize", behavior: { type: "postback", value: "summarize" }, }, { actionId: "act_extract_values", label: "Extract values", behavior: { type: "postback", value: "extract_values" }, }, ], }, ], fallbackText: "I can review it. Summarize or extract values?", replyToMessageId: "msg_01JZ8F2W7A8B9C0D1E2F3G4H5J", }), },);
if (!response.ok) { throw new Error(`Relay returned ${response.status}: ${await response.text()}`);}
console.log(await response.json());Minimal webhook server
Section titled “Minimal webhook server”This server receives Relay webhooks, verifies the signature, and echoes the text back to the same conversation.
import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http";
const relayApiKey = process.env.RELAY_API_KEY;const webhookSecret = process.env.RELAY_WEBHOOK_SECRET;
if (!relayApiKey || !webhookSecret) { throw new Error("Set RELAY_API_KEY and RELAY_WEBHOOK_SECRET");}
function verifySignature(rawBody: Buffer, timestamp: string, signature: string) { const expected = createHmac("sha256", webhookSecret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const received = signature.replace(/^v1=/, ""); return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));}
createServer(async (request, response) => { if (request.method !== "POST" || request.url !== "/relay/webhook") { response.writeHead(404).end(); return; }
const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); const rawBody = Buffer.concat(chunks);
const timestamp = request.headers["relay-timestamp"]; const signature = request.headers["relay-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string") { response.writeHead(400).end("Missing Relay signature headers"); return; } if (!verifySignature(rawBody, timestamp, signature)) { response.writeHead(401).end("Invalid signature"); return; }
const event = JSON.parse(rawBody.toString("utf-8")); response.writeHead(204).end();
if (event.type !== "message.received") return;
const conversationId = event.conversation.id; const text = event.message.fallback_text || "Received your message.";
await fetch(`https://api.relay.app/v1/conversations/${conversationId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${relayApiKey}`, "Content-Type": "application/json", "Idempotency-Key": `echo-${event.id}`, }, body: JSON.stringify({ content: [{ type: "text", text: `Echo: ${text}` }], fallbackText: `Echo: ${text}`, }), });}).listen(8787, () => { console.log("Relay webhook listening on http://localhost:8787/relay/webhook");});