Developers
Webhooks Pro
Webhooks send an HTTP POST to a URL of yours every time something happens in a workspace: a page is created, a card moves to another list, someone writes in a group chat. Use them to keep other systems in sync, trigger automations or post updates to Slack without writing any code.
How webhooks work
- They fire for changes made anywhere: in the app, on the phone, through the API, from a plugin or by an AI assistant through MCP.
- They belong to a workspace. Each webhook has a URL, a list of events and its own signing secret.
- Each delivery is signed, so your server can check it really comes from Tylode.
- The body includes a ready-written
textfield, so a Slack Incoming Webhook URL works directly. - A webhook that fails never slows down or blocks what the person was doing in Tylode.
Webhooks are set up in the app; there's no API endpoint to create or manage them.
Create a webhook
You need to be an owner or admin of the workspace, and the workspace must be on Pro or Premium. To create or reactivate a webhook, your own account must be on Pro too (the effective plan is the lower of yours and the workspace's).
- Open Settings → API and webhooks → Webhooks (go there).
- Give it a name, for example "Slack #general".
- Paste the URL that will receive the events.
- Click the events you want. Page created and Card moved to another list are selected by default. Pick at least one.
- Click Create webhook.
- Click Test to check that your receiver accepts it.
The URL must:
- Start with
https://. Plainhttpisn't accepted. - Point to a public server. Addresses such as
localhost,127.0.0.1,10.x.x.x,192.168.x.x,172.16.x.xto172.31.x.x,169.254.x.xand names ending in.local,.internal,.localdomain,.homeor.lanare rejected.
To try webhooks while developing on your computer, expose your local server through an HTTPS tunnel (such as ngrok or Cloudflare Tunnel) and use the public URL it gives you.
Events
| Event | Label in the app | When it fires | data fields |
|---|---|---|---|
page.created | Page created | A page is created. Templates don't count. | page_id, title, created_by, parent_page_id |
page.updated | Page renamed | A page's title changes. Content edits don't fire it. | page_id, title, previous_title |
page.deleted | Page deleted | A page is deleted for good (emptied from the trash), not when it's moved to the trash. | page_id, title |
card.created | Card created | A card is created on a board. | card_id, board_id, list_id, title, list_name, board_name |
card.moved | Card moved to another list | A card changes list. Reordering within the same list doesn't fire it. | card_id, board_id, from_list_id, to_list_id, title, list_name (the new list), board_name |
message.created | Message in a group | Someone writes in a group conversation. Direct messages are never sent. | message_id, conversation_id, conversation_name, sender_id, body |
member.joined | Someone joins the workspace | A person becomes a member of the workspace. | user_id, role |
comment.created | Comment on a page | Someone comments on a block of a page. | comment_id, page_id, block_id, page_title, body, created_by |
test | (the Test button) | You click Test. | subscription_id, name |
IDs are UUIDs. created_by, sender_id and user_id are user IDs. role is owner, admin, member or viewer.
Payload
Every delivery is a POST with Content-Type: application/json and this envelope:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Unique ID of this delivery. Same as the X-Verso-Delivery header. |
event | string | The event name, like card.moved. |
workspace_id | string (UUID) | The workspace where it happened. |
occurred_at | string | When it happened, ISO 8601 in UTC. |
text | string | A ready-made sentence describing the event, in Spanish, for chat tools like Slack. Don't parse it; use data. |
data | object | The event details. Fields depend on the event (see the table above). |
Tylode sends the JSON on a single line. The examples below are formatted for reading and keep the order in which the keys arrive, which you shouldn't depend on.
page.created
{
"id": "5f0c2e9a-7b1d-4c3e-9a8f-2d6b4e1c7a90",
"data": {
"title": "Q4 roadmap",
"page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4",
"created_by": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14",
"parent_page_id": null
},
"text": "Ana García creó la página «Q4 roadmap»",
"event": "page.created",
"occurred_at": "2026-09-23T08:30:12.418532+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}card.moved
{
"id": "b81e4d2c-6a3f-4f0e-8c95-1d7a2e6b3f48",
"data": {
"title": "Review contract",
"card_id": "c4e7a1b9-2d5f-4a8c-b3e6-0f9d1a7c5e22",
"board_id": "a3b5c7d9-1e2f-4a6b-8c0d-2e4f6a8b0c13",
"list_name": "Done",
"board_name": "Launch",
"to_list_id": "f1a3c5e7-9b2d-4f6a-8c1e-3b5d7f9a1c35",
"from_list_id": "e0f2a4c6-8d1b-4e5f-9a7c-2b4d6f8a0c24"
},
"text": "Ana García movió «Review contract» a Done",
"event": "card.moved",
"occurred_at": "2026-09-23T09:02:47.105328+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}message.created
{
"id": "0d6f3b8e-2c4a-4e1f-b7d9-5a8c1e3f6b71",
"data": {
"body": "Shall we review the demo at 5?",
"sender_id": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14",
"message_id": "3e5a7c9e-1b3d-4f5a-9c7e-4d6f8a0b2c46",
"conversation_id": "6c8e0a2c-4d6f-4a8b-8e0a-5f7b9d1e3a57",
"conversation_name": "Product team"
},
"text": "Ana García en Product team: Shall we review the demo at 5?",
"event": "message.created",
"occurred_at": "2026-09-23T09:15:03.772910+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}In text, the message is cut to its first 280 characters; data.body always has the full message.
test
{
"id": "7a9c1e3a-5b7d-4f9a-8c2e-6d8f0b2d4e68",
"data": {
"name": "Slack #general",
"subscription_id": "8b0d2f4b-6c8e-4a0b-9d3f-7e9a1c3e5f79"
},
"text": "Evento de prueba desde Tylode. Si lees esto, el webhook funciona.",
"event": "test",
"occurred_at": "2026-09-23T09:20:00.051244+00:00",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37"
}Other events
The other events use the same envelope. Their data looks like this:
{ "title": "Q4 plan", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4", "previous_title": "Q4 roadmap" }{ "title": "Old draft", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4" }{ "title": "Review contract", "card_id": "c4e7a1b9-2d5f-4a8c-b3e6-0f9d1a7c5e22", "list_id": "e0f2a4c6-8d1b-4e5f-9a7c-2b4d6f8a0c24", "board_id": "a3b5c7d9-1e2f-4a6b-8c0d-2e4f6a8b0c13", "list_name": "To do", "board_name": "Launch" }{ "role": "member", "user_id": "4f6a8c0e-2b4d-4f6a-8c0e-2b4d6f8a0c91" }{ "body": "Can we close this on Friday?", "page_id": "2b9d6f1e-8a4c-4f3b-b7e2-51c0d9a8e6f4", "block_id": "d2f4a6c8-0e1b-4d3f-a5c7-9e1b3d5f7a02", "comment_id": "5b7d9f1b-3c5e-4a7c-9e1a-3c5e7a9c1e84", "created_by": "9d3f7a21-4c6e-4b8a-a1f2-3e5d7c9b0a14", "page_title": "Q4 plan" }In order: page.updated, page.deleted, card.created, member.joined and comment.created.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Verso-Event | The event name, like page.created. |
X-Verso-Delivery | The delivery ID, the same as id in the body. |
X-Verso-Signature | sha256= followed by the HMAC-SHA256 of the raw body, in hexadecimal. |
These header names are stable.
Verify the signature
Anyone who knows your URL could send it fake events, so check the signature before trusting a delivery:
- Find the signing secret: in Settings → API and webhooks → Webhooks, click Details on the webhook, then Show next to Signing secret. It's a 48-character hexadecimal string.
- Compute the HMAC-SHA256 of the raw request body, exactly as received, using the secret as the key. Use the secret as a plain string; don't decode it from hex.
- Write the result in lowercase hexadecimal and put
sha256=in front. - Compare it with
X-Verso-Signatureusing a constant-time comparison.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.TYLODE_WEBHOOK_SECRET; // the 48-character signing secret
function isValidSignature(rawBody, header) {
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
// timingSafeEqual throws if the lengths differ, so check them first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as a Buffer, exactly as it arrived.
app.post("/webhooks/tylode", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-Verso-Signature"))) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Answer first, then do the slow work: Tylode waits 5 seconds at most.
res.sendStatus(204);
handleEvent(event).catch(console.error);
});
async function handleEvent(event) {
switch (event.event) {
case "card.moved":
console.log(`${event.data.title} → ${event.data.list_name}`);
break;
case "test":
console.log("Test delivery received");
break;
}
}
app.listen(3000);Don't put express.json() in front of this route: it consumes the body before you can sign it.
Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TYLODE_WEBHOOK_SECRET"].encode() # the 48-character signing secret
@app.post("/webhooks/tylode")
def tylode_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Verso-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
event = request.get_json()
if event["event"] == "page.created":
print("New page:", event["data"]["title"])
return "", 204Next.js route handler
// app/api/webhooks/tylode/route.ts
import crypto from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text(); // raw body, before parsing
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.TYLODE_WEBHOOK_SECRET!).update(raw).digest("hex");
const received = request.headers.get("x-verso-signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// Keep this quick: Tylode waits 5 seconds at most.
console.log(event.event, event.id);
return new Response(null, { status: 204 });
}Delivery and failures
- Timeout: Tylode waits up to 5 seconds for your response. Reply with any
2xxstatus as soon as you've checked the signature, and do the slow work afterwards (a queue, a background job). - No retries: each event is sent once. If your server is down or answers with an error, that delivery is lost. If you can't miss anything, also reconcile from time to time with the API.
- Order: deliveries can arrive out of order. Use
occurred_atwhen order matters. - Recent deliveries: click Details on a webhook to see its last 25 deliveries, each with its event, time and result: the status code your server returned, an error, or In progress while waiting. A response can take up to a minute to show up. If nothing was recorded after an hour, the delivery counts as failed.
- Status at a glance: each webhook shows OK · 200, Error 500, No response, No deliveries yet or Disabled.
- Automatic disabling: after 10 failed deliveries in a row (anything that isn't
2xx, including timeouts), the webhook is disabled. Fix the receiver and click Resume to turn it back on. - History: deliveries are kept for 30 days.
- Plan: if the workspace drops below Pro, its webhooks stay configured but nothing is sent until it's back on Pro.
Test, pause and rotate
- Test sends a
testevent to the URL. It's the quickest way to check the URL, the signature and your firewall. Open Details after a few seconds to see the result. - Pause stops deliveries; Resume starts them again. Events that happen while a webhook is paused are never sent.
- Rotate (in Details, next to Signing secret) creates a new secret that's used from the next delivery. The old one stops working at once, so update your receiver straight away or it will reject what comes next.
- Delete (the trash icon, click again to confirm) removes the webhook and its history.
To change the URL or the events, create a new webhook and delete the old one.
Recipe: post to a Slack channel
Slack's Incoming Webhooks read the text field and post it to a channel, so you don't need any code in between.
- In Slack, create an app for your Slack workspace at api.slack.com/apps, open Incoming Webhooks and turn them on.
- Click Add New Webhook, choose the channel and copy the URL. It looks like
https://hooks.slack.com/services/T000/B000/XXXX. - In Tylode, open Settings → API and webhooks → Webhooks, name it (for example "Slack #general"), paste the Slack URL, choose the events and click Create webhook.
- Click Test. The message "Evento de prueba desde Tylode. Si lees esto, el webhook funciona." should appear in the channel.
Slack doesn't check the signature, and it posts only the text sentence, which is in Spanish. If you want another language, a different format or to filter events (for example, only cards that reach "Done"), point the webhook at your own small server instead, verify the signature, build your message from data and forward it to Slack.
Handle duplicates
Tylode doesn't resend deliveries, but your own infrastructure can: a proxy that retries, a queue that redelivers, a handler that runs twice. Make your handler idempotent by recording the delivery ID (id in the body, or the X-Verso-Delivery header) and skipping IDs you've already processed:
const seen = new Set(); // use a database or Redis in production
function alreadyProcessed(event) {
if (seen.has(event.id)) return true;
seen.add(event.id);
return false;
}Related pages
- Developer platform: the API, to reconcile or act on events.
- Recipes: ready-made automations.
- Members and roles: who can manage webhooks.