Skip to content

How webhooks work

Slash sends an HTTP POST to the bot every time something changes (transaction created/updated, card lifecycle event). The bot processes it within seconds and posts a card in your treasurer channel.

The flow

Brother taps card at WALMART
Slash authorizes the charge
Slash POSTs to https://bot.tke-kh-cryso.site/webhook/slash
   body: {"entityId": "agg_tx_abc", "event": "aggregated_transaction.create",
          "eventId": "evt_xyz"}
   header: slash-webhook-signature: <RSA signature>
Caddy terminates TLS, reverse-proxies to localhost:8080
Bot's aiohttp server receives the POST at /webhook/slash
Verify signature against Slash's pinned RSA public key — reject with 401 if invalid
Insert eventId into webhook_events collection — if duplicate, skip
Return 200 to Slash (must be within 10s — they retry otherwise)
Background task: re-fetch full transaction via GET /transaction/{id}
Upsert into local Mongo mirror; classify MCC; resolve card holder
Build Discord embed with [✏️ Add Details] / [📎 Attach Receipt] buttons
Post in treasurer channel — or edit existing message if this is an update

End-to-end latency: ~2–3 seconds.

Why thin events + re-fetch

The Slash payload is intentionally tiny — {entityId, event, eventId} — and never carries entity state. Forces us to re-fetch on every event, which:

  • Always reflects current state (no stale data)
  • Sidesteps event-ordering races (if create + update arrive out of order, the second fetch wins regardless)
  • Means our Mongo mirror always matches Slash's source of truth, no diff logic needed

Idempotency

Duplicate webhook deliveries are common with at-least-once delivery semantics. We dedupe via the webhook_events collection — eventId is the _id, so a second insert is a no-op.

A transaction might also be processed multiple times (webhook + hourly catch-up sync + /sync). The DB is idempotent on _id (the Slash transaction ID), so all paths just upsert.

Signature verification

Slash signs every webhook with their private RSA key. We verify against their published public key using RSA-SHA256.

Pinned in slash/webhook_verify.py. If Slash ever rotates the key, we'd need to update that constant.

Retry behavior

Slash retries up to 12 times with exponential backoff if we don't return 2xx within 10 seconds. After the 12th failure, the endpoint (not just one event) enters a backing-off state and queues new events.

If 6 consecutive notification batches permanently fail, the endpoint flips to disabled and stops firing entirely until manually re-enabled.

To watch for this on the Slash side:

  • Slash dashboard → Webhooks → endpoint status
  • Or call GET /webhook from any tool

Backup: hourly catch-up sync

Even with webhooks, we run an hourly transaction sync that pages through Slash's /transaction endpoint and upserts anything new. This means:

  • Even if all webhooks fail for an hour, we lose at most an hour of freshness
  • After re-enabling a disabled endpoint, missed events get backfilled automatically by the next hourly sync

The catch-up sync also runs once at bot startup (60 days back) so cold-starts are seeded with recent activity.

What events trigger Discord posts

Event Posts in channel?
aggregated_transaction.create yes — new card
aggregated_transaction.update yes — edits existing card
card_creation.event yes — 🆕
card.update yes — 🔄
card.delete yes — 🗑️
expense_report.create/update no (we don't use expense reports)

For transactions, internal transfers and Slash fees are silently ingested but not posted (they're noise).

Webhook URL lifecycle

The bot manages webhook registration on Slash's side automatically. On every boot:

  1. List all webhooks (GET /webhook)
  2. For any webhook named "Cryso Calc Bot" with a URL different from WEBHOOK_PUBLIC_URL, archive it
  3. If a webhook already matches the current URL, use it
  4. Otherwise create a new one

This means cycling the URL (e.g. ngrok session restart, server migration) is a no-op for you — just update WEBHOOK_PUBLIC_URL and restart. Stale webhooks self-clean.