Use cases · guide
Monitoring All Cardano Governance Votes on Telegram
Every time a DRep, stake pool operator or constitutional committee member votes on a governance action, you get a Telegram message - within about a minute of the vote landing on-chain. No Cardano node, no indexer, no polling loop.
Cardano's on-chain governance moves fast when it moves at all: a proposal sits quiet for days, then a wave of votes decides it in an afternoon. If you follow governance - as a DRep, a delegator, a journalist or just an interested holder - a push notification beats refreshing an explorer.
One thing to understand up front: cardano.delivery cannot post directly to
Telegram. Our webhooks send a JSON event body, while Telegram's
sendMessage endpoint expects its own request format (chat_id,
text, and so on). Something has to reshape one into the other. That something
is a tiny relay, and a Cloudflare Worker is ideal for the job: about fifty lines, free at
this volume, and no server to keep alive.
What you will build
Cardano governance vote
↓
cardano.delivery vote webhook
↓
Cloudflare Worker (verify signature → format → forward)
↓
Telegram Bot API
↓
Telegram message on your phone
The webhook fires on detection - governance votes have no confirmation cascade, and votes are picked up from the most recent open proposals, typically within a minute of inclusion (see delivery semantics). The Worker verifies that the request really came from cardano.delivery, formats the vote into a readable line, and calls Telegram.
Prerequisites
- A free cardano.delivery account (or an API key if you prefer curl).
- A free Cloudflare account with
wranglerinstalled (npm install -g wrangler). - A Telegram account.
Step 1 · Create a Telegram bot
- In Telegram, open a chat with
@BotFather. - Send
/newbotand follow the prompts (pick any name and a unique username ending inbot). - BotFather replies with a bot token that looks like
123456789:AAE…. Save it - this isTELEGRAM_BOT_TOKEN.
Step 2 · Find your Telegram chat ID
- Send your new bot any message first (open its profile, press Start, type "hi"). Bots cannot message you until you have messaged them - this is the single most common setup mistake.
- Then read the update log:
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getUpdates"
In the JSON response, find result[0].message.chat.id - a number like
123456789 (negative for groups). That is TELEGRAM_CHAT_ID.
If result is empty, you skipped the "message the bot first" step.
Step 3 · Create the Cloudflare Worker
Scaffold a Worker project:
npm create cloudflare@latest governance-votes-bot -- --type hello-world cd governance-votes-bot
Replace src/index.js with the relay below. Before the code, three things it
does that matter:
- It reads the raw body before parsing. The signature is computed over the exact bytes cardano.delivery sent (
Delivery-Signature: t=<unix seconds>,v1=<hex>, wherev1is HMAC-SHA256 of"<t>.<raw body>"using your webhook's secret - see verifying signatures). Parsing first and re-serializing would break verification. - It rejects anything unsigned, stale, or not a
voteevent. - It escapes vote data before using Telegram HTML formatting, so an unexpected character in an on-chain value can never break or inject markup.
// src/index.js - cardano.delivery vote -> Telegram relay const te = new TextEncoder(); const hexToBytes = hex => Uint8Array.from(hex.match(/.{2}/g) ?? [], b => parseInt(b, 16)); // constant-time comparison of two byte arrays const bytesEqual = (a, b) => { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; }; const verifySignature = async (rawBody, header, secret) => { if (!header) return false; const parts = Object.fromEntries( header.split(',').map(kv => kv.split('=')), ); if (!parts.t || !parts.v1) return false; // reject stale timestamps (replay protection); t is unix seconds if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 600) return false; const key = await crypto.subtle.importKey( 'raw', te.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign( 'HMAC', key, te.encode(`${parts.t}.${rawBody}`), ); return bytesEqual(new Uint8Array(mac), hexToBytes(parts.v1)); }; // escape anything interpolated into Telegram HTML const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const VOTE_ICON = { yes: '🟢', no: '🔴', abstain: '⚪' }; const formatVote = data => { // defensive: fields may be absent on unexpected payloads const vote = data?.vote ?? {}; const proposal = data?.proposal ?? {}; return [ `${VOTE_ICON[vote.vote] ?? '🗳'} <b>${esc(vote.vote ?? 'vote')}</b> by <b>${esc(vote.voter_role ?? '?')}</b>`, `<code>${esc(vote.voter ?? 'unknown voter')}</code>`, `on ${esc(proposal.governance_type ?? 'governance action')}`, `<code>${esc(proposal.id ?? '')}</code>`, ].join('\\n'); }; export default { async fetch(request, env) { if (request.method !== 'POST') { return new Response('method not allowed', { status: 405 }); } // 1. raw body FIRST - the signature covers these exact bytes const rawBody = await request.text(); // 2. verify it came from cardano.delivery const ok = await verifySignature( rawBody, request.headers.get('Delivery-Signature'), env.CARDANO_DELIVERY_SECRET, ); if (!ok) return new Response('invalid signature', { status: 401 }); const event = JSON.parse(rawBody); // 3. only governance vote events if (event.type !== 'vote') return new Response('ignored', { status: 200 }); // 4. vote payloads are single objects today; stay defensive anyway const items = Array.isArray(event.payload) ? event.payload : [event.payload]; const text = '🏛 Governance vote\\n\\n' + items.map(formatVote).join('\\n\\n'); // 5. forward to Telegram const tg = await fetch( `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text, parse_mode: 'HTML', disable_web_page_preview: true, }), }, ); if (!tg.ok) { // surface Telegram's error in `wrangler tail` and report failure, // so the delivery shows up as failed in your webhook history console.error('telegram error', tg.status, await tg.text()); return new Response('telegram failed', { status: 502 }); } return new Response('ok', { status: 200 }); }, };
payload.vote.{voter_role, voter, vote, tx_hash, cert_index}
and payload.proposal.{id, tx_hash, cert_index, governance_type}. Before adding
anything else, log one real delivery (console.log(rawBody) + wrangler tail)
and look at the actual payload.
Step 4 · Configure Worker secrets
Deploy once, then attach the three secrets (each command prompts for the value):
wrangler deploy wrangler secret put TELEGRAM_BOT_TOKEN wrangler secret put TELEGRAM_CHAT_ID wrangler secret put CARDANO_DELIVERY_SECRET
CARDANO_DELIVERY_SECRET is the webhook's HMAC secret - you get it in the next
step, so leave that command for after webhook creation (or run wrangler secret put
again to update it). wrangler deploy prints your Worker URL, e.g.
https://governance-votes-bot.your-name.workers.dev.
Step 5 · Create the cardano.delivery vote webhook
Via the dashboard:
- Open the dashboard and click New webhook.
- Trigger: governance vote. Target URL: your Worker URL. Leave conditions empty - you want every vote. (Confirmations are locked to 1 for governance triggers; they deliver on detection.)
- Save, then click Secret on the new webhook and copy the value into
wrangler secret put CARDANO_DELIVERY_SECRET.
Via the API (the response includes auth_token - that is the secret):
curl -X POST https://cardano.delivery/api/webhooks \ -H "Authorization: Bearer $CARDANO_DELIVERY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "governance votes to telegram", "type": "vote", "target_url": "https://governance-votes-bot.your-name.workers.dev", "confirmations": 1, "conditions": [] }'
Step 6 · Test the integration
Governance votes are not constant, so do not wait for a real one to validate your setup. Send yourself a signed test event: compute the signature the same way the platform does and POST it to your Worker (requires Node 18+):
BODY='{"id":"test-event","webhook_id":"test","created":0,"api_version":1,"type":"vote","payload":{"vote":{"tx_hash":"aa","cert_index":0,"voter_role":"drep","voter":"drep1test","vote":"yes"},"proposal":{"id":"gov_action1test","tx_hash":"bb","cert_index":0,"governance_type":"info_action"}}}'
T=$(date +%s)
SIG=$(node -e "const c=require('crypto');console.log(c.createHmac('sha256',process.argv[1]).update(process.argv[2]+'.'+process.argv[3]).digest('hex'))" "$SECRET" "$T" "$BODY")
curl -X POST "$WORKER_URL" -H "Delivery-Signature: t=$T,v1=$SIG" \
-H "Content-Type: application/json" -d "$BODY"
A Telegram message should arrive immediately. When real votes flow, the webhook's History panel in the dashboard shows each delivery with the Worker's response code.
Filter which votes you receive
With no conditions, the webhook fires for every detected vote. All four vote
condition fields support = and !=, and conditions combine with
AND (full reference: conditions):
| Condition | Values | Example use |
|---|---|---|
voterRole | drep, spo, constitutional_committee | only committee votes |
voter | a DRep id, pool id or CC hot key | follow one specific DRep |
vote | yes, no, abstain | only no votes |
govActionId | gov_action1… | one proposal you care about |
jsonPath | any payload field | everything else |
Because conditions AND together, "votes by DRep A or DRep B" means two webhooks (both can point at the same Worker).
Prevent duplicate notifications
If your Worker is slow or briefly down, cardano.delivery retries the delivery - and the
platform's own at-least-once semantics mean the same logical event can occasionally be sent
twice. Event ids are deterministic (a retried or re-emitted
event carries the same id), so deduplication is one KV lookup. Create a
namespace and bind it:
wrangler kv namespace create SEEN # add the printed binding to wrangler.toml, then in fetch(), after parsing: if (await env.SEEN.get(event.id)) return new Response('duplicate', { status: 200 }); await env.SEEN.put(event.id, '1', { expirationTtl: 86400 });
A 24-hour TTL is plenty - retries happen within minutes. For most personal bots the occasional duplicate is harmless and you can skip this section entirely.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Telegram getUpdates returns an empty result | You never messaged the bot. Open its chat, press Start, send anything, retry. |
| Bot works in tests but messages never arrive | Wrong TELEGRAM_CHAT_ID (typo, or a group id missing its leading -). Re-run getUpdates and copy the exact number. |
Telegram returns 401 Unauthorized | Bad TELEGRAM_BOT_TOKEN - re-copy from BotFather, re-run wrangler secret put. |
Telegram returns 400 Bad Request | Usually malformed HTML in text (unescaped <) or an invalid chat_id. The Worker logs Telegram's error body - read it with wrangler tail. |
| Webhook history shows non-2xx deliveries | Your Worker returned an error (often 401 signature or 502 Telegram). wrangler tail shows why. Note: a webhook failing on more than 50 attempts across 48h with under 10% success is disabled automatically - fix the Worker, then re-enable it in the dashboard. |
Every delivery fails with invalid signature | Wrong secret (did you rotate it in the dashboard without updating the Worker?), or your code parsed the body before verifying. Verify over the raw body, and check clock skew if t is rejected. |
| No votes ever arrive | Conditions too restrictive (remember: AND), or genuinely no votes right now - votes are watched on the most recent open proposals. Check the History panel: no rows means no matched events, failed rows mean delivery problems. |
| The same vote arrives twice | Delivery retry after a timeout. Implement the KV dedupe above - the event id is stable across retries. |
Next steps
The final architecture, working end to end:
vote on-chain → detected ≤ ~1 min → vote webhook fires → Worker verifies Delivery-Signature over the raw body → formats + escapes → Telegram sendMessage → 🟢 yes by drep …
- Add a
proposalwebhook to the same Worker to announce new governance actions too (branch onevent.type). - Follow a specific DRep's full activity with a
drepwebhook (action = votedcatches their votes on any proposal, not just recent ones). - Point the Worker at a Telegram group or channel id to share the feed.
- More recipes: use cases · full API: API reference.