cardano.deliveryCardano webhooks

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

Step 1 · Create a Telegram bot

  1. In Telegram, open a chat with @BotFather.
  2. Send /newbot and follow the prompts (pick any name and a unique username ending in bot).
  3. BotFather replies with a bot token that looks like 123456789:AAE…. Save it - this is TELEGRAM_BOT_TOKEN.
Treat the bot token like a password. Anyone who has it can send messages as your bot. It goes into a Worker secret in step 4, never into code or git.

Step 2 · Find your Telegram chat ID

  1. 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.
  2. 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:

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

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 });
  },
};
Inspect before you customize. The formatter above only uses fields confirmed by the API docs: 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:

  1. Open the dashboard and click New webhook.
  2. 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.)
  3. 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):

ConditionValuesExample use
voterRoledrep, spo, constitutional_committeeonly committee votes
votera DRep id, pool id or CC hot keyfollow one specific DRep
voteyes, no, abstainonly no votes
govActionIdgov_action1…one proposal you care about
jsonPathany payload fieldeverything 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

SymptomCause and fix
Telegram getUpdates returns an empty resultYou never messaged the bot. Open its chat, press Start, send anything, retry.
Bot works in tests but messages never arriveWrong TELEGRAM_CHAT_ID (typo, or a group id missing its leading -). Re-run getUpdates and copy the exact number.
Telegram returns 401 UnauthorizedBad TELEGRAM_BOT_TOKEN - re-copy from BotFather, re-run wrangler secret put.
Telegram returns 400 Bad RequestUsually 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 deliveriesYour 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 signatureWrong 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 arriveConditions 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 twiceDelivery 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 …