Inbound webhook¶
The inbound-webhook source lets an external system push transactions into kasas by POSTing them to an HTTP endpoint, instead of kasas pulling on a schedule. It is the ingestion mirror of kasas's outbound webhooks — and it reuses the exact same HMAC signing scheme, so one kasas instance's outbound webhook can feed another's inbound source unchanged.
Source: internal/sources/webhook.
Design: ADR 0008.
How it works¶
The inbound webhook is a webhook archetype
source — the first push source in kasas. Where a pull source implements
Fetch and the engine calls it on a timer, this source implements Receive: an
external POST to its ingest endpoint hands it the request, it authenticates and
parses the body into the neutral ImportBatch,
and the engine persists that batch through the same path every source uses —
idempotent dedup, events, rule auto-labeling,
history, and provenance
stamping all included.
- Pushed, not polled. There is no schedule and no "Sync now" — a delivery is
ingested the instant it arrives. The run still appears in sync history
and emits a
sync.completedevent, like a scheduled sync. - Namespaced ids. Accounts and transactions are stored under
webhook:<id>, so ids never collide with another source's. The engine deduplicates by id, so re-delivering the same batch is safe (it updates, never duplicates). - Stable ids for unkeyed rows. If a transaction omits
external_id, the source derives a deterministic content hash from its fields, so retries still dedupe. - Provenance. Every pushed transaction is stamped
source = "webhook", immutable thereafter.
Inert until you generate a secret
The source is always registered, but it rejects every delivery until you generate its signing secret. An instance you never opt into exposes nothing.
Authentication¶
The ingest endpoint is internet-reachable and does not use the dashboard token (the sender does not have it). Instead, every request must carry an HMAC-SHA256 signature computed with a shared secret kasas mints for you — the identical scheme kasas uses to sign its outbound webhooks:
| Header | Value |
|---|---|
X-Kasas-Timestamp |
The Unix time (seconds) you signed at. |
X-Kasas-Signature |
sha256= + hex HMAC_SHA256(secret, "<timestamp>.<body>"). |
The source recomputes the signature over the raw request body and compares in
constant time. It also rejects a timestamp more than 5 minutes from its clock
(replay resistance — and re-delivery is idempotent anyway). Any failure — missing or
malformed signature, wrong secret, stale timestamp — returns a coarse 401 that
never reveals which check failed.
Generating the secret¶
Generate (and later rotate or reveal) the secret from the Sources → Inbound webhook page, or over the API/MCP:
Open Sources → Inbound webhook, click Generate signing secret, and copy the secret and the ingest URL into your sender. Use Rotate secret to replace it (the old one stops working immediately) or Reveal secret to copy it again.
# Mint (or rotate) the secret — admin (dashboard token) only.
curl -X POST https://kasas.example/api/v1/sources/webhook/secret/rotate \
-H "Authorization: Bearer $KASAS_TOKEN"
# -> {"secret":"whsec_…","ingest_path":"/api/v1/sources/webhook/ingest"}
# Reveal the current secret later.
curl https://kasas.example/api/v1/sources/webhook/secret \
-H "Authorization: Bearer $KASAS_TOKEN"
Call rotate_source_secret (or reveal_source_secret) with
{"type": "webhook"}. Returns the secret and the ingest path.
Revealing and rotating the secret are admin-only (the dashboard token, never an API key) — the secret is the source's entire auth boundary.
Sending a delivery¶
POST a ImportBatch JSON to the
ingest endpoint. You set the account(s) and their transactions; the source field
is ignored (kasas always stamps webhook).
{
"accounts": [
{
"external_id": "checking",
"name": "Main checking",
"currency": "USD",
"balance": "1250.00",
"org": { "name": "Acme Bank" },
"transactions": [
{
"external_id": "txn-2026-06-26-001",
"amount": "-12.50",
"date": 1750000000,
"payee": "Blue Bottle",
"description": "Coffee",
"memo": "",
"pending": false
}
]
}
]
}
amountis a decimal string, negative for money out (kasas never round-trips amounts through a float).dateis Unix seconds.external_idon an account/transaction is optional but recommended — provide it and re-delivering updates the same row; omit it and kasas derives a stable id from the transaction's content.- Restate the account's
balanceon each delivery if you want it kept current; an omitted balance is stored blank.
A valid delivery returns 202 Accepted with a summary
({"status":"accepted","accounts":…,"new_transactions":…,"updated_transactions":…}).
An empty body, {}, or {"accounts":[]} is an accepted no-op (handy as a
verification ping). The body is capped at 1 MiB.
Worked example: signing a request¶
SECRET="whsec_…" # from the rotate/reveal step
BODY='{"accounts":[{"external_id":"checking","name":"Main checking","currency":"USD","transactions":[{"external_id":"t1","amount":"-12.50","date":1750000000,"payee":"Blue Bottle"}]}]}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST https://kasas.example/api/v1/sources/webhook/ingest \
-H "X-Kasas-Timestamp: $TS" \
-H "X-Kasas-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
import hashlib, hmac, time, requests
secret = "whsec_…"
body = '{"accounts":[{"external_id":"checking","name":"Main checking","currency":"USD",' \
'"transactions":[{"external_id":"t1","amount":"-12.50","date":1750000000,"payee":"Blue Bottle"}]}]}'
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
requests.post(
"https://kasas.example/api/v1/sources/webhook/ingest",
data=body,
headers={
"X-Kasas-Timestamp": ts,
"X-Kasas-Signature": "sha256=" + sig,
"Content-Type": "application/json",
},
)
Sign the exact bytes you send
The signature is over the raw body, so sign the precise byte string you put
on the wire — do not re-serialize or pretty-print it after signing, or the
signature will not match and the delivery is rejected with 401.
Troubleshooting¶
| Symptom | Cause |
|---|---|
401 unauthorized |
No secret generated yet; wrong secret; signature over re-serialized/modified body; timestamp more than 5 minutes off; missing X-Kasas-* header. |
413 payload too large |
Body exceeds the 1 MiB cap — split into multiple deliveries. |
400 could not ingest delivery |
Authenticated but unusable: malformed JSON, or an account with neither external_id nor name. |
404 unknown source |
The path type is wrong — the endpoint is /api/v1/sources/webhook/ingest. |
Successful deliveries show up on the Transactions page, in sync history,
and on the event stream as transaction.created (and
sync.completed).
Surfaces¶
| Surface | What you get |
|---|---|
| REST | POST /api/v1/sources/webhook/ingest (open, HMAC-verified); GET /api/v1/sources/webhook/secret and POST …/secret/rotate (admin). |
| MCP | reveal_source_secret, rotate_source_secret; the source also appears in list_sources. |
| Dashboard | Sources → Inbound webhook: the ingest URL, and generate / reveal / rotate the secret. |