Skip to content

Troubleshooting

Start from the symptom. Each row links to a runbook on this page or to the page that owns the fix.

Symptom Start at
Opened a PR — nothing in Slack No message for a new PR
Webhook delivery returns 401 Webhook returns 401
Delivery returns 200 but Slack didn't change 200 OK, no Slack change
A review happened but no reaction appeared No reaction on a review
Server exits immediately on startup Server exits at startup
Server boots but logs startup validate warning Startup validate warnings
HTTPS / certificate not issued Certificate failures
Database errors, or the DB "disappeared" after an upgrade Database issues
validate / reconcile gets 401 from the Bitbucket API Bitbucket API 401
Digest never posts, lists already-merged PRs, or posts more than expected Digest surprises

Not sure where the problem is? Run the preflight tools first — they localize most faults in under a minute.

No message for a new PR

You opened a pull request and the channel stayed silent. Two quick checks before any webhook plumbing:

  • Draft PRs are silent by design. A draft posts nothing until it's marked ready for review.
  • Dependabot and Renovate PRs look different. They post one compact line instead of the "please review" message — easy to scan past. See What you see in Slack.

Neither applies? Open the webhook's delivery history on the git host (GitHub: repository Settings → Webhooks → Recent Deliveries; Bitbucket: Repository settings → Webhooks → View requests). What you find there picks the runbook:

Delivery history shows Meaning Runbook
No delivery at all The git host never sent it — no webhook covers this repository, or the PR events aren't subscribed. Boot logs startup validate warning for exactly this case when a read token is set. Register or fix the webhook: GitHub · Bitbucket
Delivery failed — timeout or connection error The request never reached the server: DNS, ingress, or TLS. curl -i https://your-domain/healthz, then Certificate failures
401 The signature check failed. Webhook returns 401
200, but still no message The server received it and deliberately ignored it — usually no_mapping. 200 OK, no Slack change

Webhook returns 401

401 means the HMAC-SHA256 signature check failed before the payload was even parsed: the secret configured on the webhook doesn't match the one in .env (GITHUB_WEBHOOK_SECRET or BITBUCKET_WEBHOOK_SECRET).

  1. Copy the exact secret from the webhook settings page — no trailing whitespace, and byte-identical in both places. Paste, don't retype.
  2. In .env, store it unquoted (Bitbucket) or single-quoted if it contains shell-special characters (GitHub via Compose): quotes that reach the value become part of the secret and break the HMAC.
  3. Restart after editing: docker compose restart notifycat.
  4. Redeliver the failed event from the webhook's delivery history and confirm a 200.

Two provider-specific traps: Bitbucket sends no signature header at all when the webhook's secret field is blank — the secret is mandatory, not optional. And on either provider, a proxy that rewrites the request body invalidates the signature; the bytes must arrive exactly as signed.

To avoid this class of failure entirely, generate secrets exactly as described in Generating the webhook secret — a hand-made secret with $, #, quotes, or spaces is the classic cause of a 401 that survives careful re-pasting. Also remember that a deployment has exactly one webhook secret: if several repository webhooks were created with different secrets, the mismatched ones 401 while the rest work, and validate cannot catch it (the git host's API never returns a webhook's secret).

200 OK, no Slack change

The git host records a successful delivery whenever Notifycat returns 200 — including when the event is intentionally ignored. Every silent no-op leaves a structured log line, ignored webhook event, with a reason field, so this question is answerable from logs alone:

docker compose logs notifycat | grep "ignored webhook event"   # or: ./notifycat logs
reason Level Meaning Fix
no_handler Debug No handler for this event kind — fires for synchronize (kind=updated), reopened (kind=reopened), labeled, and other unmapped deliveries. Expected. Set server.log_level: debug to see these.
no_mapping Warn The repository isn't covered by any mappings: entry. Add the repository (or an org "*" tier) — see routing — or remove the webhook from that repository.
no_stored_message Info The handler found no Slack message row for this PR — common when the PR predates Notifycat or the row was cleaned up. Toggle the PR to draft and back to re-announce it, or wait for the next PR.
already_sent Info OpenHandler saw an existing message — idempotency. Expected on ready_for_review after a prior opened. Nothing to fix.

