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 /webhookfrom 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
disabledendpoint, 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:
- List all webhooks (
GET /webhook) - For any webhook named "Cryso Calc Bot" with a URL different from
WEBHOOK_PUBLIC_URL, archive it - If a webhook already matches the current URL, use it
- 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.