Every line also carries handler, provider, kind (the provider-neutral event classification — opened, merged, changes_requested, unknown, …), repository, and pr, so you can slice by any of them in a log aggregator.

If there's no ignored webhook event line at all, the delivery didn't reach the server — check the webhook's delivery history for the response code and your ingress logs.

No reaction on a review

You expected ✅ / 💬 / ❗ on the PR message and nothing landed:

  1. Check the logs at debug level for skipped bot reviewer reaction. If it's there, bot suppression fired: reviews.ignore_ai_reviews is true and the reviewer's account is a bot/app. Either accept that (it's the configured policy) or turn suppression off — see bot reviewers.
  2. No suppression line? Work through 200 OK, no Slack change — the usual culprit is no_stored_message on a PR that predates Notifycat.
  3. Reactions also fail when the bot isn't a member of the channel. chat:write.public covers posting, but reactions.add needs membership — /invite @notifycat in the channel, then notifycat-config validate to confirm.

Server exits at startup

Notifycat fails fast on configuration it can't trust. docker compose logs notifycat (or the process output) names the cause:

Log says Cause Fix
startup validation failed for N entries A mapping failed its mapping, channel-format, slack-auth, or slack-channel check at boot. Webhook problems never appear here — they warn instead (see Startup validate warnings). Run notifycat-config validate for per-entry detail; fix the entries in config.yaml. The remediation table is under CLI → validate.
these env vars are no longer read Pre-0.17 environment variables still set. Remove them; their values now live in config.yaml. See the 0.17 migration.
required key git_provider is missing / git_provider … is invalid Config predates the provider switch. Add git_provider: github (or bitbucket) — one line, see Upgrading.
an error naming digest.schedule or digest.timezone Invalid cron expression or unrecognized IANA zone. Fix the value — digest config.
SLACK_BOT_TOKEN / webhook-secret missing A required secret is unset for the selected git_provider. Set it in .env — secrets reference.

Startup validate warnings

The server is up and healthy, but the log carries lines like:

level=WARN msg="startup validate warning" entry=acme/api check=webhook detail="no active webhook on acme/api points at notifycat; create one so PR events reach it"
level=WARN msg="startup validation completed with warnings" entries=acme/api

A warning means functionality is limited for that entry only — every other repository keeps posting normally. The webhook coverage check and wildcard org expansion probe state that lives on the git host, so they never abort startup.

detail says Meaning Fix
no active webhook on … points at notifycat No hook on that repository targets /webhook/<provider>, so its PR events never arrive. On GitHub, an organization-level webhook also looks like this — delivery works anyway (caveat). Create the webhook: GitHub · Bitbucket
webhook on … is missing event(s) … The hook exists but doesn't subscribe to every event the dispatcher consumes, so some transitions are silent. Edit the webhook and add the named events.
listing … hooks failed: … 403 … Coverage is unconfirmed: the read token's identity may read the repository but not list its hooks. Delivery is unaffected. Grant the identity write/admin on the repository (Bitbucket), or accept the warning.
list repos in <org>: … A "*" tier could not be expanded, so none of that org's repositories were validated this boot. Routing still works from config.yaml. Grant the read token access to the org's repositories, or retry if it was a rate limit.

Warned entries are excluded from config.lock on purpose, so the same warning reappears on every boot until it turns OK — an unfixed warning is never silently cached away.

Certificate failures

Applies to the Compose stack and the manual-Caddy VM install alike. First, read Caddy's own account of it:

docker compose logs caddy        # Compose
journalctl -u caddy              # host-installed Caddy
Symptom in the log Cause Fix
connection refused / timeout on the ACME challenge Port 80 blocked by a firewall or security group Open inbound TCP 80 to 0.0.0.0/0
no such host / NXDOMAIN DNS record not propagated yet Wait; check with dig +short notifycat.example.com
unauthorized DNS points at a different IP than this host Compare curl -s https://api.ipify.org on the host vs dig +short from elsewhere
rate limited Let's Encrypt's 5-failures-per-week limit hit Wait it out — or debug against the staging endpoint (below)

Port 80/443 already taken is its own failure: sudo ss -tlnp | grep ':80\|:443' and stop the conflicting nginx/apache2/leftover container.

Debugging without burning rate limits — point Caddy at the LE staging endpoint (same errors, no limits, cert untrusted by browsers). Add to the Caddyfile global block, restart Caddy, and remove before go-live:

{
    email ops@example.com
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

Database issues

unable to open database file: out of memory (14) — the parent directory of database.url doesn't exist or isn't writable by the container user. With file:/app/notifycat.db, the /app mount must be writable: run with --user $(id -u):$(id -g), or chown 65532:65532 the directory once.

permission denied on the named volume (UID 65532) — usually after restoring a backup into the volume. One-shot fix: docker run --rm -v notifycat_notifycat_data:/app alpine chown -R 65532:65532 /app (check the volume name with docker volume ls).

The database "disappeared" after docker compose pull && up — the data is almost never gone; it's at a path outside the volume. The notifycat_data volume mounts at /app, so only paths under /app persist — a database.url like file:/data/notifycat.db writes to the container's ephemeral layer and vanishes on recreate. Inspect the volume (docker run --rm -v notifycat_notifycat_data:/v alpine ls -laR /v), point database.url back at the file you find, and keep it under /app.

write lock tmp: … permission denied — a pre-0.4.0 image quirk; upgrade to :latest and see Upgrading → pre-0.4.0 layout.

Bitbucket API 401

A 401 from validate, doctor, or reconcile (not from webhook delivery) means the auth scheme doesn't match the token type — it's not a scope problem:

Token type Scheme BITBUCKET_AUTH_EMAIL
Repository / workspace access token Bearer must be unset
Scoped Atlassian API token (Free plan) Basic must be set

Mixing them — an access token with the email set, or an API token without it — is the classic cause. Full detail and a curl to isolate it: Bitbucket webhook setup → Access token & scopes.

Digest surprises

The digest never posts — first check the day: weekends are always skipped, and so are public holidays when digest.country is set. Both log a line you can grep for, and neither makes a Slack call:

INFO skipped digest schedule="0 9 * * *" reason=weekend date=2026-07-04 weekday=Saturday
INFO skipped digest schedule="0 9 * * *" reason=holiday date=2026-12-25 country=DE holiday="1. Weihnachtstag"

A schedule that deliberately targets a weekend (0 9 * * 6) will never fire — that is not configurable. See Weekends and holidays.

Otherwise, in order of likelihood: nothing was stuck (a channel with no stuck PRs gets no digest — working as intended); the schedule fired at 9am UTC while your team's morning is elsewhere (set digest.timezone); digest.enabled: false at the global or tier level; or the PRs you expected predate Notifycat — the digest only tracks PRs it announced.

The first digest lists long-merged PRs — rows created before the digest feature carry no open/closed marker. Run the one-time reconcile.

A digest posted on a public holiday — check the boot log for one of two warnings:

  • digest holidays not configured — digest.country is unset, so only weekends are skipped. Set it: supported set.
  • digest country not recognized — the code is not supported (Germany instead of DE, DE-BY instead of DE). The server keeps running weekends-only rather than failing startup, and the warning lists every accepted code.

If the country is set correctly and the day is a regional or company holiday rather than a national one, that is a known limit.

You expected a digest but nothing posts — the digest is off by default. Add digest: { enabled: true }; a block with only schedule: or timezone: stays off, and per-repository tiers need enabled: true too. See Stuck-PR digest.

The same channel gets several digests a day — two repository tiers with different digest.schedule values post to that channel; each schedule runs independently. Align the schedules or move one repository.

Verify the whole path

Three tools, in escalating order of realism:

notifycat-config validate            # per-mapping: Slack channel, scopes, membership, webhook events
notifycat-doctor                     # + runtime config, database, mappings file health
./notifycat smoke <org>/<repo>       # forges a real signed PR event through the running server → Slack

The doctor proves the configuration is sound; the smoke test proves the whole delivery path works — real signature check, real dispatcher, real Slack post (a throwaway message titled [notifycat smoke] … appears in the mapped channel; delete it after). Add --reactions to replay a comment, approval, and merge against the synthetic PR and verify the emoji landed. See Doctor and CLI → notifycat-smoke.